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)]
16pub struct RestoreArgs {
17    /// Memory name to restore (must exist, including soft-deleted/forgotten).
18    #[arg(long)]
19    pub name: String,
20    /// Version to restore. When omitted, defaults to the latest non-`restore` version
21    /// from `memory_versions`. This makes the forget+restore workflow work without
22    /// requiring the user to discover the version first.
23    #[arg(long)]
24    pub version: Option<i64>,
25    #[arg(long, default_value = "global")]
26    pub namespace: Option<String>,
27    /// Optimistic locking: reject if the current updated_at does not match (exit 3).
28    #[arg(
29        long,
30        value_name = "EPOCH_OR_RFC3339",
31        value_parser = crate::parsers::parse_expected_updated_at,
32        long_help = "Optimistic lock: reject if updated_at does not match. \
33Accepts Unix epoch (e.g. 1700000000) or RFC 3339 (e.g. 2026-04-19T12:00:00Z)."
34    )]
35    pub expected_updated_at: Option<i64>,
36    /// Output format.
37    #[arg(long, value_enum, default_value_t = JsonOutputFormat::Json)]
38    pub format: JsonOutputFormat,
39    #[arg(long, help = "No-op; JSON is always emitted on stdout")]
40    pub json: bool,
41    #[arg(long, env = "SQLITE_GRAPHRAG_DB_PATH")]
42    pub db: Option<String>,
43}
44
45#[derive(Serialize)]
46struct RestoreResponse {
47    memory_id: i64,
48    name: String,
49    version: i64,
50    restored_from: i64,
51    /// Total execution time in milliseconds from handler start to serialisation.
52    elapsed_ms: u64,
53}
54
55pub fn run(args: RestoreArgs) -> Result<(), AppError> {
56    let inicio = std::time::Instant::now();
57    let _ = args.format;
58    let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
59    let paths = AppPaths::resolve(args.db.as_deref())?;
60    let mut conn = open_rw(&paths.db)?;
61
62    // PRD linha 1118: buscar SEM filtro deleted_at — restore deve funcionar em memórias soft-deletadas
63    let result: Option<(i64, i64)> = conn
64        .query_row(
65            "SELECT id, updated_at FROM memories WHERE namespace = ?1 AND name = ?2",
66            params![namespace, args.name],
67            |r| Ok((r.get(0)?, r.get(1)?)),
68        )
69        .optional()?;
70    let (memory_id, current_updated_at) = result
71        .ok_or_else(|| AppError::NotFound(errors_msg::memory_not_found(&args.name, &namespace)))?;
72
73    if let Some(expected) = args.expected_updated_at {
74        if expected != current_updated_at {
75            return Err(AppError::Conflict(errors_msg::optimistic_lock_conflict(
76                expected,
77                current_updated_at,
78            )));
79        }
80    }
81
82    // v1.0.22 P0: resolve `--version` opcional. Quando ausente, usa a maior versão
83    // cujo `change_reason` não seja 'restore' (recupera o estado real, não meta-restore).
84    // Permite o workflow forget+restore funcionar sem ler memory_versions manualmente.
85    let target_version: i64 = match args.version {
86        Some(v) => v,
87        None => {
88            let last: Option<i64> = conn
89                .query_row(
90                    "SELECT MAX(version) FROM memory_versions
91                     WHERE memory_id = ?1 AND change_reason != 'restore'",
92                    params![memory_id],
93                    |r| r.get(0),
94                )
95                .optional()?
96                .flatten();
97            let v = last.ok_or_else(|| {
98                AppError::NotFound(errors_msg::memory_not_found(&args.name, &namespace))
99            })?;
100            tracing::info!(
101                "restore --version omitido; usando última versão não-restore: {}",
102                v
103            );
104            v
105        }
106    };
107
108    let version_row: (String, String, String, String, String) = {
109        let mut stmt = conn.prepare(
110            "SELECT name, type, description, body, metadata
111             FROM memory_versions
112             WHERE memory_id = ?1 AND version = ?2",
113        )?;
114
115        stmt.query_row(params![memory_id, target_version], |r| {
116            Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?))
117        })
118        .map_err(|_| {
119            AppError::NotFound(errors_msg::version_not_found(target_version, &args.name))
120        })?
121    };
122
123    let (old_name, old_type, old_description, old_body, old_metadata) = version_row;
124
125    // v1.0.21 P1-D: re-embed body restaurado para manter `vec_memories` sincronizado
126    // com `memories`. Sem isso, queries semânticas usavam o vetor da versão pós-forget,
127    // causando recall inconsistente (vec_memories=2 vs memories=3 após forget+restore).
128    output::emit_progress_i18n(
129        "Re-computing embedding for restored memory...",
130        "Recalculando embedding da memória restaurada...",
131    );
132    let embedding = crate::daemon::embed_passage_or_local(&paths.models, &old_body)?;
133    let snippet: String = old_body.chars().take(300).collect();
134
135    let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
136
137    // deleted_at = NULL reativa memórias soft-deletadas; sem filtro deleted_at no WHERE
138    let affected = if let Some(ts) = args.expected_updated_at {
139        tx.execute(
140            "UPDATE memories SET name=?2, type=?3, description=?4, body=?5, body_hash=?6, deleted_at=NULL
141             WHERE id=?1 AND updated_at=?7",
142            rusqlite::params![
143                memory_id,
144                old_name,
145                old_type,
146                old_description,
147                old_body,
148                blake3::hash(old_body.as_bytes()).to_hex().to_string(),
149                ts
150            ],
151        )?
152    } else {
153        tx.execute(
154            "UPDATE memories SET name=?2, type=?3, description=?4, body=?5, body_hash=?6, deleted_at=NULL
155             WHERE id=?1",
156            rusqlite::params![
157                memory_id,
158                old_name,
159                old_type,
160                old_description,
161                old_body,
162                blake3::hash(old_body.as_bytes()).to_hex().to_string()
163            ],
164        )?
165    };
166
167    if affected == 0 {
168        return Err(AppError::Conflict(errors_msg::concurrent_process_conflict()));
169    }
170
171    let next_v = versions::next_version(&tx, memory_id)?;
172
173    versions::insert_version(
174        &tx,
175        memory_id,
176        next_v,
177        &old_name,
178        &old_type,
179        &old_description,
180        &old_body,
181        &old_metadata,
182        None,
183        "restore",
184    )?;
185
186    // v1.0.21 P1-D: ressincronizar vec_memories com o body restaurado.
187    memories::upsert_vec(
188        &tx, memory_id, &namespace, &old_type, &embedding, &old_name, &snippet,
189    )?;
190
191    tx.commit()?;
192
193    output::emit_json(&RestoreResponse {
194        memory_id,
195        name: old_name,
196        version: next_v,
197        restored_from: target_version,
198        elapsed_ms: inicio.elapsed().as_millis() as u64,
199    })?;
200
201    Ok(())
202}
203
204#[cfg(test)]
205mod tests {
206    use crate::errors::AppError;
207
208    #[test]
209    fn optimistic_lock_conflict_retorna_exit_3() {
210        let err = AppError::Conflict(
211            "optimistic lock conflict: expected updated_at=50, but current is 99".to_string(),
212        );
213        assert_eq!(err.exit_code(), 3);
214        assert!(err.to_string().contains("conflict"));
215    }
216}