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")]
23/// Restore args.
24pub struct RestoreArgs {
25    /// Memory name as a positional argument. Alternative to `--name`.
26    #[arg(
27        value_name = "NAME",
28        conflicts_with = "name",
29        help = "Memory name to restore; alternative to --name"
30    )]
31    pub name_positional: Option<String>,
32    /// Memory name to restore (must exist, including soft-deleted/forgotten).
33    #[arg(long)]
34    pub name: Option<String>,
35    /// Version to restore. When omitted, defaults to the latest non-`restore` version
36    /// from `memory_versions`. This makes the forget+restore workflow work without
37    /// requiring the user to discover the version first.
38    #[arg(long)]
39    pub version: Option<i64>,
40    #[arg(
41        long,
42        help = "Namespace (flag / XDG namespace.default / global)"
43    )]
44    /// Namespace scope.
45    pub namespace: Option<String>,
46    /// Optimistic locking: reject if the current updated_at does not match (exit 3).
47    #[arg(
48        long,
49        value_name = "EPOCH_OR_RFC3339",
50        value_parser = crate::parsers::parse_expected_updated_at,
51        long_help = "Optimistic lock: reject if updated_at does not match. \
52Accepts Unix epoch (e.g. 1700000000) or RFC 3339 (e.g. 2026-04-19T12:00:00Z)."
53    )]
54    pub expected_updated_at: Option<i64>,
55    /// Output format.
56    #[arg(long, value_enum, default_value_t = JsonOutputFormat::Json)]
57    pub format: JsonOutputFormat,
58    /// Emit machine-readable JSON on stdout.
59    #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
60    pub json: bool,
61    /// Path to the SQLite database file.
62    #[arg(long)]
63    pub db: Option<String>,
64}
65
66#[derive(Serialize)]
67struct RestoreResponse {
68    /// Always `"restored"` — signals the completed action to shell callers and LLM agents.
69    action: String,
70    memory_id: i64,
71    name: String,
72    version: i64,
73    restored_from: i64,
74    /// Total execution time in milliseconds from handler start to serialisation.
75    elapsed_ms: u64,
76}
77
78/// Run.
79pub fn run(
80    args: RestoreArgs,
81    llm_backend: crate::cli::LlmBackendChoice,
82    embedding_backend: crate::cli::EmbeddingBackendChoice,
83) -> Result<(), AppError> {
84    let start = std::time::Instant::now();
85    let _ = args.format;
86    tracing::debug!(target: "restore", name = ?args.name_positional.as_deref().or(args.name.as_deref()), version = ?args.version, "restoring version");
87    let name = args
88        .name_positional
89        .as_deref()
90        .or(args.name.as_deref())
91        .ok_or_else(|| {
92            AppError::Validation(
93                "name required: pass as positional argument or via --name".to_string(),
94            )
95        })?
96        .to_string();
97    let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
98    let paths = AppPaths::resolve(args.db.as_deref())?;
99    let mut conn = open_rw(&paths.db)?;
100
101    // PRD line 1118: query WITHOUT a deleted_at filter — restore must work on soft-deleted memories
102    let result: Option<(i64, i64)> = conn
103        .query_row(
104            "SELECT id, updated_at FROM memories WHERE namespace = ?1 AND name = ?2",
105            params![namespace, name],
106            |r| Ok((r.get(0)?, r.get(1)?)),
107        )
108        .optional()?;
109    let (memory_id, current_updated_at) = result
110        .ok_or_else(|| AppError::NotFound(errors_msg::memory_not_found(&name, &namespace)))?;
111
112    if let Some(expected) = args.expected_updated_at {
113        if expected != current_updated_at {
114            return Err(AppError::Conflict(errors_msg::optimistic_lock_conflict(
115                expected,
116                current_updated_at,
117            )));
118        }
119    }
120
121    // v1.0.22 P0: resolve optional `--version`. When absent, uses the highest version
122    // whose `change_reason` is not 'restore' (recovers the real state, not meta-restore).
123    // Lets the forget+restore workflow function without manually reading memory_versions.
124    let target_version: i64 = match args.version {
125        Some(v) => v,
126        None => {
127            let last: Option<i64> = conn
128                .query_row(
129                    "SELECT MAX(version) FROM memory_versions
130                     WHERE memory_id = ?1 AND change_reason != 'restore'",
131                    params![memory_id],
132                    |r| r.get(0),
133                )
134                .optional()?
135                .flatten();
136            let v = last.ok_or_else(|| {
137                AppError::NotFound(errors_msg::memory_not_found(&name, &namespace))
138            })?;
139            tracing::info!(target: "restore",
140                "restore --version omitted; using latest non-restore version: {}",
141                v
142            );
143            v
144        }
145    };
146
147    let version_row: (String, String, String, String, String) = {
148        let mut stmt = conn.prepare_cached(
149            "SELECT name, type, description, body, metadata
150             FROM memory_versions
151             WHERE memory_id = ?1 AND version = ?2",
152        )?;
153
154        stmt.query_row(params![memory_id, target_version], |r| {
155            Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?))
156        })
157        .map_err(|_| AppError::NotFound(errors_msg::version_not_found(target_version, &name)))?
158    };
159
160    let (_old_name, old_type, old_description, old_body, old_metadata) = version_row;
161
162    // Read current FTS-indexed values before the UPDATE so sync_fts_after_update
163    // can issue the correct DELETE command for the external-content FTS5 table.
164    let (cur_name, cur_desc, cur_body): (String, String, String) = conn.query_row(
165        "SELECT name, description, body FROM memories WHERE id = ?1",
166        params![memory_id],
167        |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
168    )?;
169
170    // v1.0.21 P1-D: re-embed restored body to keep `vec_memories` synchronized
171    // with `memories`. Without this, semantic queries used the post-forget version
172    // vector, causing inconsistent recall (vec_memories=2 vs memories=3 after forget+restore).
173    output::emit_progress_i18n(
174        "Re-computing embedding for restored memory...",
175        crate::i18n::validation::runtime_pt::restore_recomputing_embedding(),
176    );
177    let skip_embed = crate::embedder::should_skip_embedding_on_failure();
178    let embedding: Option<Vec<f32>> = match crate::embedder::embed_passage_with_embedding_choice(
179        &paths.models,
180        &old_body,
181        embedding_backend,
182        llm_backend,
183    ) {
184        Ok((emb, _backend)) => Some(emb),
185        // v1.1.2 (Gap 2): typed payload rejections are permanent and must not
186        // be swallowed by --skip-embedding-on-failure.
187        Err(
188            e @ (AppError::Validation(_)
189            | AppError::BodyTooLarge { .. }
190            | AppError::TooManyTokens { .. }),
191        ) => return Err(e),
192        Err(e) if skip_embed => {
193            tracing::warn!(error = %e, "restore: embedding failed; --skip-embedding-on-failure active, persisting without embedding");
194            None
195        }
196        Err(e) => return Err(e),
197    };
198    let snippet: String = old_body.chars().take(300).collect();
199
200    let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
201
202    // deleted_at = NULL reactivates soft-deleted memories; no deleted_at filter in the WHERE
203    let affected = if let Some(ts) = args.expected_updated_at {
204        tx.execute(
205            "UPDATE memories SET type=?2, description=?3, body=?4, body_hash=?5, deleted_at=NULL
206             WHERE id=?1 AND updated_at=?6",
207            rusqlite::params![
208                memory_id,
209                old_type,
210                old_description,
211                old_body,
212                blake3::hash(old_body.as_bytes()).to_hex().to_string(),
213                ts
214            ],
215        )?
216    } else {
217        tx.execute(
218            "UPDATE memories SET type=?2, description=?3, body=?4, body_hash=?5, deleted_at=NULL
219             WHERE id=?1",
220            rusqlite::params![
221                memory_id,
222                old_type,
223                old_description,
224                old_body,
225                blake3::hash(old_body.as_bytes()).to_hex().to_string()
226            ],
227        )?
228    };
229
230    if affected == 0 {
231        return Err(AppError::Conflict(errors_msg::concurrent_process_conflict()));
232    }
233
234    let next_v = versions::next_version(&tx, memory_id)?;
235
236    versions::insert_version(
237        &tx,
238        memory_id,
239        next_v,
240        &cur_name,
241        &old_type,
242        &old_description,
243        &old_body,
244        &old_metadata,
245        None,
246        "restore",
247    )?;
248
249    if let Some(ref emb) = embedding {
250        memories::upsert_vec(
251            &tx, memory_id, &namespace, &old_type, emb, &cur_name, &snippet,
252        )?;
253    }
254
255    memories::sync_fts_after_update(
256        &tx,
257        memory_id,
258        &cur_name,
259        &cur_desc,
260        &cur_body,
261        &cur_name,
262        &old_description,
263        &old_body,
264    )?;
265
266    tx.commit()?;
267
268    conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
269
270    output::emit_json(&RestoreResponse {
271        action: "restored".to_string(),
272        memory_id,
273        name: cur_name.clone(),
274        version: next_v,
275        restored_from: target_version,
276        elapsed_ms: start.elapsed().as_millis() as u64,
277    })?;
278
279    Ok(())
280}
281
282#[cfg(test)]
283mod tests {
284    use crate::errors::AppError;
285
286    #[test]
287    fn optimistic_lock_conflict_returns_exit_3() {
288        let err = AppError::Conflict(
289            "optimistic lock conflict: expected updated_at=50, but current is 99".to_string(),
290        );
291        assert_eq!(err.exit_code(), 3);
292        assert!(err.to_string().contains("conflict"));
293    }
294
295    #[test]
296    fn restore_response_includes_action_field() {
297        let resp = super::RestoreResponse {
298            action: "restored".to_string(),
299            memory_id: 1,
300            name: "test-mem".to_string(),
301            version: 3,
302            restored_from: 2,
303            elapsed_ms: 42,
304        };
305        let json = serde_json::to_value(&resp).expect("serialization failed");
306        assert_eq!(json["action"], "restored");
307        assert_eq!(json["memory_id"], 1);
308        assert_eq!(json["version"], 3);
309        assert_eq!(json["restored_from"], 2);
310    }
311}