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