sqlite_graphrag/commands/
restore.rs1use crate::errors::AppError;
4use crate::i18n::errors_msg;
5use crate::output;
6use crate::output::JsonOutputFormat;
7use crate::paths::AppPaths;
8use crate::storage::connection::open_rw;
9use crate::storage::memories;
10use crate::storage::versions;
11use rusqlite::params;
12use rusqlite::OptionalExtension;
13use serde::Serialize;
14
15#[derive(clap::Args)]
16pub struct RestoreArgs {
17 #[arg(long)]
19 pub name: String,
20 #[arg(long)]
24 pub version: Option<i64>,
25 #[arg(long, default_value = "global")]
26 pub namespace: Option<String>,
27 #[arg(
29 long,
30 value_name = "EPOCH_OR_RFC3339",
31 value_parser = crate::parsers::parse_expected_updated_at,
32 long_help = "Optimistic lock: reject if updated_at does not match. \
33Accepts Unix epoch (e.g. 1700000000) or RFC 3339 (e.g. 2026-04-19T12:00:00Z)."
34 )]
35 pub expected_updated_at: Option<i64>,
36 #[arg(long, value_enum, default_value_t = JsonOutputFormat::Json)]
38 pub format: JsonOutputFormat,
39 #[arg(long, help = "No-op; JSON is always emitted on stdout")]
40 pub json: bool,
41 #[arg(long, env = "SQLITE_GRAPHRAG_DB_PATH")]
42 pub db: Option<String>,
43}
44
45#[derive(Serialize)]
46struct RestoreResponse {
47 memory_id: i64,
48 name: String,
49 version: i64,
50 restored_from: i64,
51 elapsed_ms: u64,
53}
54
55pub fn run(args: RestoreArgs) -> Result<(), AppError> {
56 let inicio = std::time::Instant::now();
57 let _ = args.format;
58 let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
59 let paths = AppPaths::resolve(args.db.as_deref())?;
60 let mut conn = open_rw(&paths.db)?;
61
62 let result: Option<(i64, i64)> = conn
64 .query_row(
65 "SELECT id, updated_at FROM memories WHERE namespace = ?1 AND name = ?2",
66 params![namespace, args.name],
67 |r| Ok((r.get(0)?, r.get(1)?)),
68 )
69 .optional()?;
70 let (memory_id, current_updated_at) = result
71 .ok_or_else(|| AppError::NotFound(errors_msg::memory_not_found(&args.name, &namespace)))?;
72
73 if let Some(expected) = args.expected_updated_at {
74 if expected != current_updated_at {
75 return Err(AppError::Conflict(errors_msg::optimistic_lock_conflict(
76 expected,
77 current_updated_at,
78 )));
79 }
80 }
81
82 let target_version: i64 = match args.version {
86 Some(v) => v,
87 None => {
88 let last: Option<i64> = conn
89 .query_row(
90 "SELECT MAX(version) FROM memory_versions
91 WHERE memory_id = ?1 AND change_reason != 'restore'",
92 params![memory_id],
93 |r| r.get(0),
94 )
95 .optional()?
96 .flatten();
97 let v = last.ok_or_else(|| {
98 AppError::NotFound(errors_msg::memory_not_found(&args.name, &namespace))
99 })?;
100 tracing::info!(
101 "restore --version omitido; usando última versão não-restore: {}",
102 v
103 );
104 v
105 }
106 };
107
108 let version_row: (String, String, String, String, String) = {
109 let mut stmt = conn.prepare(
110 "SELECT name, type, description, body, metadata
111 FROM memory_versions
112 WHERE memory_id = ?1 AND version = ?2",
113 )?;
114
115 stmt.query_row(params![memory_id, target_version], |r| {
116 Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?))
117 })
118 .map_err(|_| {
119 AppError::NotFound(errors_msg::version_not_found(target_version, &args.name))
120 })?
121 };
122
123 let (old_name, old_type, old_description, old_body, old_metadata) = version_row;
124
125 output::emit_progress_i18n(
129 "Re-computing embedding for restored memory...",
130 "Recalculando embedding da memória restaurada...",
131 );
132 let embedding = crate::daemon::embed_passage_or_local(&paths.models, &old_body)?;
133 let snippet: String = old_body.chars().take(300).collect();
134
135 let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
136
137 let affected = if let Some(ts) = args.expected_updated_at {
139 tx.execute(
140 "UPDATE memories SET name=?2, type=?3, description=?4, body=?5, body_hash=?6, deleted_at=NULL
141 WHERE id=?1 AND updated_at=?7",
142 rusqlite::params![
143 memory_id,
144 old_name,
145 old_type,
146 old_description,
147 old_body,
148 blake3::hash(old_body.as_bytes()).to_hex().to_string(),
149 ts
150 ],
151 )?
152 } else {
153 tx.execute(
154 "UPDATE memories SET name=?2, type=?3, description=?4, body=?5, body_hash=?6, deleted_at=NULL
155 WHERE id=?1",
156 rusqlite::params![
157 memory_id,
158 old_name,
159 old_type,
160 old_description,
161 old_body,
162 blake3::hash(old_body.as_bytes()).to_hex().to_string()
163 ],
164 )?
165 };
166
167 if affected == 0 {
168 return Err(AppError::Conflict(errors_msg::concurrent_process_conflict()));
169 }
170
171 let next_v = versions::next_version(&tx, memory_id)?;
172
173 versions::insert_version(
174 &tx,
175 memory_id,
176 next_v,
177 &old_name,
178 &old_type,
179 &old_description,
180 &old_body,
181 &old_metadata,
182 None,
183 "restore",
184 )?;
185
186 memories::upsert_vec(
188 &tx, memory_id, &namespace, &old_type, &embedding, &old_name, &snippet,
189 )?;
190
191 tx.commit()?;
192
193 output::emit_json(&RestoreResponse {
194 memory_id,
195 name: old_name,
196 version: next_v,
197 restored_from: target_version,
198 elapsed_ms: inicio.elapsed().as_millis() as u64,
199 })?;
200
201 Ok(())
202}
203
204#[cfg(test)]
205mod tests {
206 use crate::errors::AppError;
207
208 #[test]
209 fn optimistic_lock_conflict_retorna_exit_3() {
210 let err = AppError::Conflict(
211 "optimistic lock conflict: expected updated_at=50, but current is 99".to_string(),
212 );
213 assert_eq!(err.exit_code(), 3);
214 assert!(err.to_string().contains("conflict"));
215 }
216}