Skip to main content

sqlite_graphrag/commands/
backup.rs

1//! Handler for the `backup` CLI subcommand.
2//!
3//! Uses the SQLite Online Backup API (via rusqlite) to produce a consistent
4//! point-in-time copy of the database file even while the database is in use.
5
6use 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    /// Destination path for the backup file. Required.
29    #[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    // Validate: destination must differ from source.
53    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    // Create parent directories if necessary.
60    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    // Atomic write: backup to tempfile in the SAME directory, then rename.
66    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    // Apply 0600 permissions on Unix to prevent leakage in shared directories.
82    #[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        // Simulate the guard without a real DB.
160        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}