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