Skip to main content

sqlite_graphrag/commands/
restore.rs

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