Skip to main content

max / goingson

8.0 KB · 223 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 if now - last >= MIN_EVENT_INTERVAL_MS {
130 last_event_time_clone.store(now, Ordering::Relaxed);
131
132 debug!("Database change detected, emitting db:external-change event");
133
134 // Emit event to frontend
135 if let Err(e) = app_handle.emit("db:external-change", ()) {
136 warn!("Failed to emit db change event: {}", e);
137 }
138 } else {
139 // Mark that a trailing event should fire
140 pending_trailing_clone.store(true, Ordering::Relaxed);
141 debug!("Rate-limited db change event, queued trailing emit");
142 }
143 }
144 }
145 Ok(Err(e)) => {
146 warn!("File watcher error: {:?}", e);
147 }
148 Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
149 // No events, loop back to check shutdown flag
150 continue;
151 }
152 Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
153 warn!("File watcher channel disconnected");
154 break;
155 }
156 }
157 }
158 });
159 }
160
161 /// Check if a path is one of the SQLite database files
162 fn is_db_file(path: &Path, db_path: &Path) -> bool {
163 let db_name = db_path.file_name().and_then(|n| n.to_str()).unwrap_or("goingson.db");
164
165 if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) {
166 // Match main db file and WAL/SHM/journal files
167 file_name == db_name
168 || file_name == format!("{}-wal", db_name)
169 || file_name == format!("{}-shm", db_name)
170 || file_name == format!("{}-journal", db_name)
171 } else {
172 false
173 }
174 }
175
176 #[cfg(test)]
177 mod tests {
178 use super::*;
179 use std::path::PathBuf;
180
181 #[test]
182 fn test_is_db_file_matches_main_db() {
183 let db_path = PathBuf::from("/data/goingson.db");
184 let test_path = PathBuf::from("/data/goingson.db");
185 assert!(is_db_file(&test_path, &db_path));
186 }
187
188 #[test]
189 fn test_is_db_file_matches_wal() {
190 let db_path = PathBuf::from("/data/goingson.db");
191 let test_path = PathBuf::from("/data/goingson.db-wal");
192 assert!(is_db_file(&test_path, &db_path));
193 }
194
195 #[test]
196 fn test_is_db_file_matches_shm() {
197 let db_path = PathBuf::from("/data/goingson.db");
198 let test_path = PathBuf::from("/data/goingson.db-shm");
199 assert!(is_db_file(&test_path, &db_path));
200 }
201
202 #[test]
203 fn test_is_db_file_matches_journal() {
204 let db_path = PathBuf::from("/data/goingson.db");
205 let test_path = PathBuf::from("/data/goingson.db-journal");
206 assert!(is_db_file(&test_path, &db_path));
207 }
208
209 #[test]
210 fn test_is_db_file_ignores_other_files() {
211 let db_path = PathBuf::from("/data/goingson.db");
212 let test_path = PathBuf::from("/data/other.db");
213 assert!(!is_db_file(&test_path, &db_path));
214 }
215
216 #[test]
217 fn test_is_db_file_ignores_backup_files() {
218 let db_path = PathBuf::from("/data/goingson.db");
219 let test_path = PathBuf::from("/data/goingson.db.backup");
220 assert!(!is_db_file(&test_path, &db_path));
221 }
222 }
223