Skip to main content

sqlite_graphrag/commands/
restore.rs

1//! Handler for the `restore` CLI subcommand.
2
3use crate::errors::AppError;
4use crate::i18n::errors_msg;
5use crate::output;
6use crate::output::JsonOutputFormat;
7use crate::paths::AppPaths;
8use crate::storage::connection::open_rw;
9use crate::storage::memories;
10use crate::storage::versions;
11use rusqlite::params;
12use rusqlite::OptionalExtension;
13use serde::Serialize;
14
15#[derive(clap::Args)]
16#[command(after_long_help = "EXAMPLES:\n  \
17    # Restore the latest non-`restore` version of a memory\n  \
18    sqlite-graphrag restore --name onboarding\n\n  \
19    # Restore a specific version\n  \
20    sqlite-graphrag restore --name onboarding --version 3\n\n  \
21    # Restore within a specific namespace\n  \
22    sqlite-graphrag restore --name onboarding --namespace my-project")]
23pub struct RestoreArgs {
24    /// Memory name to restore (must exist, including soft-deleted/forgotten).
25    #[arg(long)]
26    pub name: String,
27    /// Version to restore. When omitted, defaults to the latest non-`restore` version
28    /// from `memory_versions`. This makes the forget+restore workflow work without
29    /// requiring the user to discover the version first.
30    #[arg(long)]
31    pub version: Option<i64>,
32    #[arg(long, default_value = "global")]
33    pub namespace: Option<String>,
34    /// Optimistic locking: reject if the current updated_at does not match (exit 3).
35    #[arg(
36        long,
37        value_name = "EPOCH_OR_RFC3339",
38        value_parser = crate::parsers::parse_expected_updated_at,
39        long_help = "Optimistic lock: reject if updated_at does not match. \
40Accepts Unix epoch (e.g. 1700000000) or RFC 3339 (e.g. 2026-04-19T12:00:00Z)."
41    )]
42    pub expected_updated_at: Option<i64>,
43    /// Output format.
44    #[arg(long, value_enum, default_value_t = JsonOutputFormat::Json)]
45    pub format: JsonOutputFormat,
46    #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
47    pub json: bool,
48    #[arg(long, env = "SQLITE_GRAPHRAG_DB_PATH")]
49    pub db: Option<String>,
50}
51
52#[derive(Serialize)]
53struct RestoreResponse {
54    memory_id: i64,
55    name: String,
56    version: i64,
57    restored_from: i64,
58    /// Total execution time in milliseconds from handler start to serialisation.
59    elapsed_ms: u64,
60}
61
62pub fn run(args: RestoreArgs) -> Result<(), AppError> {
63    let start = std::time::Instant::now();
64    let _ = args.format;
65    let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
66    let paths = AppPaths::resolve(args.db.as_deref())?;
67    let mut conn = open_rw(&paths.db)?;
68
69    // PRD line 1118: query WITHOUT a deleted_at filter — restore must work on soft-deleted memories
70    let result: Option<(i64, i64)> = conn
71        .query_row(
72            "SELECT id, updated_at FROM memories WHERE namespace = ?1 AND name = ?2",
73            params![namespace, args.name],
74            |r| Ok((r.get(0)?, r.get(1)?)),
75        )
76        .optional()?;
77    let (memory_id, current_updated_at) = result
78        .ok_or_else(|| AppError::NotFound(errors_msg::memory_not_found(&args.name, &namespace)))?;
79
80    if let Some(expected) = args.expected_updated_at {
81        if expected != current_updated_at {
82            return Err(AppError::Conflict(errors_msg::optimistic_lock_conflict(
83                expected,
84                current_updated_at,
85            )));
86        }
87    }
88
89    // v1.0.22 P0: resolve optional `--version`. When absent, uses the highest version
90    // whose `change_reason` is not 'restore' (recovers the real state, not meta-restore).
91    // Lets the forget+restore workflow function without manually reading memory_versions.
92    let target_version: i64 = match args.version {
93        Some(v) => v,
94        None => {
95            let last: Option<i64> = conn
96                .query_row(
97                    "SELECT MAX(version) FROM memory_versions
98                     WHERE memory_id = ?1 AND change_reason != 'restore'",
99                    params![memory_id],
100                    |r| r.get(0),
101                )
102                .optional()?
103                .flatten();
104            let v = last.ok_or_else(|| {
105                AppError::NotFound(errors_msg::memory_not_found(&args.name, &namespace))
106            })?;
107            tracing::info!(
108                "restore --version omitted; using latest non-restore version: {}",
109                v
110            );
111            v
112        }
113    };
114
115    let version_row: (String, String, String, String, String) = {
116        let mut stmt = conn.prepare(
117            "SELECT name, type, description, body, metadata
118             FROM memory_versions
119             WHERE memory_id = ?1 AND version = ?2",
120        )?;
121
122        stmt.query_row(params![memory_id, target_version], |r| {
123            Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?))
124        })
125        .map_err(|_| {
126            AppError::NotFound(errors_msg::version_not_found(target_version, &args.name))
127        })?
128    };
129
130    let (old_name, old_type, old_description, old_body, old_metadata) = version_row;
131
132    // v1.0.21 P1-D: re-embed restored body to keep `vec_memories` synchronized
133    // with `memories`. Without this, semantic queries used the post-forget version
134    // vector, causing inconsistent recall (vec_memories=2 vs memories=3 after forget+restore).
135    output::emit_progress_i18n(
136        "Re-computing embedding for restored memory...",
137        crate::i18n::validation::runtime_pt::restore_recomputing_embedding(),
138    );
139    let embedding = crate::daemon::embed_passage_or_local(&paths.models, &old_body)?;
140    let snippet: String = old_body.chars().take(300).collect();
141
142    let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
143
144    // deleted_at = NULL reactivates soft-deleted memories; no deleted_at filter in the WHERE
145    let affected = if let Some(ts) = args.expected_updated_at {
146        tx.execute(
147            "UPDATE memories SET name=?2, type=?3, description=?4, body=?5, body_hash=?6, deleted_at=NULL
148             WHERE id=?1 AND updated_at=?7",
149            rusqlite::params![
150                memory_id,
151                old_name,
152                old_type,
153                old_description,
154                old_body,
155                blake3::hash(old_body.as_bytes()).to_hex().to_string(),
156                ts
157            ],
158        )?
159    } else {
160        tx.execute(
161            "UPDATE memories SET name=?2, type=?3, description=?4, body=?5, body_hash=?6, deleted_at=NULL
162             WHERE id=?1",
163            rusqlite::params![
164                memory_id,
165                old_name,
166                old_type,
167                old_description,
168                old_body,
169                blake3::hash(old_body.as_bytes()).to_hex().to_string()
170            ],
171        )?
172    };
173
174    if affected == 0 {
175        return Err(AppError::Conflict(errors_msg::concurrent_process_conflict()));
176    }
177
178    let next_v = versions::next_version(&tx, memory_id)?;
179
180    versions::insert_version(
181        &tx,
182        memory_id,
183        next_v,
184        &old_name,
185        &old_type,
186        &old_description,
187        &old_body,
188        &old_metadata,
189        None,
190        "restore",
191    )?;
192
193    // v1.0.21 P1-D: ressincronizar vec_memories com o body restaurado.
194    memories::upsert_vec(
195        &tx, memory_id, &namespace, &old_type, &embedding, &old_name, &snippet,
196    )?;
197
198    tx.commit()?;
199
200    output::emit_json(&RestoreResponse {
201        memory_id,
202        name: old_name,
203        version: next_v,
204        restored_from: target_version,
205        elapsed_ms: start.elapsed().as_millis() as u64,
206    })?;
207
208    Ok(())
209}
210
211#[cfg(test)]
212mod tests {
213    use crate::errors::AppError;
214
215    #[test]
216    fn optimistic_lock_conflict_returns_exit_3() {
217        let err = AppError::Conflict(
218            "optimistic lock conflict: expected updated_at=50, but current is 99".to_string(),
219        );
220        assert_eq!(err.exit_code(), 3);
221        assert!(err.to_string().contains("conflict"));
222    }
223}