sqlite_graphrag/commands/
restore.rs1use 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)]
17 pub name: String,
18 #[arg(long)]
22 pub version: Option<i64>,
23 #[arg(long, default_value = "global")]
24 pub namespace: Option<String>,
25 #[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 #[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 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 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 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 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 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 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}