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