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