Skip to main content

mini_app_core/
snapshot.rs

1/// Snapshot utilities for mini-app-mcp data tools.
2///
3/// This module provides two public async functions:
4///
5/// - [`write_snapshot_db`] — creates an online SQLite snapshot of a table's
6///   database in `{scope_dir}/_snapshots/`.  No YAML is copied (snapshots are
7///   DB-only).
8/// - [`purge_old_snapshots`] — removes the oldest snapshot files beyond the
9///   configured retention limit.
10///
11/// All I/O is performed inside `tokio::task::spawn_blocking` (K-110) to
12/// avoid blocking the async executor.  The SQLite snapshot uses
13/// `rusqlite::Connection::backup` with a fresh source connection so the
14/// existing `Store`'s `Mutex<Connection>` is never borrowed (K-103).
15///
16/// # Snapshot placement
17///
18/// ```text
19/// {scope_dir}/
20///   _snapshots/
21///     {table}.{unix_secs}.db
22/// ```
23///
24/// # Retention isolation
25///
26/// Snapshot retention is controlled exclusively by `MINI_APP_SNAPSHOT_RETENTION`
27/// (default `10`).  The `_backup/` directory and `MINI_APP_BACKUP_RETENTION` are
28/// never read, written, or purged by this module (Crux: snapshot retention
29/// isolation).
30use std::collections::HashMap;
31use std::path::{Path, PathBuf};
32use std::sync::Arc;
33use std::time::{SystemTime, UNIX_EPOCH};
34
35use arc_swap::ArcSwap;
36use rusqlite::Connection;
37use schemars::JsonSchema;
38use serde::Deserialize;
39
40use crate::config::Config;
41use crate::error::MiniAppError;
42use crate::registry::TableRegistry;
43
44/// Creates a SQLite snapshot for a table using the hot backup API.
45///
46/// The snapshot is written to
47/// `{scope_dir}/_snapshots/{table}.{unix_secs}.db`.  The `_snapshots/`
48/// directory is created if it does not exist.
49///
50/// The snapshot file is created via
51/// `rusqlite::Connection::backup(rusqlite::MAIN_DB, …, None)` using a fresh
52/// source connection opened from `db_path` — the existing Store connection is
53/// never borrowed (K-103).  This satisfies the SQLite Online Backup API
54/// contract that "the source can be used while the backup is running".
55///
56/// A `PRAGMA wal_checkpoint(TRUNCATE)` is attempted before the backup to
57/// ensure the WAL is flushed into the main DB file so the snapshot captures
58/// the most recent committed state.  If the checkpoint fails it is logged as a
59/// warning and the backup continues regardless (rusqlite's backup API handles
60/// WAL-mode databases internally).
61///
62/// **Crux (rusqlite hot backup API)**: only `rusqlite::Connection::backup` is
63/// used to create the snapshot.  `std::fs::copy` of the `.db` file is never
64/// used because it would produce a corrupted or stale snapshot when the source
65/// database has an open WAL file.
66///
67/// # Arguments
68/// - `scope_dir`: the `.mini-app/<scope>/` root directory for this table.
69/// - `table`: the logical table name (used as filename prefix).
70/// - `db_path`: path to the SQLite database file to snapshot.
71///
72/// # Returns
73/// `Ok(())` on success.
74///
75/// # Errors
76/// - [`MiniAppError::Snapshot`] if the timestamp cannot be determined, the
77///   snapshot directory cannot be created, or the SQLite backup fails.
78/// - [`MiniAppError::Snapshot`] if the `spawn_blocking` task panics.
79pub async fn write_snapshot_db(
80    scope_dir: &Path,
81    table: &str,
82    db_path: &Path,
83) -> Result<(), MiniAppError> {
84    let scope_dir = scope_dir.to_path_buf();
85    let table = table.to_string();
86    let db_path = db_path.to_path_buf();
87
88    tokio::task::spawn_blocking(move || -> Result<(), MiniAppError> {
89        write_snapshot_db_sync(&scope_dir, &table, &db_path)
90    })
91    .await
92    .map_err(|e| MiniAppError::Snapshot(format!("blocking task panic: {e}")))?
93}
94
95/// Synchronous implementation of [`write_snapshot_db`], executed inside
96/// `spawn_blocking`.
97fn write_snapshot_db_sync(
98    scope_dir: &Path,
99    table: &str,
100    db_path: &Path,
101) -> Result<(), MiniAppError> {
102    // Obtain current Unix timestamp (seconds since UNIX_EPOCH).
103    let unix_secs = SystemTime::now()
104        .duration_since(UNIX_EPOCH)
105        .map_err(|e| MiniAppError::Snapshot(format!("system clock error: {e}")))?
106        .as_secs();
107
108    let snapshot_dir = scope_dir.join("_snapshots");
109    std::fs::create_dir_all(&snapshot_dir)
110        .map_err(|e| MiniAppError::Snapshot(format!("cannot create snapshot dir: {e}")))?;
111
112    // Open a fresh source connection for the backup so we don't borrow the
113    // Store's Mutex<Connection> (K-103).
114    let src_conn = Connection::open(db_path)
115        .map_err(|e| MiniAppError::Snapshot(format!("cannot open source db: {e}")))?;
116
117    // Attempt WAL checkpoint before snapshot.  Failure is non-fatal.
118    if let Err(e) = src_conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE)") {
119        tracing::warn!(error = %e, "WAL checkpoint before snapshot failed; continuing anyway");
120    }
121
122    let db_dst = snapshot_dir.join(format!("{}.{}.db", table, unix_secs));
123    // Crux: use rusqlite::Connection::backup (hot backup API), never std::fs::copy.
124    src_conn
125        .backup(rusqlite::MAIN_DB, &db_dst, None)
126        .map_err(|e| MiniAppError::Snapshot(format!("rusqlite backup failed: {e}")))?;
127
128    Ok(())
129}
130
131/// Removes the oldest snapshot files beyond the retention limit.
132///
133/// Scans `{scope_dir}/_snapshots/` for files matching `{table}.*.db`.  Files
134/// are sorted by the numeric timestamp embedded in their name (descending —
135/// newest first).  Files beyond the `retention` limit are deleted.
136///
137/// If a file cannot be removed (e.g. already deleted), the error is logged as
138/// a warning and purge continues for the remaining files.
139///
140/// **Crux (snapshot retention isolation)**: this function only touches
141/// `{scope_dir}/_snapshots/`.  It never reads, writes, or removes files from
142/// `{scope_dir}/_backup/`, and it never consults `MINI_APP_BACKUP_RETENTION`.
143///
144/// # Arguments
145/// - `scope_dir`: the `.mini-app/<scope>/` root for this table.
146/// - `table`: the logical table name used as filename prefix.
147/// - `retention`: number of snapshot files to keep (files beyond this count
148///   are deleted).
149///
150/// # Returns
151/// `Ok(())` on success (including the no-op case where fewer than
152/// `retention + 1` snapshot files exist).
153///
154/// # Errors
155/// - [`MiniAppError::Snapshot`] if the `_snapshots` directory cannot be read,
156///   or if the `spawn_blocking` task panics.
157pub async fn purge_old_snapshots(
158    scope_dir: &Path,
159    table: &str,
160    retention: usize,
161) -> Result<(), MiniAppError> {
162    let scope_dir = scope_dir.to_path_buf();
163    let table = table.to_string();
164
165    tokio::task::spawn_blocking(move || -> Result<(), MiniAppError> {
166        purge_old_snapshots_sync(&scope_dir, &table, retention)
167    })
168    .await
169    .map_err(|e| MiniAppError::Snapshot(format!("blocking task panic: {e}")))?
170}
171
172/// Synchronous implementation of [`purge_old_snapshots`], executed inside
173/// `spawn_blocking`.
174fn purge_old_snapshots_sync(
175    scope_dir: &Path,
176    table: &str,
177    retention: usize,
178) -> Result<(), MiniAppError> {
179    let snapshot_dir = scope_dir.join("_snapshots");
180
181    // If the snapshot directory does not exist yet, nothing to purge.
182    if !snapshot_dir.exists() {
183        return Ok(());
184    }
185
186    // Collect timestamps from .db files that belong to this table.
187    let entries = std::fs::read_dir(&snapshot_dir)
188        .map_err(|e| MiniAppError::Snapshot(format!("cannot read snapshot dir: {e}")))?;
189
190    let mut timestamps: Vec<u64> = entries
191        .filter_map(|entry| {
192            let entry = entry.ok()?;
193            let name = entry.file_name();
194            let name = name.to_string_lossy();
195            parse_snapshot_timestamp(&name, table, "db")
196        })
197        .collect();
198
199    // Sort descending — newest first.
200    timestamps.sort_unstable_by(|a, b| b.cmp(a));
201
202    // Delete snapshot files beyond `retention`.
203    for ts in timestamps.iter().skip(retention) {
204        let db_path = snapshot_dir.join(format!("{}.{}.db", table, ts));
205
206        if let Err(e) = std::fs::remove_file(&db_path) {
207            tracing::warn!(
208                path = %db_path.display(),
209                error = %e,
210                "failed to remove old snapshot db; continuing"
211            );
212        }
213    }
214
215    Ok(())
216}
217
218/// Parses the numeric timestamp from a snapshot filename of the form
219/// `{table}.{ts}.{ext}`.
220///
221/// Returns `None` if the name does not match the expected pattern or if the
222/// timestamp segment is not a valid `u64`.
223///
224/// # Arguments
225/// - `filename`: the bare filename string to parse.
226/// - `table`: the expected table name prefix.
227/// - `ext`: the expected extension (without leading dot), e.g. `"db"`.
228pub(crate) fn parse_snapshot_timestamp(filename: &str, table: &str, ext: &str) -> Option<u64> {
229    // Expected format: "{table}.{ts}.{ext}"
230    let prefix = format!("{}.", table);
231    let suffix = format!(".{}", ext);
232
233    let without_prefix = filename.strip_prefix(&prefix)?;
234    let ts_str = without_prefix.strip_suffix(&suffix)?;
235    ts_str.parse::<u64>().ok()
236}
237
238// =============================================================================
239// MCP tool: data_snapshot
240// =============================================================================
241
242/// Parameters for the `data_snapshot` MCP tool.
243///
244/// All fields are optional; `None` means "all" (tables / scopes).  The
245/// `dry_run` flag, when `true`, returns inspection metadata without touching
246/// any file or database state (Crux: dry_run zero-write guarantee).
247#[derive(Debug, Default, Deserialize, JsonSchema)]
248#[serde(default)]
249pub struct DataSnapshotParams {
250    /// Target a single table by name.  When `None`, all mounted tables in the
251    /// given `scope` (or all scopes) are snapshotted.
252    pub table: Option<String>,
253    /// Restrict operation to `"project"` or `"user"` scope.  When `None`,
254    /// all scopes are considered.
255    pub scope: Option<String>,
256    /// When `true`, returns `affects` metadata (target tables, row counts,
257    /// would-purge counts) **without** creating, modifying, or deleting any
258    /// file or database state (Crux: dry_run zero-write guarantee).
259    pub dry_run: Option<bool>,
260    /// When `true`, each written snapshot is additionally uploaded to the
261    /// S3-compatible destination configured via `MINI_APP_S3_*` environment
262    /// variables (requires the `s3-upload` cargo feature).
263    ///
264    /// Configuration is validated **before** any snapshot is written: a build
265    /// without the feature or incomplete env returns `UPLOAD_NOT_CONFIGURED`
266    /// immediately. Individual upload failures are non-fatal — local snapshot
267    /// and purge results stand, and failures are reported in the
268    /// `upload_errors[]` response field. Combined with `dry_run=true`, the
269    /// configuration is still validated but no upload is attempted.
270    pub upload: Option<bool>,
271}
272
273/// A single entry resolved for snapshotting.
274///
275/// Holds clones of the Arc pointers extracted from the registry so the
276/// ArcSwap Guard can be dropped before any `.await` (K-110 / no
277/// await-holding-lock).
278struct SnapshotTarget {
279    table_name: String,
280    scope_root: PathBuf,
281    db_path: PathBuf,
282    store: Arc<crate::store::Store>,
283}
284
285/// Executes the `data_snapshot` MCP tool.
286///
287/// Fan-out logic:
288/// - `table=Some + scope=Some` → 1 matching entry (scope-filtered).
289/// - `table=Some + scope=None` → 1 entry via `registry.resolve`.
290/// - `table=None + scope=Some("project")` → all entries whose `schema_path`
291///   starts with `config.project_dir`.
292/// - `table=None + scope=Some("user")` → same with `config.user_dir`.
293/// - `table=None + scope=None` → all mounted entries.
294///
295/// When `dry_run=true` (Crux: dry_run zero-write guarantee):
296/// - Returns `{ "dry_run": true, "affects": { "target_tables": [...],
297///   "row_counts": {...}, "would_purge_generations": {...} } }`.
298/// - **No file or database state is created, modified, or deleted.**
299///
300/// When `dry_run=false` (or omitted):
301/// - Calls [`write_snapshot_db`] then [`purge_old_snapshots`] per entry.
302/// - Returns `{ "snapshotted": [...], "purged": [...] }`.
303///
304/// # Arguments
305/// - `config`: server mount configuration (dirs + retention).
306/// - `tables`: the live `ArcSwap`-wrapped table registry.
307/// - `params`: tool parameters.
308///
309/// # Returns
310/// JSON string with operation results.
311///
312/// # Errors
313/// - [`MiniAppError::Snapshot`] if any snapshot or purge operation fails, or
314///   if the scope argument is unrecognised.
315pub async fn do_data_snapshot(
316    config: &Config,
317    tables: &Arc<ArcSwap<TableRegistry>>,
318    params: DataSnapshotParams,
319) -> Result<String, MiniAppError> {
320    let dry_run = params.dry_run.unwrap_or(false);
321    let upload_requested = params.upload.unwrap_or(false);
322
323    // Resolve upload configuration BEFORE any write so that a misconfigured
324    // upload never produces a half-done call (D4: fail before first snapshot).
325    let upload_config = if upload_requested {
326        if !crate::snapshot_upload::upload_feature_enabled() {
327            return Err(MiniAppError::UploadNotConfigured(
328                "server built without the 's3-upload' cargo feature".into(),
329            ));
330        }
331        Some(crate::snapshot_upload::S3UploadConfig::from_env()?)
332    } else {
333        None
334    };
335
336    // Resolve the target entries from the registry.  The ArcSwap Guard is
337    // dropped immediately after the clone loop (no Guard across .await).
338    let targets: Vec<SnapshotTarget> = {
339        let registry = tables.load_full();
340        resolve_targets(
341            &registry,
342            config,
343            params.table.as_deref(),
344            params.scope.as_deref(),
345        )?
346    };
347
348    if dry_run {
349        // Crux: dry_run zero-write guarantee — read-only path only.
350        let mut target_tables: Vec<String> = targets.iter().map(|t| t.table_name.clone()).collect();
351        target_tables.sort();
352
353        let mut row_counts: HashMap<String, u64> = HashMap::new();
354        let mut would_purge: HashMap<String, usize> = HashMap::new();
355
356        for target in &targets {
357            // row_count uses Store::row_count() which is read-only (SELECT COUNT(*)).
358            let count = target.store.row_count().await.map_err(|e| {
359                MiniAppError::Snapshot(format!(
360                    "row_count failed for table '{}': {e}",
361                    target.table_name
362                ))
363            })?;
364            row_counts.insert(target.table_name.clone(), count);
365
366            // Compute would-purge count by scanning _snapshots/ read-only.
367            // No write occurs here (Crux: dry_run zero-write guarantee).
368            let purge_count = count_would_purge(
369                &target.scope_root,
370                &target.table_name,
371                config.snapshot_retention(),
372            );
373            would_purge.insert(target.table_name.clone(), purge_count);
374        }
375
376        let result = serde_json::json!({
377            "dry_run": true,
378            "affects": {
379                "target_tables": target_tables,
380                "row_counts": row_counts,
381                "would_purge_generations": would_purge,
382            }
383        });
384        return serde_json::to_string(&result)
385            .map_err(|e| MiniAppError::Snapshot(format!("json serialization error: {e}")));
386    }
387
388    // Real path: write snapshots and purge old generations.
389    let mut snapshotted: Vec<serde_json::Value> = Vec::new();
390    let mut purged: Vec<serde_json::Value> = Vec::new();
391    #[cfg_attr(not(feature = "s3-upload"), allow(unused_mut))]
392    let mut uploaded: Vec<serde_json::Value> = Vec::new();
393    #[cfg_attr(not(feature = "s3-upload"), allow(unused_mut))]
394    let mut upload_errors: Vec<serde_json::Value> = Vec::new();
395
396    let retention = config.snapshot_retention();
397
398    for target in &targets {
399        // Write the snapshot using the hot backup API (Crux: rusqlite hot backup API).
400        write_snapshot_db(&target.scope_root, &target.table_name, &target.db_path).await?;
401
402        // Determine the timestamp of the snapshot just written (newest file).
403        let snapshot_path = newest_snapshot_path(&target.scope_root, &target.table_name);
404        let unix_secs = snapshot_path.as_ref().and_then(|p| {
405            p.file_name()
406                .and_then(|n| n.to_str())
407                .and_then(|n| parse_snapshot_timestamp(n, &target.table_name, "db"))
408        });
409
410        let scope_label = scope_label_for(&target.scope_root, config);
411        snapshotted.push(serde_json::json!({
412            "table": target.table_name,
413            "scope": scope_label,
414            "snapshot_path": snapshot_path.as_ref().map(|p| p.display().to_string()).unwrap_or_default(),
415            "unix_secs": unix_secs,
416        }));
417
418        // Purge old generations (Crux: snapshot retention isolation — only
419        // calls config.snapshot_retention(), never backup_retention()).
420        let snapshot_dir = target.scope_root.join("_snapshots");
421        let before_count = count_snapshots_in_dir(&snapshot_dir, &target.table_name);
422        purge_old_snapshots(&target.scope_root, &target.table_name, retention).await?;
423        let after_count = count_snapshots_in_dir(&snapshot_dir, &target.table_name);
424        let removed = before_count.saturating_sub(after_count);
425
426        if removed > 0 {
427            purged.push(serde_json::json!({
428                "table": target.table_name,
429                "generations_removed": removed,
430            }));
431        }
432
433        // Upload the snapshot just written (non-fatal on per-table failure:
434        // the local snapshot and purge results above stand regardless).
435        if let Some(upload_config) = &upload_config {
436            #[cfg(feature = "s3-upload")]
437            {
438                match &snapshot_path {
439                    Some(path) => {
440                        let file_name = path
441                            .file_name()
442                            .and_then(|n| n.to_str())
443                            .unwrap_or_default();
444                        let key = upload_config.key_for(file_name);
445                        match crate::snapshot_upload::upload_snapshot(upload_config, path, &key)
446                            .await
447                        {
448                            Ok(bytes) => uploaded.push(serde_json::json!({
449                                "table": target.table_name,
450                                "key": key,
451                                "bytes": bytes,
452                            })),
453                            Err(e) => upload_errors.push(serde_json::json!({
454                                "table": target.table_name,
455                                "error": e.to_string(),
456                            })),
457                        }
458                    }
459                    None => upload_errors.push(serde_json::json!({
460                        "table": target.table_name,
461                        "error": "snapshot file not found after write",
462                    })),
463                }
464            }
465            #[cfg(not(feature = "s3-upload"))]
466            {
467                let _ = upload_config;
468                unreachable!(
469                    "upload=true is rejected before any write when the s3-upload feature is disabled"
470                );
471            }
472        }
473    }
474
475    let mut result = serde_json::json!({
476        "snapshotted": snapshotted,
477        "purged": purged,
478    });
479    if upload_requested {
480        result["uploaded"] = serde_json::Value::Array(uploaded);
481        result["upload_errors"] = serde_json::Value::Array(upload_errors);
482    }
483    serde_json::to_string(&result)
484        .map_err(|e| MiniAppError::Snapshot(format!("json serialization error: {e}")))
485}
486
487/// Resolves the list of snapshot targets from the registry according to
488/// `table` and `scope` filter parameters.
489///
490/// # Arguments
491/// - `registry`: the current table registry snapshot.
492/// - `config`: mount config (for scope dir resolution).
493/// - `table`: optional table name filter.
494/// - `scope`: optional scope string (`"project"` or `"user"`).
495///
496/// # Returns
497/// A `Vec<SnapshotTarget>` sorted by table name for deterministic output.
498///
499/// # Errors
500/// - [`MiniAppError::Snapshot`] if the scope is unrecognised or if a
501///   `schema_path` has no parent directory.
502/// - [`MiniAppError::TableNotFound`] / [`MiniAppError::TableRequired`] from
503///   `registry.resolve` when `table=Some`.
504fn resolve_targets(
505    registry: &TableRegistry,
506    config: &Config,
507    table: Option<&str>,
508    scope: Option<&str>,
509) -> Result<Vec<SnapshotTarget>, MiniAppError> {
510    let is_legacy = registry.default_table().is_some();
511
512    if let Some(table_name) = table {
513        // Single-table path: resolve via registry.
514        let entry = registry.resolve(Some(table_name))?;
515        let scope_root = derive_scope_root(&entry.schema_path, is_legacy)?;
516        let db_path = entry
517            .schema_path
518            .parent()
519            .ok_or_else(|| MiniAppError::Snapshot("schema_path has no parent dir".into()))?
520            .join(format!("{}.db", table_name));
521
522        // Verify scope filter if provided.
523        if let Some(scope_str) = scope {
524            let expected_dir = resolve_scope_dir(config, scope_str)?;
525            if let Some(expected) = expected_dir {
526                if !scope_root.starts_with(&expected) {
527                    return Ok(Vec::new()); // No match.
528                }
529            }
530        }
531
532        return Ok(vec![SnapshotTarget {
533            table_name: table_name.to_string(),
534            scope_root,
535            db_path,
536            store: Arc::clone(&entry.store),
537        }]);
538    }
539
540    // Multi-table path: iterate entries with optional scope filter.
541    let scope_filter: Option<PathBuf> = match scope {
542        Some(s) => resolve_scope_dir(config, s)?,
543        None => None,
544    };
545
546    let mut targets: Vec<SnapshotTarget> = registry
547        .entries()
548        .iter()
549        .filter_map(|(name, entry)| {
550            let scope_root = derive_scope_root(&entry.schema_path, is_legacy).ok()?;
551            // Apply scope filter if present.
552            if let Some(ref expected) = scope_filter {
553                if !scope_root.starts_with(expected) {
554                    return None;
555                }
556            }
557            let db_path = entry.schema_path.parent()?.join(format!("{}.db", name));
558            Some(SnapshotTarget {
559                table_name: name.clone(),
560                scope_root,
561                db_path,
562                store: Arc::clone(&entry.store),
563            })
564        })
565        .collect();
566
567    // Sort by table name for deterministic output (HashMap is unordered).
568    targets.sort_by(|a, b| a.table_name.cmp(&b.table_name));
569    Ok(targets)
570}
571
572/// Derives the `scope_root` path from a `schema_path`.
573///
574/// In **multi-table mode** (`is_legacy = false`), `schema_path` follows
575/// `{scope_root}/{table}/schema.yaml`, so the scope root is 2 levels up.
576///
577/// In **legacy mode** (`is_legacy = true`), `schema_path` is an arbitrary
578/// path provided via `MINI_APP_SCHEMA`, so the scope root is 1 level up
579/// (same directory as the schema file).
580///
581/// # Errors
582/// - [`MiniAppError::Snapshot`] if a required parent directory cannot be
583///   determined.
584fn derive_scope_root(schema_path: &Path, is_legacy: bool) -> Result<PathBuf, MiniAppError> {
585    if is_legacy {
586        schema_path
587            .parent()
588            .map(|p| p.to_path_buf())
589            .ok_or_else(|| MiniAppError::Snapshot("schema_path has no parent dir".into()))
590    } else {
591        schema_path
592            .parent()
593            .and_then(|p| p.parent())
594            .map(|p| p.to_path_buf())
595            .ok_or_else(|| MiniAppError::Snapshot("schema_path has no grandparent dir".into()))
596    }
597}
598
599/// Resolves the filesystem path for a scope string (`"project"` or `"user"`).
600///
601/// Returns `Ok(None)` if the corresponding directory is not configured.
602///
603/// # Errors
604/// - [`MiniAppError::Snapshot`] if `scope` is not `"project"` or `"user"`.
605fn resolve_scope_dir(config: &Config, scope: &str) -> Result<Option<PathBuf>, MiniAppError> {
606    match scope {
607        "project" => Ok(config.project_dir.as_deref().map(|p| p.to_path_buf())),
608        "user" => Ok(config.user_dir.as_deref().map(|p| p.to_path_buf())),
609        other => Err(MiniAppError::Snapshot(format!(
610            "unrecognised scope '{other}': expected 'project' or 'user'"
611        ))),
612    }
613}
614
615/// Returns a human-readable scope label (`"project"`, `"user"`, or `"unknown"`)
616/// by comparing `scope_root` against the configured dirs.
617fn scope_label_for(scope_root: &Path, config: &Config) -> &'static str {
618    if let Some(pd) = config.project_dir.as_deref() {
619        if scope_root.starts_with(pd) {
620            return "project";
621        }
622    }
623    if let Some(ud) = config.user_dir.as_deref() {
624        if scope_root.starts_with(ud) {
625            return "user";
626        }
627    }
628    "unknown"
629}
630
631/// Counts how many snapshot generations would be purged for a given table
632/// given the current retention setting.
633///
634/// Reads the `_snapshots/` directory but never writes, modifies, or deletes
635/// anything (Crux: dry_run zero-write guarantee).
636///
637/// Returns `0` if the `_snapshots/` directory does not exist.
638fn count_would_purge(scope_root: &Path, table: &str, retention: usize) -> usize {
639    let snapshot_dir = scope_root.join("_snapshots");
640    if !snapshot_dir.exists() {
641        return 0;
642    }
643    let Ok(entries) = std::fs::read_dir(&snapshot_dir) else {
644        return 0;
645    };
646    let count = entries
647        .filter_map(|e| {
648            let e = e.ok()?;
649            let name = e.file_name();
650            parse_snapshot_timestamp(&name.to_string_lossy(), table, "db").map(|_| ())
651        })
652        .count();
653    count.saturating_sub(retention)
654}
655
656/// Counts the number of `.db` snapshot files for `table` in `snapshot_dir`.
657fn count_snapshots_in_dir(snapshot_dir: &Path, table: &str) -> usize {
658    if !snapshot_dir.exists() {
659        return 0;
660    }
661    let Ok(entries) = std::fs::read_dir(snapshot_dir) else {
662        return 0;
663    };
664    entries
665        .filter_map(|e| {
666            let e = e.ok()?;
667            let name = e.file_name();
668            parse_snapshot_timestamp(&name.to_string_lossy(), table, "db").map(|_| ())
669        })
670        .count()
671}
672
673/// Returns the path of the newest snapshot file for `table` in `scope_root/_snapshots/`,
674/// or `None` if none exist.
675fn newest_snapshot_path(scope_root: &Path, table: &str) -> Option<PathBuf> {
676    let snapshot_dir = scope_root.join("_snapshots");
677    let entries = std::fs::read_dir(&snapshot_dir).ok()?;
678    let mut best: Option<(u64, PathBuf)> = None;
679    for entry in entries.flatten() {
680        let name = entry.file_name();
681        let name_str = name.to_string_lossy();
682        if let Some(ts) = parse_snapshot_timestamp(&name_str, table, "db") {
683            if best.as_ref().is_none_or(|(best_ts, _)| ts > *best_ts) {
684                best = Some((ts, entry.path()));
685            }
686        }
687    }
688    best.map(|(_, path)| path)
689}
690
691/// Returns the sorted list of snapshot timestamps (descending) for a given
692/// table, scanning only `.db` files.  Used internally for testing.
693///
694/// # Arguments
695/// - `snapshot_dir`: the `_snapshots/` directory to scan.
696/// - `table`: the logical table name.
697///
698/// # Returns
699/// A `Vec<u64>` of timestamps sorted newest-first.
700///
701/// # Errors
702/// - [`MiniAppError::Snapshot`] if the directory cannot be read.
703#[cfg(test)]
704fn list_snapshot_timestamps(snapshot_dir: &Path, table: &str) -> Result<Vec<u64>, MiniAppError> {
705    let entries = std::fs::read_dir(snapshot_dir)
706        .map_err(|e| MiniAppError::Snapshot(format!("cannot read snapshot dir: {e}")))?;
707
708    let mut timestamps: Vec<u64> = entries
709        .filter_map(|entry| {
710            let entry = entry.ok()?;
711            let name = entry.file_name();
712            let name = name.to_string_lossy().to_string();
713            parse_snapshot_timestamp(&name, table, "db")
714        })
715        .collect();
716
717    timestamps.sort_unstable_by(|a, b| b.cmp(a));
718    Ok(timestamps)
719}
720
721#[cfg(test)]
722mod tests {
723    use super::*;
724    use rusqlite::Connection;
725    use std::path::PathBuf;
726    use tempfile::TempDir;
727    use tokio::task;
728
729    /// Helper: create a minimal SQLite database with WAL mode enabled at `path`.
730    fn create_test_db(path: &Path) {
731        // SAFETY: Connection::open and execute_batch are safe in test context;
732        // panicking here would fail the test with a clear message.
733        let conn = Connection::open(path).expect("open test db");
734        conn.execute_batch(
735            "PRAGMA journal_mode=WAL; CREATE TABLE IF NOT EXISTS t (id INTEGER PRIMARY KEY, v TEXT);",
736        )
737        .expect("setup test db");
738    }
739
740    // ── T1: happy-path ────────────────────────────────────────────────────
741
742    /// T1: write_snapshot_db creates exactly one .db file in `_snapshots/`
743    /// and does NOT create any .yaml file (snapshots are DB-only).
744    #[tokio::test]
745    async fn write_snapshot_db_creates_db_file_only() {
746        let dir = TempDir::new().expect("temp dir");
747        let scope_dir = dir.path();
748        let db_path = scope_dir.join("items.db");
749
750        create_test_db(&db_path);
751
752        write_snapshot_db(scope_dir, "items", &db_path)
753            .await
754            .expect("write_snapshot_db must succeed");
755
756        let snapshot_dir = scope_dir.join("_snapshots");
757        assert!(snapshot_dir.exists(), "_snapshots dir must be created");
758
759        let entries: Vec<_> = std::fs::read_dir(&snapshot_dir)
760            .expect("read snapshot dir")
761            .filter_map(|e| e.ok())
762            .collect();
763
764        let yaml_count = entries
765            .iter()
766            .filter(|e| e.file_name().to_string_lossy().ends_with(".yaml"))
767            .count();
768        let db_count = entries
769            .iter()
770            .filter(|e| e.file_name().to_string_lossy().ends_with(".db"))
771            .count();
772
773        assert_eq!(yaml_count, 0, "snapshot must NOT create any yaml file");
774        assert_eq!(db_count, 1, "exactly one db snapshot must exist");
775    }
776
777    /// T1: purge_old_snapshots keeps only the N newest .db files.
778    #[tokio::test]
779    async fn purge_old_snapshots_keeps_n_newest() {
780        let dir = TempDir::new().expect("temp dir");
781        let scope_dir = dir.path();
782        let snapshot_dir = scope_dir.join("_snapshots");
783        std::fs::create_dir_all(&snapshot_dir).expect("create snapshot dir");
784
785        // Create 5 fake snapshot .db files with distinct timestamps.
786        for ts in [100u64, 200, 300, 400, 500] {
787            std::fs::write(snapshot_dir.join(format!("items.{}.db", ts)), b"db").expect("write db");
788        }
789
790        purge_old_snapshots(scope_dir, "items", 3)
791            .await
792            .expect("purge must succeed");
793
794        // Newest 3 timestamps: 500, 400, 300.  Oldest 2 (100, 200) must be gone.
795        let timestamps = list_snapshot_timestamps(&snapshot_dir, "items").expect("list timestamps");
796        assert_eq!(timestamps.len(), 3, "exactly 3 snapshots must remain");
797        assert_eq!(timestamps, vec![500, 400, 300], "newest 3 must be kept");
798
799        // Verify the deleted snapshots are truly gone.
800        assert!(!snapshot_dir.join("items.100.db").exists());
801        assert!(!snapshot_dir.join("items.200.db").exists());
802    }
803
804    // ── T2: boundary / edge-case ──────────────────────────────────────────
805
806    /// T2: purge_old_snapshots is a no-op when snapshot count is below retention.
807    #[tokio::test]
808    async fn purge_old_snapshots_no_op_when_below_limit() {
809        let dir = TempDir::new().expect("temp dir");
810        let scope_dir = dir.path();
811        let snapshot_dir = scope_dir.join("_snapshots");
812        std::fs::create_dir_all(&snapshot_dir).expect("create snapshot dir");
813
814        // Only 2 snapshots, retention = 10.
815        for ts in [100u64, 200] {
816            std::fs::write(snapshot_dir.join(format!("items.{}.db", ts)), b"db").expect("write db");
817        }
818
819        purge_old_snapshots(scope_dir, "items", 10)
820            .await
821            .expect("purge must succeed");
822
823        let timestamps = list_snapshot_timestamps(&snapshot_dir, "items").expect("list timestamps");
824        assert_eq!(timestamps.len(), 2, "both snapshots must still exist");
825    }
826
827    /// T2: purge_old_snapshots is a no-op when _snapshots/ directory does not
828    /// exist yet (first call before any snapshot has been written).
829    #[tokio::test]
830    async fn purge_old_snapshots_no_op_when_dir_missing() {
831        let dir = TempDir::new().expect("temp dir");
832        let scope_dir = dir.path();
833        // _snapshots/ directory is never created.
834
835        let result = purge_old_snapshots(scope_dir, "items", 10).await;
836        assert!(result.is_ok(), "purge must succeed when dir is missing");
837
838        // Directory must still not exist after no-op purge.
839        assert!(!scope_dir.join("_snapshots").exists());
840    }
841
842    // ── T3: error-path ────────────────────────────────────────────────────
843
844    /// T3: write_snapshot_db returns Snapshot error when db_path does not exist.
845    #[tokio::test]
846    async fn write_snapshot_db_missing_db_returns_snapshot_variant() {
847        let dir = TempDir::new().expect("temp dir");
848        let scope_dir = dir.path();
849
850        // Point to a non-existent database file.
851        let result =
852            write_snapshot_db(scope_dir, "items", Path::new("/nonexistent/items.db")).await;
853
854        let err = result.expect_err("missing db file must error");
855        assert!(
856            matches!(err, MiniAppError::Snapshot(_)),
857            "expected Snapshot variant, got {:?}",
858            err
859        );
860    }
861
862    // ── Concurrency: snapshot does not block concurrent writes ────────────
863
864    /// Concurrency test: snapshot runs concurrently with INSERT operations and
865    /// both complete successfully.
866    ///
867    /// This verifies `rusqlite::Connection::backup` is safe to call on a
868    /// WAL-mode database while another connection is writing.  rusqlite docs
869    /// state "source can be used while the backup is running".
870    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
871    async fn test_snapshot_does_not_block_concurrent_writes() {
872        let dir = TempDir::new().expect("temp dir");
873        let db_path = dir.path().join("concurrent.db");
874
875        // Prepare DB with WAL mode and a table.
876        {
877            let conn = Connection::open(&db_path).expect("open db");
878            conn.execute_batch(
879                "PRAGMA journal_mode=WAL; CREATE TABLE rows (id INTEGER PRIMARY KEY, val TEXT);",
880            )
881            .expect("setup db");
882        }
883
884        let db_path_writer = db_path.clone();
885        let scope_dir = dir.path().to_path_buf();
886
887        // Launch writer task: inserts 100 rows using a separate connection.
888        let writer = task::spawn(async move {
889            task::spawn_blocking(move || {
890                let conn = Connection::open(&db_path_writer).expect("open writer db");
891                for i in 0i64..100 {
892                    conn.execute("INSERT INTO rows (val) VALUES (?1)", [format!("v{}", i)])
893                        .expect("insert row");
894                }
895            })
896            .await
897            .expect("writer blocking task")
898        });
899
900        // Launch snapshot task: runs the snapshot while writer is active.
901        let snapshot_task = write_snapshot_db(&scope_dir, "concurrent", &db_path);
902
903        let (writer_result, snapshot_result) = tokio::join!(writer, snapshot_task);
904
905        writer_result.expect("writer must succeed");
906        snapshot_result.expect("snapshot must succeed");
907
908        // The snapshot file must exist and be a valid SQLite database.
909        let snapshot_dir = scope_dir.join("_snapshots");
910        let snapshot_entries: Vec<PathBuf> = std::fs::read_dir(&snapshot_dir)
911            .expect("read snapshot dir")
912            .filter_map(|e| e.ok())
913            .map(|e| e.path())
914            .filter(|p| {
915                p.extension()
916                    .and_then(|x| x.to_str())
917                    .map(|x| x == "db")
918                    .unwrap_or(false)
919            })
920            .collect();
921        assert!(
922            !snapshot_entries.is_empty(),
923            "at least one db snapshot must exist"
924        );
925
926        // Verify snapshot db is a valid SQLite database (can be opened).
927        let snap_conn = Connection::open(&snapshot_entries[0]).expect("open snapshot db");
928        let snap_row_count: i64 = snap_conn
929            .query_row("SELECT COUNT(*) FROM rows", [], |row| row.get(0))
930            .unwrap_or(0);
931        // Snapshot may have captured 0..100 rows (concurrent; exact count not deterministic).
932        assert!(snap_row_count >= 0, "snapshot db must be a valid sqlite db");
933    }
934
935    // ── Concurrency: spawn_blocking cancel safety ─────────────────────────
936
937    /// Cancel-safety test: dropping a `write_snapshot_db` Future immediately
938    /// after spawn_blocking starts does not leave the source DB in a corrupt state.
939    ///
940    /// `tokio::task::spawn_blocking` is abort-unsafe: once the blocking
941    /// closure starts running it runs to completion even if the outer Future
942    /// is dropped.  This test verifies that the source DB remains valid after
943    /// the Future has been dropped.
944    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
945    async fn test_spawn_blocking_cancel_safety_snapshot_survives() {
946        let dir = TempDir::new().expect("temp dir");
947        let scope_dir = dir.path().to_path_buf();
948        let db_path = scope_dir.join("cancel_test.db");
949
950        {
951            let conn = Connection::open(&db_path).expect("open db");
952            conn.execute_batch(
953                "PRAGMA journal_mode=WAL; CREATE TABLE rows (id INTEGER PRIMARY KEY, val TEXT);",
954            )
955            .expect("setup db");
956        }
957
958        // Issue snapshot with a very short timeout to trigger "cancel" of the Future.
959        // spawn_blocking closure continues running even after the outer Future is dropped.
960        let snapshot_fut = write_snapshot_db(&scope_dir, "cancel_test", &db_path);
961        let result = tokio::time::timeout(std::time::Duration::from_millis(1), snapshot_fut).await;
962
963        // Give the spawn_blocking closure time to complete (it runs to completion
964        // regardless of the timeout because spawn_blocking is abort-unsafe).
965        tokio::time::sleep(std::time::Duration::from_millis(500)).await;
966
967        // The source DB must not be corrupted regardless of whether the Future
968        // was cancelled or completed.
969        let src_conn = Connection::open(&db_path).expect("source db must still be openable");
970        let _count: i64 = src_conn
971            .query_row("SELECT COUNT(*) FROM rows", [], |row| row.get(0))
972            .expect("source db must be a valid sqlite db after cancellation");
973
974        // If the future completed successfully, verify the snapshot directory exists.
975        if let Ok(Ok(())) = result {
976            let snapshot_dir = scope_dir.join("_snapshots");
977            assert!(
978                snapshot_dir.exists(),
979                "snapshot dir must exist on successful write"
980            );
981        }
982        // Whether timed out or not, no panic occurred — the test passes.
983    }
984
985    // ── Integration: do_data_snapshot dry_run zero-write guarantee ────────
986
987    /// T1/Crux2: dry_run=true returns affects metadata without creating any
988    /// file or database state.
989    ///
990    /// Verifies the Crux "dry_run zero-write guarantee": after calling
991    /// `do_data_snapshot` with `dry_run=true`, the `_snapshots/` directory
992    /// must not exist (it was not present before the call).
993    #[tokio::test]
994    async fn test_do_data_snapshot_dry_run_zero_write() {
995        use crate::config::Config;
996        use crate::registry::{TableEntry, TableRegistry};
997        use crate::schema::{FieldDef, FieldType, SchemaConfig};
998        use crate::store::Store;
999        use arc_swap::ArcSwap;
1000        use std::collections::HashMap;
1001
1002        let dir = TempDir::new().expect("temp dir");
1003        let table_name = "items";
1004
1005        // Create multi-table layout: scope_root/{table}/schema.yaml
1006        // scope_root = dir.path()
1007        // table_dir  = dir.path()/{table}/
1008        // schema_path = dir.path()/{table}/schema.yaml
1009        // db_path     = dir.path()/{table}/{table}.db
1010        let table_dir = dir.path().join(table_name);
1011        std::fs::create_dir_all(&table_dir).expect("create table dir");
1012
1013        let schema_path = table_dir.join("schema.yaml");
1014        std::fs::write(
1015            &schema_path,
1016            "table: items\nfields:\n  - name: title\n    type: string\n    required: true\n",
1017        )
1018        .expect("write schema.yaml");
1019
1020        let db_path = table_dir.join(format!("{}.db", table_name));
1021        // SAFETY: Connection::open and execute_batch are safe in test context.
1022        let conn = Connection::open(&db_path).expect("open test db");
1023        conn.execute_batch(
1024            "PRAGMA journal_mode=WAL; \
1025             CREATE TABLE IF NOT EXISTS rows (id TEXT PRIMARY KEY, data TEXT, created_at TEXT, updated_at TEXT);",
1026        )
1027        .expect("setup test db");
1028        drop(conn);
1029
1030        // Build Store and TableRegistry in multi-table mode (default_table = None).
1031        let schema = SchemaConfig {
1032            table: table_name.to_string(),
1033            title: None,
1034            description: None,
1035            fields: vec![FieldDef {
1036                name: "title".to_string(),
1037                ty: FieldType::String,
1038                required: true,
1039                description: None,
1040            }],
1041            dump: None,
1042        };
1043        let store = Store::open(&db_path, schema.clone())
1044            .await
1045            .expect("open store");
1046
1047        let entry = TableEntry {
1048            store: Arc::new(store),
1049            schema: Arc::new(schema),
1050            schema_path: Arc::new(schema_path),
1051        };
1052        let mut entries = HashMap::new();
1053        entries.insert(table_name.to_string(), entry);
1054        // Multi-table mode: default_table = None — scope_root is 2 levels up from schema.yaml
1055        let registry = TableRegistry::from_entries(entries, None);
1056        let tables: Arc<ArcSwap<TableRegistry>> = Arc::new(ArcSwap::from_pointee(registry));
1057
1058        // Config: project_dir points to scope_root (dir.path()).
1059        let config = Config {
1060            schema_path: None,
1061            db_path: None,
1062            user_dir: None,
1063            project_dir: Some(dir.path().to_path_buf()),
1064            backup_retention: None,
1065            snapshot_retention: None,
1066        };
1067
1068        // _snapshots/ must NOT exist before the dry_run call.
1069        // In multi-table mode scope_root = dir.path(), so _snapshots is at dir.path()/_snapshots/.
1070        let snapshots_dir = dir.path().join("_snapshots");
1071        assert!(
1072            !snapshots_dir.exists(),
1073            "_snapshots must not exist before dry_run call"
1074        );
1075
1076        let params = DataSnapshotParams {
1077            table: None,
1078            scope: None,
1079            dry_run: Some(true),
1080            upload: None,
1081        };
1082
1083        let result = do_data_snapshot(&config, &tables, params)
1084            .await
1085            .expect("do_data_snapshot dry_run must succeed");
1086
1087        // _snapshots/ must STILL not exist — zero-write guarantee (Crux 2).
1088        assert!(
1089            !snapshots_dir.exists(),
1090            "_snapshots must not be created by dry_run=true (Crux: zero-write guarantee)"
1091        );
1092
1093        // Response must carry dry_run: true and affects.target_tables.
1094        // SAFETY: serde_json::from_str is safe to unwrap in test context.
1095        let json: serde_json::Value =
1096            serde_json::from_str(&result).expect("result must be valid JSON");
1097        assert_eq!(
1098            json["dry_run"],
1099            serde_json::Value::Bool(true),
1100            "response must contain dry_run: true"
1101        );
1102        let target_tables = json["affects"]["target_tables"]
1103            .as_array()
1104            .expect("affects.target_tables must be an array");
1105        assert_eq!(
1106            target_tables.len(),
1107            1,
1108            "exactly one table should be in target_tables"
1109        );
1110        assert_eq!(
1111            target_tables[0],
1112            serde_json::Value::String(table_name.to_string()),
1113            "target table must be 'items'"
1114        );
1115
1116        // row_counts and would_purge_generations must be present.
1117        assert!(
1118            json["affects"]["row_counts"].is_object(),
1119            "row_counts must be an object"
1120        );
1121        assert!(
1122            json["affects"]["would_purge_generations"].is_object(),
1123            "would_purge_generations must be an object"
1124        );
1125    }
1126
1127    /// T3 (D4 fail-before-first-write): `upload=true` on a build without the
1128    /// `s3-upload` feature errors with UPLOAD_NOT_CONFIGURED before any
1129    /// snapshot file is written.
1130    ///
1131    /// Gated to non-feature builds so the assertion is deterministic (with the
1132    /// feature enabled the same call proceeds to env resolution instead).
1133    #[cfg(not(feature = "s3-upload"))]
1134    #[tokio::test]
1135    async fn test_upload_without_feature_errors_before_any_write() {
1136        use crate::config::Config;
1137        use crate::registry::TableRegistry;
1138        use arc_swap::ArcSwap;
1139        use std::collections::HashMap;
1140
1141        let dir = TempDir::new().expect("temp dir");
1142
1143        let registry = TableRegistry::from_entries(HashMap::new(), None);
1144        let tables: Arc<ArcSwap<TableRegistry>> = Arc::new(ArcSwap::from_pointee(registry));
1145        let config = Config {
1146            schema_path: None,
1147            db_path: None,
1148            user_dir: None,
1149            project_dir: Some(dir.path().to_path_buf()),
1150            backup_retention: None,
1151            snapshot_retention: None,
1152        };
1153
1154        let params = DataSnapshotParams {
1155            table: None,
1156            scope: None,
1157            dry_run: None,
1158            upload: Some(true),
1159        };
1160
1161        let err = do_data_snapshot(&config, &tables, params)
1162            .await
1163            .expect_err("upload=true without the feature must error");
1164        assert_eq!(
1165            err.code(),
1166            crate::error::codes::UPLOAD_NOT_CONFIGURED,
1167            "expected UPLOAD_NOT_CONFIGURED, got {err:?}"
1168        );
1169
1170        // No snapshot dir may have been created (error precedes all writes).
1171        assert!(
1172            !dir.path().join("_snapshots").exists(),
1173            "_snapshots must not exist after an UPLOAD_NOT_CONFIGURED error"
1174        );
1175    }
1176}