1use 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
44pub 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
95fn write_snapshot_db_sync(
98 scope_dir: &Path,
99 table: &str,
100 db_path: &Path,
101) -> Result<(), MiniAppError> {
102 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 let src_conn = Connection::open(db_path)
115 .map_err(|e| MiniAppError::Snapshot(format!("cannot open source db: {e}")))?;
116
117 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 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
131pub 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
172fn 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 !snapshot_dir.exists() {
183 return Ok(());
184 }
185
186 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 timestamps.sort_unstable_by(|a, b| b.cmp(a));
201
202 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
218pub(crate) fn parse_snapshot_timestamp(filename: &str, table: &str, ext: &str) -> Option<u64> {
229 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#[derive(Debug, Default, Deserialize, JsonSchema)]
248#[serde(default)]
249pub struct DataSnapshotParams {
250 pub table: Option<String>,
253 pub scope: Option<String>,
256 pub dry_run: Option<bool>,
260 pub upload: Option<bool>,
271}
272
273struct SnapshotTarget {
279 table_name: String,
280 scope_root: PathBuf,
281 db_path: PathBuf,
282 store: Arc<crate::store::Store>,
283}
284
285pub 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 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 let targets: Vec<SnapshotTarget> = {
339 let registry = tables.load_full();
340 resolve_targets(
341 ®istry,
342 config,
343 params.table.as_deref(),
344 params.scope.as_deref(),
345 )?
346 };
347
348 if dry_run {
349 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 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 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 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_snapshot_db(&target.scope_root, &target.table_name, &target.db_path).await?;
401
402 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 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 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
487fn 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 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 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()); }
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 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 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 targets.sort_by(|a, b| a.table_name.cmp(&b.table_name));
569 Ok(targets)
570}
571
572fn 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
599fn 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
615fn 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
631fn 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
656fn 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
673fn 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#[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 fn create_test_db(path: &Path) {
731 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 #[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 #[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 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 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 assert!(!snapshot_dir.join("items.100.db").exists());
801 assert!(!snapshot_dir.join("items.200.db").exists());
802 }
803
804 #[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 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 #[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 let result = purge_old_snapshots(scope_dir, "items", 10).await;
836 assert!(result.is_ok(), "purge must succeed when dir is missing");
837
838 assert!(!scope_dir.join("_snapshots").exists());
840 }
841
842 #[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 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 #[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 {
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 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 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 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 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 assert!(snap_row_count >= 0, "snapshot db must be a valid sqlite db");
933 }
934
935 #[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 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 tokio::time::sleep(std::time::Duration::from_millis(500)).await;
966
967 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 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 }
984
985 #[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 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 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 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 let registry = TableRegistry::from_entries(entries, None);
1056 let tables: Arc<ArcSwap<TableRegistry>> = Arc::new(ArcSwap::from_pointee(registry));
1057
1058 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 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 assert!(
1089 !snapshots_dir.exists(),
1090 "_snapshots must not be created by dry_run=true (Crux: zero-write guarantee)"
1091 );
1092
1093 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 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 #[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 assert!(
1172 !dir.path().join("_snapshots").exists(),
1173 "_snapshots must not exist after an UPLOAD_NOT_CONFIGURED error"
1174 );
1175 }
1176}