sqlite_graphrag/commands/
backup.rs1use crate::errors::AppError;
7use crate::output;
8use crate::paths::AppPaths;
9use crate::storage::connection::open_ro;
10use serde::Serialize;
11use std::path::PathBuf;
12use tempfile::NamedTempFile;
13
14#[derive(clap::Args)]
15#[command(after_long_help = "EXAMPLES:\n \
16 # Back up the default database to a specific path\n \
17 sqlite-graphrag backup --output /backup/graphrag-$(date +%F).sqlite\n\n \
18 # Back up a custom source database\n \
19 sqlite-graphrag backup --db /data/graphrag.sqlite --output /backup/snapshot.sqlite\n\n \
20 # Emit JSON on success\n \
21 sqlite-graphrag backup --output /tmp/snap.sqlite --json\n\n \
22NOTES:\n \
23 Uses the SQLite Online Backup API: safe to run while the database is in use.\n \
24 The destination is written atomically via tempfile-rename in the same directory.\n \
25 If the process is interrupted, the previous file (if any) remains intact.\n \
26 On Unix the destination is chmod 0600 after the backup completes.")]
27pub struct BackupArgs {
28 #[arg(long, value_name = "PATH")]
30 pub output: PathBuf,
31 #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
32 pub json: bool,
33 #[arg(long, env = "SQLITE_GRAPHRAG_DB_PATH")]
34 pub db: Option<String>,
35}
36
37#[derive(Serialize)]
38struct BackupResponse {
39 action: String,
40 source: String,
41 destination: String,
42 size_bytes: u64,
43 elapsed_ms: u64,
44}
45
46pub fn run(args: BackupArgs) -> Result<(), AppError> {
47 let start = std::time::Instant::now();
48 let paths = AppPaths::resolve(args.db.as_deref())?;
49
50 crate::storage::connection::ensure_db_ready(&paths)?;
51
52 if args.output == paths.db {
54 return Err(AppError::Validation(
55 "destination path must differ from the source database path".to_string(),
56 ));
57 }
58
59 let parent = args.output.parent().unwrap_or(std::path::Path::new("."));
61 if !parent.as_os_str().is_empty() {
62 std::fs::create_dir_all(parent)?;
63 }
64
65 let temp = NamedTempFile::new_in(parent).map_err(AppError::Io)?;
67 let temp_path = temp.path().to_path_buf();
68
69 let src_conn = open_ro(&paths.db)?;
70 let mut dst_conn = rusqlite::Connection::open(&temp_path)?;
71
72 {
73 let backup = rusqlite::backup::Backup::new(&src_conn, &mut dst_conn)?;
74 backup.run_to_completion(100, std::time::Duration::from_millis(50), None)?;
75 }
76 drop(dst_conn);
77
78 temp.persist(&args.output)
79 .map_err(|e| AppError::Io(e.error))?;
80
81 #[cfg(unix)]
83 {
84 use std::os::unix::fs::PermissionsExt;
85 if let Ok(meta) = std::fs::metadata(&args.output) {
86 let mut perms = meta.permissions();
87 perms.set_mode(0o600);
88 if let Err(e) = std::fs::set_permissions(&args.output, perms) {
89 tracing::warn!(
90 path = %args.output.display(),
91 error = %e,
92 "failed to set 0600 permissions on backup file"
93 );
94 }
95 }
96 }
97 #[cfg(windows)]
98 {
99 tracing::debug!(
100 path = %args.output.display(),
101 "skipping Unix mode 0o600 on Windows; NTFS DACL default is private-to-user"
102 );
103 }
104
105 let size_bytes = std::fs::metadata(&args.output)
106 .map(|m| m.len())
107 .unwrap_or(0);
108
109 output::emit_json(&BackupResponse {
110 action: "backed_up".to_string(),
111 source: paths.db.display().to_string(),
112 destination: args.output.display().to_string(),
113 size_bytes,
114 elapsed_ms: start.elapsed().as_millis() as u64,
115 })?;
116
117 Ok(())
118}
119
120#[cfg(test)]
121mod tests {
122 use super::*;
123
124 #[test]
125 fn backup_response_serializes_all_fields() {
126 let resp = BackupResponse {
127 action: "backed_up".to_string(),
128 source: "/data/graphrag.sqlite".to_string(),
129 destination: "/backup/snapshot.sqlite".to_string(),
130 size_bytes: 32768,
131 elapsed_ms: 42,
132 };
133 let json = serde_json::to_value(&resp).expect("serialization failed");
134 assert_eq!(json["action"], "backed_up");
135 assert_eq!(json["source"], "/data/graphrag.sqlite");
136 assert_eq!(json["destination"], "/backup/snapshot.sqlite");
137 assert_eq!(json["size_bytes"], 32768u64);
138 assert_eq!(json["elapsed_ms"], 42u64);
139 }
140
141 #[test]
142 fn backup_response_action_is_backed_up() {
143 let resp = BackupResponse {
144 action: "backed_up".to_string(),
145 source: "/a.sqlite".to_string(),
146 destination: "/b.sqlite".to_string(),
147 size_bytes: 0,
148 elapsed_ms: 0,
149 };
150 let json = serde_json::to_value(&resp).expect("serialization failed");
151 assert_eq!(
152 json["action"], "backed_up",
153 "action must always be 'backed_up'"
154 );
155 }
156
157 #[test]
158 fn backup_rejects_destination_equal_to_source() {
159 let src = PathBuf::from("/tmp/graphrag.sqlite");
161 let dst = PathBuf::from("/tmp/graphrag.sqlite");
162 let result: Result<(), AppError> = if dst == src {
163 Err(AppError::Validation(
164 "destination path must differ from the source database path".to_string(),
165 ))
166 } else {
167 Ok(())
168 };
169 assert!(
170 result.is_err(),
171 "must reject identical source and destination"
172 );
173 if let Err(AppError::Validation(msg)) = result {
174 assert!(msg.contains("destination path must differ"));
175 }
176 }
177
178 #[test]
179 fn backup_response_size_bytes_zero_is_valid() {
180 let resp = BackupResponse {
181 action: "backed_up".to_string(),
182 source: "/a.sqlite".to_string(),
183 destination: "/b.sqlite".to_string(),
184 size_bytes: 0,
185 elapsed_ms: 1,
186 };
187 let json = serde_json::to_value(&resp).expect("serialization failed");
188 assert_eq!(json["size_bytes"], 0u64);
189 }
190}