Skip to main content

sqlite_graphrag/commands/
restore.rs

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