1use std::path::Path;
24use std::time::{SystemTime, UNIX_EPOCH};
25
26use rusqlite::Connection;
27
28use crate::error::MiniAppError;
29
30pub async fn write_backup_pair(
60 scope_dir: &Path,
61 table: &str,
62 schema_yaml_path: &Path,
63 db_path: &Path,
64) -> Result<(), MiniAppError> {
65 let scope_dir = scope_dir.to_path_buf();
66 let table = table.to_string();
67 let schema_yaml_path = schema_yaml_path.to_path_buf();
68 let db_path = db_path.to_path_buf();
69
70 tokio::task::spawn_blocking(move || -> Result<(), MiniAppError> {
71 write_backup_pair_sync(&scope_dir, &table, &schema_yaml_path, &db_path)
72 })
73 .await
74 .map_err(|e| MiniAppError::Backup(format!("blocking task panic: {e}")))?
75}
76
77fn write_backup_pair_sync(
80 scope_dir: &Path,
81 table: &str,
82 schema_yaml_path: &Path,
83 db_path: &Path,
84) -> Result<(), MiniAppError> {
85 let unix_secs = SystemTime::now()
87 .duration_since(UNIX_EPOCH)
88 .map_err(|e| MiniAppError::Backup(format!("system clock error: {e}")))?
89 .as_secs();
90
91 let backup_dir = scope_dir.join("_backup");
92 std::fs::create_dir_all(&backup_dir)
93 .map_err(|e| MiniAppError::Backup(format!("cannot create backup dir: {e}")))?;
94
95 let yaml_dst = backup_dir.join(format!("{}.{}.yaml", table, unix_secs));
97 std::fs::copy(schema_yaml_path, &yaml_dst)
98 .map_err(|e| MiniAppError::Backup(format!("cannot copy schema yaml: {e}")))?;
99
100 let src_conn = Connection::open(db_path)
103 .map_err(|e| MiniAppError::Backup(format!("cannot open source db: {e}")))?;
104
105 if let Err(e) = src_conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE)") {
107 tracing::warn!(error = %e, "WAL checkpoint before backup failed; continuing anyway");
108 }
109
110 let db_dst = backup_dir.join(format!("{}.{}.db", table, unix_secs));
111 src_conn
112 .backup(rusqlite::DatabaseName::Main, &db_dst, None)
113 .map_err(|e| MiniAppError::Backup(format!("rusqlite backup failed: {e}")))?;
114
115 Ok(())
116}
117
118pub async fn purge_old_backups(
142 scope_dir: &Path,
143 table: &str,
144 retention: usize,
145) -> Result<(), MiniAppError> {
146 let scope_dir = scope_dir.to_path_buf();
147 let table = table.to_string();
148
149 tokio::task::spawn_blocking(move || -> Result<(), MiniAppError> {
150 purge_old_backups_sync(&scope_dir, &table, retention)
151 })
152 .await
153 .map_err(|e| MiniAppError::Backup(format!("blocking task panic: {e}")))?
154}
155
156fn purge_old_backups_sync(
159 scope_dir: &Path,
160 table: &str,
161 retention: usize,
162) -> Result<(), MiniAppError> {
163 let backup_dir = scope_dir.join("_backup");
164
165 if !backup_dir.exists() {
167 return Ok(());
168 }
169
170 let entries = std::fs::read_dir(&backup_dir)
172 .map_err(|e| MiniAppError::Backup(format!("cannot read backup dir: {e}")))?;
173
174 let mut timestamps: Vec<u64> = entries
175 .filter_map(|entry| {
176 let entry = entry.ok()?;
177 let name = entry.file_name();
178 let name = name.to_string_lossy();
179 parse_backup_timestamp(&name, table, "yaml")
180 })
181 .collect();
182
183 timestamps.sort_unstable_by(|a, b| b.cmp(a));
185
186 for ts in timestamps.iter().skip(retention) {
188 let yaml_path = backup_dir.join(format!("{}.{}.yaml", table, ts));
189 let db_path = backup_dir.join(format!("{}.{}.db", table, ts));
190
191 if let Err(e) = std::fs::remove_file(&yaml_path) {
192 tracing::warn!(
193 path = %yaml_path.display(),
194 error = %e,
195 "failed to remove old backup yaml; continuing"
196 );
197 }
198 if let Err(e) = std::fs::remove_file(&db_path) {
199 tracing::warn!(
200 path = %db_path.display(),
201 error = %e,
202 "failed to remove old backup db; continuing"
203 );
204 }
205 }
206
207 Ok(())
208}
209
210fn parse_backup_timestamp(filename: &str, table: &str, ext: &str) -> Option<u64> {
221 let prefix = format!("{}.", table);
223 let suffix = format!(".{}", ext);
224
225 let without_prefix = filename.strip_prefix(&prefix)?;
226 let ts_str = without_prefix.strip_suffix(&suffix)?;
227 ts_str.parse::<u64>().ok()
228}
229
230#[cfg(test)]
243fn list_backup_timestamps(backup_dir: &Path, table: &str) -> Result<Vec<u64>, MiniAppError> {
244 let entries = std::fs::read_dir(backup_dir)
245 .map_err(|e| MiniAppError::Backup(format!("cannot read backup dir: {e}")))?;
246
247 let mut timestamps: Vec<u64> = entries
248 .filter_map(|entry| {
249 let entry = entry.ok()?;
250 let name = entry.file_name();
251 let name = name.to_string_lossy().to_string();
252 parse_backup_timestamp(&name, table, "yaml")
253 })
254 .collect();
255
256 timestamps.sort_unstable_by(|a, b| b.cmp(a));
257 Ok(timestamps)
258}
259
260#[cfg(test)]
261mod tests {
262 use super::*;
263 use rusqlite::Connection;
264 use std::io::Write;
265 use std::path::PathBuf;
266 use tempfile::TempDir;
267 use tokio::task;
268
269 fn create_test_db(path: &Path) {
271 let conn = Connection::open(path).expect("open test db");
272 conn.execute_batch(
273 "PRAGMA journal_mode=WAL; CREATE TABLE IF NOT EXISTS t (id INTEGER PRIMARY KEY, v TEXT);",
274 )
275 .expect("setup test db");
276 }
277
278 fn create_test_schema_yaml(path: &Path) {
280 let mut f = std::fs::File::create(path).expect("create schema yaml");
281 f.write_all(b"table: items\nfields:\n - name: v\n type: string\n required: false\n")
282 .expect("write schema yaml");
283 }
284
285 #[tokio::test]
289 async fn write_backup_pair_creates_yaml_and_db() {
290 let dir = TempDir::new().expect("temp dir");
291 let scope_dir = dir.path();
292 let db_path = scope_dir.join("items.db");
293 let schema_path = scope_dir.join("schema.yaml");
294
295 create_test_db(&db_path);
296 create_test_schema_yaml(&schema_path);
297
298 write_backup_pair(scope_dir, "items", &schema_path, &db_path)
299 .await
300 .expect("write_backup_pair must succeed");
301
302 let backup_dir = scope_dir.join("_backup");
303 assert!(backup_dir.exists(), "_backup dir must be created");
304
305 let entries: Vec<_> = std::fs::read_dir(&backup_dir)
306 .expect("read backup dir")
307 .filter_map(|e| e.ok())
308 .collect();
309
310 let yaml_count = entries
311 .iter()
312 .filter(|e| e.file_name().to_string_lossy().ends_with(".yaml"))
313 .count();
314 let db_count = entries
315 .iter()
316 .filter(|e| e.file_name().to_string_lossy().ends_with(".db"))
317 .count();
318
319 assert_eq!(yaml_count, 1, "exactly one yaml backup must exist");
320 assert_eq!(db_count, 1, "exactly one db backup must exist");
321 }
322
323 #[tokio::test]
325 async fn purge_old_backups_keeps_n_newest() {
326 let dir = TempDir::new().expect("temp dir");
327 let scope_dir = dir.path();
328 let backup_dir = scope_dir.join("_backup");
329 std::fs::create_dir_all(&backup_dir).expect("create backup dir");
330
331 for ts in [100u64, 200, 300, 400, 500] {
333 std::fs::write(backup_dir.join(format!("items.{}.yaml", ts)), b"yaml")
334 .expect("write yaml");
335 std::fs::write(backup_dir.join(format!("items.{}.db", ts)), b"db").expect("write db");
336 }
337
338 purge_old_backups(scope_dir, "items", 3)
339 .await
340 .expect("purge must succeed");
341
342 let timestamps = list_backup_timestamps(&backup_dir, "items").expect("list timestamps");
344 assert_eq!(timestamps.len(), 3, "exactly 3 pairs must remain");
345 assert_eq!(timestamps, vec![500, 400, 300], "newest 3 must be kept");
346
347 assert!(!backup_dir.join("items.100.yaml").exists());
349 assert!(!backup_dir.join("items.100.db").exists());
350 assert!(!backup_dir.join("items.200.yaml").exists());
351 assert!(!backup_dir.join("items.200.db").exists());
352 }
353
354 #[tokio::test]
358 async fn purge_old_backups_no_op_when_below_limit() {
359 let dir = TempDir::new().expect("temp dir");
360 let scope_dir = dir.path();
361 let backup_dir = scope_dir.join("_backup");
362 std::fs::create_dir_all(&backup_dir).expect("create backup dir");
363
364 for ts in [100u64, 200] {
366 std::fs::write(backup_dir.join(format!("items.{}.yaml", ts)), b"yaml")
367 .expect("write yaml");
368 std::fs::write(backup_dir.join(format!("items.{}.db", ts)), b"db").expect("write db");
369 }
370
371 purge_old_backups(scope_dir, "items", 10)
372 .await
373 .expect("purge must succeed");
374
375 let timestamps = list_backup_timestamps(&backup_dir, "items").expect("list timestamps");
376 assert_eq!(timestamps.len(), 2, "both pairs must still exist");
377 }
378
379 #[tokio::test]
383 async fn write_backup_pair_io_error_returns_backup_variant() {
384 let dir = TempDir::new().expect("temp dir");
385 let scope_dir = dir.path();
386 let db_path = scope_dir.join("items.db");
387 create_test_db(&db_path);
389
390 let result = write_backup_pair(
391 scope_dir,
392 "items",
393 Path::new("/nonexistent/schema.yaml"),
394 &db_path,
395 )
396 .await;
397
398 let err = result.expect_err("missing schema file must error");
399 assert!(
400 matches!(err, MiniAppError::Backup(_)),
401 "expected Backup variant, got {:?}",
402 err
403 );
404 }
405
406 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
415 async fn test_backup_does_not_block_concurrent_reads() {
416 let dir = TempDir::new().expect("temp dir");
417 let db_path = dir.path().join("concurrent.db");
418 let dst_path = dir.path().join("backup.db");
419 let schema_path = dir.path().join("schema.yaml");
420
421 {
423 let conn = Connection::open(&db_path).expect("open db");
424 conn.execute_batch(
425 "PRAGMA journal_mode=WAL; CREATE TABLE rows (id INTEGER PRIMARY KEY, val TEXT);",
426 )
427 .expect("setup db");
428 }
429 create_test_schema_yaml(&schema_path);
430
431 let db_path_writer = db_path.clone();
432 let dst_path_backup = dst_path.clone();
433 let schema_path_backup = schema_path.clone();
434 let scope_dir = dir.path().to_path_buf();
435
436 let writer = task::spawn(async move {
438 task::spawn_blocking(move || {
439 let conn = Connection::open(&db_path_writer).expect("open writer db");
440 for i in 0i64..100 {
441 conn.execute("INSERT INTO rows (val) VALUES (?1)", [format!("v{}", i)])
442 .expect("insert row");
443 }
444 })
445 .await
446 .expect("writer blocking task")
447 });
448
449 let backup_task =
451 write_backup_pair(&scope_dir, "concurrent", &schema_path_backup, &db_path);
452
453 let (writer_result, backup_result) = tokio::join!(writer, backup_task);
454
455 writer_result.expect("writer must succeed");
456 backup_result.expect("backup must succeed");
457
458 let backup_dir = scope_dir.join("_backup");
460 let backup_entries: Vec<PathBuf> = std::fs::read_dir(&backup_dir)
461 .expect("read backup dir")
462 .filter_map(|e| e.ok())
463 .map(|e| e.path())
464 .filter(|p| {
465 p.extension()
466 .and_then(|x| x.to_str())
467 .map(|x| x == "db")
468 .unwrap_or(false)
469 })
470 .collect();
471 assert!(
472 !backup_entries.is_empty(),
473 "at least one db backup must exist"
474 );
475
476 let backup_conn = Connection::open(&backup_entries[0]).expect("open backup db");
478 let backup_row_count: i64 = backup_conn
479 .query_row("SELECT COUNT(*) FROM rows", [], |row| row.get(0))
480 .unwrap_or(0);
481 assert!(backup_row_count >= 0, "backup db must be a valid sqlite db");
483
484 let _ = dst_path_backup; }
487
488 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
498 async fn test_spawn_blocking_cancel_safety_insert_survives() {
499 let dir = TempDir::new().expect("temp dir");
500 let scope_dir = dir.path().to_path_buf();
501 let db_path = scope_dir.join("cancel_test.db");
502 let schema_path = scope_dir.join("schema.yaml");
503
504 {
505 let conn = Connection::open(&db_path).expect("open db");
506 conn.execute_batch(
507 "PRAGMA journal_mode=WAL; CREATE TABLE rows (id INTEGER PRIMARY KEY, val TEXT);",
508 )
509 .expect("setup db");
510 }
511 create_test_schema_yaml(&schema_path);
512
513 let backup_fut = write_backup_pair(&scope_dir, "cancel_test", &schema_path, &db_path);
516 let result = tokio::time::timeout(std::time::Duration::from_millis(1), backup_fut).await;
517
518 tokio::time::sleep(std::time::Duration::from_millis(500)).await;
521
522 let src_conn = Connection::open(&db_path).expect("source db must still be openable");
526 let _count: i64 = src_conn
527 .query_row("SELECT COUNT(*) FROM rows", [], |row| row.get(0))
528 .expect("source db must be a valid sqlite db after cancellation");
529
530 if let Ok(Ok(())) = result {
533 let backup_dir = scope_dir.join("_backup");
534 assert!(
535 backup_dir.exists(),
536 "backup dir must exist on successful write"
537 );
538 }
539 }
541}