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)]
16#[command(after_long_help = "EXAMPLES:\n \
17 # Restore the latest non-`restore` version of a memory\n \
18 sqlite-graphrag restore --name onboarding\n\n \
19 # Restore a specific version\n \
20 sqlite-graphrag restore --name onboarding --version 3\n\n \
21 # Restore within a specific namespace\n \
22 sqlite-graphrag restore --name onboarding --namespace my-project")]
23pub struct RestoreArgs {
25 #[arg(
27 value_name = "NAME",
28 conflicts_with = "name",
29 help = "Memory name to restore; alternative to --name"
30 )]
31 pub name_positional: Option<String>,
32 #[arg(long)]
34 pub name: Option<String>,
35 #[arg(long)]
39 pub version: Option<i64>,
40 #[arg(
41 long,
42 help = "Namespace (flag / XDG namespace.default / global)"
43 )]
44 pub namespace: Option<String>,
46 #[arg(
48 long,
49 value_name = "EPOCH_OR_RFC3339",
50 value_parser = crate::parsers::parse_expected_updated_at,
51 long_help = "Optimistic lock: reject if updated_at does not match. \
52Accepts Unix epoch (e.g. 1700000000) or RFC 3339 (e.g. 2026-04-19T12:00:00Z)."
53 )]
54 pub expected_updated_at: Option<i64>,
55 #[arg(long, value_enum, default_value_t = JsonOutputFormat::Json)]
57 pub format: JsonOutputFormat,
58 #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
60 pub json: bool,
61 #[arg(long)]
63 pub db: Option<String>,
64}
65
66#[derive(Serialize)]
67struct RestoreResponse {
68 action: String,
70 memory_id: i64,
71 name: String,
72 version: i64,
73 restored_from: i64,
74 elapsed_ms: u64,
76}
77
78pub fn run(
80 args: RestoreArgs,
81 llm_backend: crate::cli::LlmBackendChoice,
82 embedding_backend: crate::cli::EmbeddingBackendChoice,
83) -> Result<(), AppError> {
84 let start = std::time::Instant::now();
85 let _ = args.format;
86 tracing::debug!(target: "restore", name = ?args.name_positional.as_deref().or(args.name.as_deref()), version = ?args.version, "restoring version");
87 let name = args
88 .name_positional
89 .as_deref()
90 .or(args.name.as_deref())
91 .ok_or_else(|| {
92 AppError::Validation(
93 "name required: pass as positional argument or via --name".to_string(),
94 )
95 })?
96 .to_string();
97 let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
98 let paths = AppPaths::resolve(args.db.as_deref())?;
99 let mut conn = open_rw(&paths.db)?;
100
101 let result: Option<(i64, i64)> = conn
103 .query_row(
104 "SELECT id, updated_at FROM memories WHERE namespace = ?1 AND name = ?2",
105 params![namespace, name],
106 |r| Ok((r.get(0)?, r.get(1)?)),
107 )
108 .optional()?;
109 let (memory_id, current_updated_at) = result
110 .ok_or_else(|| AppError::NotFound(errors_msg::memory_not_found(&name, &namespace)))?;
111
112 if let Some(expected) = args.expected_updated_at {
113 if expected != current_updated_at {
114 return Err(AppError::Conflict(errors_msg::optimistic_lock_conflict(
115 expected,
116 current_updated_at,
117 )));
118 }
119 }
120
121 let target_version: i64 = match args.version {
125 Some(v) => v,
126 None => {
127 let last: Option<i64> = conn
128 .query_row(
129 "SELECT MAX(version) FROM memory_versions
130 WHERE memory_id = ?1 AND change_reason != 'restore'",
131 params![memory_id],
132 |r| r.get(0),
133 )
134 .optional()?
135 .flatten();
136 let v = last.ok_or_else(|| {
137 AppError::NotFound(errors_msg::memory_not_found(&name, &namespace))
138 })?;
139 tracing::info!(target: "restore",
140 "restore --version omitted; using latest non-restore version: {}",
141 v
142 );
143 v
144 }
145 };
146
147 let version_row: (String, String, String, String, String) = {
148 let mut stmt = conn.prepare_cached(
149 "SELECT name, type, description, body, metadata
150 FROM memory_versions
151 WHERE memory_id = ?1 AND version = ?2",
152 )?;
153
154 stmt.query_row(params![memory_id, target_version], |r| {
155 Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?))
156 })
157 .map_err(|_| AppError::NotFound(errors_msg::version_not_found(target_version, &name)))?
158 };
159
160 let (_old_name, old_type, old_description, old_body, old_metadata) = version_row;
161
162 let (cur_name, cur_desc, cur_body): (String, String, String) = conn.query_row(
165 "SELECT name, description, body FROM memories WHERE id = ?1",
166 params![memory_id],
167 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
168 )?;
169
170 output::emit_progress_i18n(
174 "Re-computing embedding for restored memory...",
175 crate::i18n::validation::runtime_pt::restore_recomputing_embedding(),
176 );
177 let skip_embed = crate::embedder::should_skip_embedding_on_failure();
178 let embedding: Option<Vec<f32>> = match crate::embedder::embed_passage_with_embedding_choice(
179 &paths.models,
180 &old_body,
181 embedding_backend,
182 llm_backend,
183 ) {
184 Ok((emb, _backend)) => Some(emb),
185 Err(
188 e @ (AppError::Validation(_)
189 | AppError::BodyTooLarge { .. }
190 | AppError::TooManyTokens { .. }),
191 ) => return Err(e),
192 Err(e) if skip_embed => {
193 tracing::warn!(error = %e, "restore: embedding failed; --skip-embedding-on-failure active, persisting without embedding");
194 None
195 }
196 Err(e) => return Err(e),
197 };
198 let snippet: String = old_body.chars().take(300).collect();
199
200 let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
201
202 let affected = if let Some(ts) = args.expected_updated_at {
204 tx.execute(
205 "UPDATE memories SET type=?2, description=?3, body=?4, body_hash=?5, deleted_at=NULL
206 WHERE id=?1 AND updated_at=?6",
207 rusqlite::params![
208 memory_id,
209 old_type,
210 old_description,
211 old_body,
212 blake3::hash(old_body.as_bytes()).to_hex().to_string(),
213 ts
214 ],
215 )?
216 } else {
217 tx.execute(
218 "UPDATE memories SET type=?2, description=?3, body=?4, body_hash=?5, deleted_at=NULL
219 WHERE id=?1",
220 rusqlite::params![
221 memory_id,
222 old_type,
223 old_description,
224 old_body,
225 blake3::hash(old_body.as_bytes()).to_hex().to_string()
226 ],
227 )?
228 };
229
230 if affected == 0 {
231 return Err(AppError::Conflict(errors_msg::concurrent_process_conflict()));
232 }
233
234 let next_v = versions::next_version(&tx, memory_id)?;
235
236 versions::insert_version(
237 &tx,
238 memory_id,
239 next_v,
240 &cur_name,
241 &old_type,
242 &old_description,
243 &old_body,
244 &old_metadata,
245 None,
246 "restore",
247 )?;
248
249 if let Some(ref emb) = embedding {
250 memories::upsert_vec(
251 &tx, memory_id, &namespace, &old_type, emb, &cur_name, &snippet,
252 )?;
253 }
254
255 memories::sync_fts_after_update(
256 &tx,
257 memory_id,
258 &cur_name,
259 &cur_desc,
260 &cur_body,
261 &cur_name,
262 &old_description,
263 &old_body,
264 )?;
265
266 tx.commit()?;
267
268 conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
269
270 output::emit_json(&RestoreResponse {
271 action: "restored".to_string(),
272 memory_id,
273 name: cur_name.clone(),
274 version: next_v,
275 restored_from: target_version,
276 elapsed_ms: start.elapsed().as_millis() as u64,
277 })?;
278
279 Ok(())
280}
281
282#[cfg(test)]
283mod tests {
284 use crate::errors::AppError;
285
286 #[test]
287 fn optimistic_lock_conflict_returns_exit_3() {
288 let err = AppError::Conflict(
289 "optimistic lock conflict: expected updated_at=50, but current is 99".to_string(),
290 );
291 assert_eq!(err.exit_code(), 3);
292 assert!(err.to_string().contains("conflict"));
293 }
294
295 #[test]
296 fn restore_response_includes_action_field() {
297 let resp = super::RestoreResponse {
298 action: "restored".to_string(),
299 memory_id: 1,
300 name: "test-mem".to_string(),
301 version: 3,
302 restored_from: 2,
303 elapsed_ms: 42,
304 };
305 let json = serde_json::to_value(&resp).expect("serialization failed");
306 assert_eq!(json["action"], "restored");
307 assert_eq!(json["memory_id"], 1);
308 assert_eq!(json["version"], 3);
309 assert_eq!(json["restored_from"], 2);
310 }
311}