sqlite_graphrag/commands/
sync_safe_copy.rs1use crate::errors::AppError;
4use crate::i18n::validation;
5use crate::output;
6use crate::paths::AppPaths;
7use crate::storage::connection::open_rw;
8use serde::Serialize;
9
10#[derive(clap::Args)]
11#[command(after_long_help = "EXAMPLES:\n \
12 # Create a checkpointed snapshot safe for cloud sync\n \
13 sqlite-graphrag sync-safe-copy --dest /backup/graphrag-snapshot.sqlite\n\n \
14 # Use the --to alias\n \
15 sqlite-graphrag sync-safe-copy --to /backup/graphrag-snapshot.sqlite\n\n \
16 # Snapshot a custom source database\n \
17 sqlite-graphrag sync-safe-copy --db /data/graphrag.sqlite --dest /backup/snapshot.sqlite")]
18pub struct SyncSafeCopyArgs {
19 #[arg(
21 value_name = "DEST",
22 conflicts_with = "dest",
23 help = "Snapshot destination path; alternative to --dest"
24 )]
25 pub dest_positional: Option<std::path::PathBuf>,
26 #[arg(long, alias = "to", alias = "output")]
28 pub dest: Option<std::path::PathBuf>,
29 #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
30 pub json: bool,
31 #[arg(long, value_parser = ["json", "text"], hide = true)]
33 pub format: Option<String>,
34 #[arg(long, env = "SQLITE_GRAPHRAG_DB_PATH")]
35 pub db: Option<String>,
36}
37
38#[derive(Serialize)]
39struct SyncSafeCopyResponse {
40 source_db_path: String,
41 dest_path: String,
42 bytes_copied: u64,
43 status: String,
44 elapsed_ms: u64,
46}
47
48pub fn run(args: SyncSafeCopyArgs) -> Result<(), AppError> {
49 let start = std::time::Instant::now();
50 let _ = args.format; let dest = args
52 .dest_positional
53 .clone()
54 .or_else(|| args.dest.clone())
55 .ok_or_else(|| {
56 AppError::Validation(
57 "destination required: pass as positional argument or via --dest/--to/--output"
58 .to_string(),
59 )
60 })?;
61 let paths = AppPaths::resolve(args.db.as_deref())?;
62
63 crate::storage::connection::ensure_db_ready(&paths)?;
64
65 if dest == paths.db {
66 return Err(AppError::Validation(
67 validation::sync_destination_equals_source(),
68 ));
69 }
70
71 if let Some(parent) = dest.parent() {
72 std::fs::create_dir_all(parent)?;
73 }
74
75 let conn = open_rw(&paths.db)?;
76 conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
77 drop(conn);
78
79 let bytes_copied = std::fs::copy(&paths.db, &dest)?;
80
81 #[cfg(unix)]
84 {
85 use std::os::unix::fs::PermissionsExt;
86 let mut perms = std::fs::metadata(&dest)?.permissions();
87 perms.set_mode(0o600);
88 std::fs::set_permissions(&dest, perms)?;
89 }
90 #[cfg(windows)]
91 {
92 tracing::debug!(
93 path = %dest.display(),
94 "skipping Unix mode 0o600 on Windows; NTFS DACL default is private-to-user"
95 );
96 }
97
98 output::emit_json(&SyncSafeCopyResponse {
99 source_db_path: paths.db.display().to_string(),
100 dest_path: dest.display().to_string(),
101 bytes_copied,
102 status: "ok".to_string(),
103 elapsed_ms: start.elapsed().as_millis() as u64,
104 })?;
105
106 Ok(())
107}
108
109#[cfg(test)]
110mod tests {
111 use super::*;
112
113 #[test]
114 fn sync_safe_copy_response_serializes_all_fields() {
115 let resp = SyncSafeCopyResponse {
116 source_db_path: "/home/user/.local/share/sqlite-graphrag/db.sqlite".to_string(),
117 dest_path: "/tmp/backup.sqlite".to_string(),
118 bytes_copied: 16384,
119 status: "ok".to_string(),
120 elapsed_ms: 12,
121 };
122 let json = serde_json::to_value(&resp).expect("serialization failed");
123 assert_eq!(
124 json["source_db_path"],
125 "/home/user/.local/share/sqlite-graphrag/db.sqlite"
126 );
127 assert_eq!(json["dest_path"], "/tmp/backup.sqlite");
128 assert_eq!(json["bytes_copied"], 16384u64);
129 assert_eq!(json["status"], "ok");
130 assert_eq!(json["elapsed_ms"], 12u64);
131 }
132
133 #[test]
134 fn sync_safe_copy_rejects_dest_equal_to_source() {
135 let db_path = std::path::PathBuf::from("/tmp/same.sqlite");
136 let args = SyncSafeCopyArgs {
137 dest_positional: None,
138 dest: Some(db_path.clone()),
139 json: false,
140 format: None,
141 db: Some("/tmp/same.sqlite".to_string()),
142 };
143 let resolved_dest = args
145 .dest_positional
146 .clone()
147 .or_else(|| args.dest.clone())
148 .expect("test must pass dest");
149 let result = if resolved_dest == std::path::PathBuf::from(args.db.as_deref().unwrap_or(""))
150 {
151 Err(AppError::Validation(
152 "destination path must differ from the source database path".to_string(),
153 ))
154 } else {
155 Ok(())
156 };
157 assert!(result.is_err(), "must reject dest equal to source");
158 if let Err(AppError::Validation(msg)) = result {
159 assert!(msg.contains("destination path must differ"));
160 }
161 }
162
163 #[test]
164 fn sync_safe_copy_response_status_ok() {
165 let resp = SyncSafeCopyResponse {
166 source_db_path: "/data/db.sqlite".to_string(),
167 dest_path: "/backup/db.sqlite".to_string(),
168 bytes_copied: 0,
169 status: "ok".to_string(),
170 elapsed_ms: 0,
171 };
172 let json = serde_json::to_value(&resp).expect("serialization failed");
173 assert_eq!(json["status"], "ok");
174 }
175
176 #[test]
177 fn sync_safe_copy_response_bytes_copied_zero_valid() {
178 let resp = SyncSafeCopyResponse {
179 source_db_path: "/data/db.sqlite".to_string(),
180 dest_path: "/backup/db.sqlite".to_string(),
181 bytes_copied: 0,
182 status: "ok".to_string(),
183 elapsed_ms: 1,
184 };
185 let json = serde_json::to_value(&resp).expect("serialization failed");
186 assert_eq!(json["bytes_copied"], 0u64);
187 assert_eq!(json["elapsed_ms"], 1u64);
188 }
189}