Skip to main content

max / goingson

8.3 KB · 226 lines History Blame Raw
1 //! Database file watcher for detecting external changes.
2 //!
3 //! Watches the SQLite database file for modifications made by external
4 //! processes and emits Tauri events to trigger UI refreshes.
5
6 use notify::RecursiveMode;
7 use notify_debouncer_mini::new_debouncer;
8 use std::path::Path;
9 use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
10 use std::sync::Arc;
11 use std::time::Duration;
12 use tauri::{Emitter, Manager};
13 use tracing::{debug, error, info, warn};
14
15 /// Debounce duration for file change events (milliseconds)
16 const DEBOUNCE_MS: u64 = 500;
17
18 /// Minimum interval between emitted events (milliseconds)
19 /// Prevents rapid-fire refreshes even after debouncing
20 const MIN_EVENT_INTERVAL_MS: u64 = 1000;
21
22 /// Starts the database file watcher.
23 ///
24 /// Watches the SQLite database file and its WAL/SHM files for changes.
25 /// When changes are detected, emits a `db:external-change` event to the frontend.
26 pub fn start_db_watcher(app: tauri::AppHandle, shutdown: Arc<AtomicBool>) {
27 // Get the database path
28 let app_data_dir = match app.path().app_data_dir() {
29 Ok(dir) => dir,
30 Err(e) => {
31 error!("Failed to get app data dir for db watcher: {}", e);
32 return;
33 }
34 };
35
36 let db_path = app_data_dir.join("goingson.db");
37
38 if !db_path.exists() {
39 warn!(?db_path, "Database file does not exist yet, watcher will start when it's created");
40 }
41
42 info!(?db_path, "Starting database file watcher");
43
44 // Track last event time to prevent rapid-fire refreshes
45 let last_event_time = Arc::new(AtomicU64::new(0));
46 let last_event_time_clone = last_event_time.clone();
47
48 // Track whether a trailing event is pending (for changes that arrive too soon)
49 let pending_trailing = Arc::new(AtomicBool::new(false));
50 let pending_trailing_clone = pending_trailing.clone();
51
52 // Create a debounced watcher
53 let app_handle = app.clone();
54 let trailing_app_handle = app.clone();
55 let trailing_last_event_time = last_event_time.clone();
56 let trailing_pending = pending_trailing.clone();
57
58 // Trailing edge timer: emits a final event after MIN_EVENT_INTERVAL_MS
59 // if any changes were dropped during rate-limiting
60 let trailing_shutdown = shutdown.clone();
61 std::thread::spawn(move || {
62 loop {
63 std::thread::sleep(Duration::from_millis(MIN_EVENT_INTERVAL_MS));
64
65 if trailing_shutdown.load(Ordering::Relaxed) {
66 info!("Trailing timer shutting down");
67 break;
68 }
69
70 if trailing_pending.swap(false, Ordering::Relaxed) {
71 let now = std::time::SystemTime::now()
72 .duration_since(std::time::UNIX_EPOCH)
73 .map(|d| d.as_millis() as u64)
74 .unwrap_or(0);
75
76 trailing_last_event_time.store(now, Ordering::Relaxed);
77
78 debug!("Emitting trailing db:external-change event");
79 if let Err(e) = trailing_app_handle.emit("db:external-change", ()) {
80 warn!("Failed to emit trailing db change event: {}", e);
81 }
82 }
83 }
84 });
85
86 let watcher_shutdown = shutdown;
87 std::thread::spawn(move || {
88 let (tx, rx) = std::sync::mpsc::channel();
89
90 let mut debouncer = match new_debouncer(Duration::from_millis(DEBOUNCE_MS), tx) {
91 Ok(d) => d,
92 Err(e) => {
93 error!("Failed to create file watcher: {}", e);
94 return;
95 }
96 };
97
98 // Watch the app data directory (contains db, wal, shm files)
99 if let Err(e) = debouncer.watcher().watch(&app_data_dir, RecursiveMode::NonRecursive) {
100 error!(?app_data_dir, "Failed to watch directory: {}", e);
101 return;
102 }
103
104 info!(?app_data_dir, "Database watcher started");
105
106 // Process file change events with shutdown checks
107 loop {
108 if watcher_shutdown.load(Ordering::Relaxed) {
109 info!("Database watcher shutting down");
110 break;
111 }
112
113 match rx.recv_timeout(Duration::from_secs(1)) {
114 Ok(Ok(events)) => {
115 // Check if any event is for our database files
116 let db_changed = events.iter().any(|event| {
117 is_db_file(&event.path, &db_path)
118 });
119
120 if db_changed {
121 // Check if enough time has passed since last event
122 let now = std::time::SystemTime::now()
123 .duration_since(std::time::UNIX_EPOCH)
124 .map(|d| d.as_millis() as u64)
125 .unwrap_or(0);
126
127 let last = last_event_time_clone.load(Ordering::Relaxed);
128
129 // saturating_sub: a backward wall-clock step (NTP, suspend/resume)
130 // would otherwise underflow this u64 and either panic in debug or
131 // wedge the rate limiter (ultra-fuzz Run #28).
132 if now.saturating_sub(last) >= MIN_EVENT_INTERVAL_MS {
133 last_event_time_clone.store(now, Ordering::Relaxed);
134
135 debug!("Database change detected, emitting db:external-change event");
136
137 // Emit event to frontend
138 if let Err(e) = app_handle.emit("db:external-change", ()) {
139 warn!("Failed to emit db change event: {}", e);
140 }
141 } else {
142 // Mark that a trailing event should fire
143 pending_trailing_clone.store(true, Ordering::Relaxed);
144 debug!("Rate-limited db change event, queued trailing emit");
145 }
146 }
147 }
148 Ok(Err(e)) => {
149 warn!("File watcher error: {:?}", e);
150 }
151 Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
152 // No events, loop back to check shutdown flag
153 continue;
154 }
155 Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
156 warn!("File watcher channel disconnected");
157 break;
158 }
159 }
160 }
161 });
162 }
163
164 /// Check if a path is one of the SQLite database files
165 fn is_db_file(path: &Path, db_path: &Path) -> bool {
166 let db_name = db_path.file_name().and_then(|n| n.to_str()).unwrap_or("goingson.db");
167
168 if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) {
169 // Match main db file and WAL/SHM/journal files
170 file_name == db_name
171 || file_name == format!("{}-wal", db_name)
172 || file_name == format!("{}-shm", db_name)
173 || file_name == format!("{}-journal", db_name)
174 } else {
175 false
176 }
177 }
178
179 #[cfg(test)]
180 mod tests {
181 use super::*;
182 use std::path::PathBuf;
183
184 #[test]
185 fn test_is_db_file_matches_main_db() {
186 let db_path = PathBuf::from("/data/goingson.db");
187 let test_path = PathBuf::from("/data/goingson.db");
188 assert!(is_db_file(&test_path, &db_path));
189 }
190
191 #[test]
192 fn test_is_db_file_matches_wal() {
193 let db_path = PathBuf::from("/data/goingson.db");
194 let test_path = PathBuf::from("/data/goingson.db-wal");
195 assert!(is_db_file(&test_path, &db_path));
196 }
197
198 #[test]
199 fn test_is_db_file_matches_shm() {
200 let db_path = PathBuf::from("/data/goingson.db");
201 let test_path = PathBuf::from("/data/goingson.db-shm");
202 assert!(is_db_file(&test_path, &db_path));
203 }
204
205 #[test]
206 fn test_is_db_file_matches_journal() {
207 let db_path = PathBuf::from("/data/goingson.db");
208 let test_path = PathBuf::from("/data/goingson.db-journal");
209 assert!(is_db_file(&test_path, &db_path));
210 }
211
212 #[test]
213 fn test_is_db_file_ignores_other_files() {
214 let db_path = PathBuf::from("/data/goingson.db");
215 let test_path = PathBuf::from("/data/other.db");
216 assert!(!is_db_file(&test_path, &db_path));
217 }
218
219 #[test]
220 fn test_is_db_file_ignores_backup_files() {
221 let db_path = PathBuf::from("/data/goingson.db");
222 let test_path = PathBuf::from("/data/goingson.db.backup");
223 assert!(!is_db_file(&test_path, &db_path));
224 }
225 }
226