Skip to main content

mini_app_core/
backup.rs

1/// Backup utilities for mini-app-mcp schema CRUD tools.
2///
3/// This module provides two public async functions:
4///
5/// - [`write_backup_pair`] — copies a table's `schema.yaml` and creates an
6///   online SQLite backup in `{scope_dir}/_backup/`.
7/// - [`purge_old_backups`] — removes the oldest backup pairs beyond the
8///   configured retention limit.
9///
10/// All I/O is performed inside `tokio::task::spawn_blocking` (K-110) to
11/// avoid blocking the async executor.  The SQLite backup uses
12/// `rusqlite::Connection::backup` with a fresh source connection so the
13/// existing `Store`'s `Mutex<Connection>` is never borrowed (K-103).
14///
15/// # Backup placement
16///
17/// ```text
18/// {scope_dir}/
19///   _backup/
20///     {table}.{unix_secs}.yaml
21///     {table}.{unix_secs}.db
22/// ```
23use std::path::Path;
24use std::time::{SystemTime, UNIX_EPOCH};
25
26use rusqlite::Connection;
27
28use crate::error::MiniAppError;
29
30/// Creates a backup pair (YAML + SQLite DB snapshot) for a table.
31///
32/// The pair is written to `{scope_dir}/_backup/{table}.{unix_secs}.yaml` and
33/// `{scope_dir}/_backup/{table}.{unix_secs}.db`.  The `_backup` directory is
34/// created if it does not exist.
35///
36/// The YAML file is copied verbatim from `schema_yaml_path`.  The DB file is
37/// created via `rusqlite::Connection::backup("main", …, None)` using a fresh
38/// read-only connection (the existing Store connection is not touched).
39///
40/// A `PRAGMA wal_checkpoint(TRUNCATE)` is attempted before the backup to
41/// ensure the WAL is flushed.  If the checkpoint fails it is logged as a
42/// warning and the backup continues regardless (rusqlite's backup API handles
43/// WAL-mode databases internally).
44///
45/// # Arguments
46/// - `scope_dir`: the `.mini-app/<scope>/` root directory for this table.
47/// - `table`: the logical table name (used as filename prefix).
48/// - `schema_yaml_path`: path to the `schema.yaml` file to back up.
49/// - `db_path`: path to the SQLite database file to back up.
50///
51/// # Returns
52/// `Ok(())` on success.
53///
54/// # Errors
55/// - [`MiniAppError::Backup`] if the timestamp cannot be determined, the
56///   backup directory cannot be created, the YAML copy fails, or the SQLite
57///   backup fails.
58/// - [`MiniAppError::Backup`] if the `spawn_blocking` task panics.
59pub 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
77/// Synchronous implementation of [`write_backup_pair`], executed inside
78/// `spawn_blocking`.
79fn write_backup_pair_sync(
80    scope_dir: &Path,
81    table: &str,
82    schema_yaml_path: &Path,
83    db_path: &Path,
84) -> Result<(), MiniAppError> {
85    // Obtain current Unix timestamp (seconds since UNIX_EPOCH).
86    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    // Copy schema YAML.
96    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    // Open a fresh source connection for the backup so we don't borrow the
101    // Store's Mutex<Connection> (K-103).
102    let src_conn = Connection::open(db_path)
103        .map_err(|e| MiniAppError::Backup(format!("cannot open source db: {e}")))?;
104
105    // Attempt WAL checkpoint before backup.  Failure is non-fatal.
106    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
118/// Removes the oldest backup pairs beyond the retention limit.
119///
120/// Scans `{scope_dir}/_backup/` for files matching `{table}.*.yaml` and
121/// `{table}.*.db`.  Files are sorted by the numeric timestamp embedded in
122/// their name (descending — newest first).  Pairs beyond the `retention`
123/// limit are deleted.
124///
125/// If one file in a pair cannot be removed (e.g. already deleted), the error
126/// is logged as a warning and purge continues for the remaining files.
127///
128/// # Arguments
129/// - `scope_dir`: the `.mini-app/<scope>/` root for this table.
130/// - `table`: the logical table name used as filename prefix.
131/// - `retention`: number of backup pairs to keep (pairs beyond this count are
132///   deleted).
133///
134/// # Returns
135/// `Ok(())` on success (including the no-op case where fewer than
136/// `retention + 1` pairs exist).
137///
138/// # Errors
139/// - [`MiniAppError::Backup`] if the `_backup` directory cannot be read, or
140///   if the `spawn_blocking` task panics.
141pub 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
156/// Synchronous implementation of [`purge_old_backups`], executed inside
157/// `spawn_blocking`.
158fn 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 the backup directory does not exist yet, nothing to purge.
166    if !backup_dir.exists() {
167        return Ok(());
168    }
169
170    // Collect timestamps from YAML files that belong to this table.
171    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    // Sort descending — newest first.
184    timestamps.sort_unstable_by(|a, b| b.cmp(a));
185
186    // Delete pairs beyond `retention`.
187    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
210/// Parses the numeric timestamp from a backup filename of the form
211/// `{table}.{ts}.{ext}`.
212///
213/// Returns `None` if the name does not match the expected pattern or if the
214/// timestamp segment is not a valid `u64`.
215///
216/// # Arguments
217/// - `filename`: the bare filename string to parse.
218/// - `table`: the expected table name prefix.
219/// - `ext`: the expected extension (without leading dot), e.g. `"yaml"`.
220fn parse_backup_timestamp(filename: &str, table: &str, ext: &str) -> Option<u64> {
221    // Expected format: "{table}.{ts}.{ext}"
222    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/// Returns the sorted list of backup timestamps (descending) for a given
231/// table, scanning only YAML files.  Used internally for testing.
232///
233/// # Arguments
234/// - `backup_dir`: the `_backup/` directory to scan.
235/// - `table`: the logical table name.
236///
237/// # Returns
238/// A `Vec<u64>` of timestamps sorted newest-first.
239///
240/// # Errors
241/// - [`MiniAppError::Backup`] if the directory cannot be read.
242#[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    /// Helper: create a minimal SQLite database with WAL mode enabled at `path`.
270    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    /// Helper: create a minimal schema.yaml at `path`.
279    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    // ── T1: happy-path ────────────────────────────────────────────────────
286
287    /// T1: write_backup_pair creates both yaml and db files in `_backup/`.
288    #[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    /// T1: purge_old_backups keeps only the N newest pairs.
324    #[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        // Create 5 fake backup pairs with distinct timestamps.
332        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        // Newest 3 timestamps: 500, 400, 300.  Oldest 2 (100, 200) must be gone.
343        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        // Verify the deleted pairs are truly gone.
348        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    // ── T2: boundary / edge-case ──────────────────────────────────────────
355
356    /// T2: purge_old_backups is a no-op when backup count is below retention.
357    #[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        // Only 2 pairs, retention = 10.
365        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    // ── T3: error-path ────────────────────────────────────────────────────
380
381    /// T3: write_backup_pair returns Backup error when schema_yaml_path is missing.
382    #[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 a real db but point schema to a non-existent file.
388        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    // ── Concurrency: backup does not block concurrent reads ───────────────
407
408    /// Concurrency test: backup runs concurrently with INSERT operations and
409    /// both complete successfully.
410    ///
411    /// This verifies `rusqlite::Connection::backup` is safe to call on a
412    /// WAL-mode database while another connection is writing.  rusqlite docs
413    /// state "source can be used while the backup is running".
414    #[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        // Prepare DB with WAL mode and a table.
422        {
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        // Launch writer task: inserts 100 rows using a separate connection.
437        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        // Launch backup task: runs the backup while writer is active.
450        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        // The backup file must exist and be a valid SQLite database.
459        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        // Verify backup db is a valid SQLite database (can be opened).
477        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        // Backup may have captured 0..100 rows (concurrent; exact count not deterministic).
482        assert!(backup_row_count >= 0, "backup db must be a valid sqlite db");
483
484        // Destination path for direct write_backup_pair output exists.
485        let _ = dst_path_backup; // suppress unused warning
486    }
487
488    // ── Concurrency: spawn_blocking cancel safety ─────────────────────────
489
490    /// Cancel-safety test: dropping a `write_backup_pair` Future immediately
491    /// after spawn_blocking starts does not leave DB in a corrupt state.
492    ///
493    /// `tokio::task::spawn_blocking` is abort-unsafe: once the blocking
494    /// closure starts running it runs to completion even if the outer Future
495    /// is dropped.  This test verifies that DB and backup files are in a
496    /// complete state after the Future has been dropped.
497    #[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        // Issue backup with a very short timeout to trigger "cancel" of the Future.
514        // spawn_blocking closure continues running even after the outer Future is dropped.
515        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        // Give the spawn_blocking closure time to complete (it runs to completion
519        // regardless of the timeout because spawn_blocking is abort-unsafe).
520        tokio::time::sleep(std::time::Duration::from_millis(500)).await;
521
522        // The backup files may or may not exist depending on whether the blocking
523        // task completed before or after the timeout — the key guarantee is that
524        // the source DB is not corrupted regardless.
525        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 the future was cancelled (Err = timeout), the backup may not have
531        // completed.  If it succeeded (Ok), verify the backup is also valid.
532        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        // Whether timed out or not, no panic occurred — the test passes.
540    }
541}