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 {
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(args: RestoreArgs) -> Result<(), AppError> {
75 let start = std::time::Instant::now();
76 let _ = args.format;
77 let name = args
78 .name_positional
79 .as_deref()
80 .or(args.name.as_deref())
81 .ok_or_else(|| {
82 AppError::Validation(
83 "name required: pass as positional argument or via --name".to_string(),
84 )
85 })?
86 .to_string();
87 let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
88 let paths = AppPaths::resolve(args.db.as_deref())?;
89 let mut conn = open_rw(&paths.db)?;
90
91 let result: Option<(i64, i64)> = conn
93 .query_row(
94 "SELECT id, updated_at FROM memories WHERE namespace = ?1 AND name = ?2",
95 params![namespace, name],
96 |r| Ok((r.get(0)?, r.get(1)?)),
97 )
98 .optional()?;
99 let (memory_id, current_updated_at) = result
100 .ok_or_else(|| AppError::NotFound(errors_msg::memory_not_found(&name, &namespace)))?;
101
102 if let Some(expected) = args.expected_updated_at {
103 if expected != current_updated_at {
104 return Err(AppError::Conflict(errors_msg::optimistic_lock_conflict(
105 expected,
106 current_updated_at,
107 )));
108 }
109 }
110
111 let target_version: i64 = match args.version {
115 Some(v) => v,
116 None => {
117 let last: Option<i64> = conn
118 .query_row(
119 "SELECT MAX(version) FROM memory_versions
120 WHERE memory_id = ?1 AND change_reason != 'restore'",
121 params![memory_id],
122 |r| r.get(0),
123 )
124 .optional()?
125 .flatten();
126 let v = last.ok_or_else(|| {
127 AppError::NotFound(errors_msg::memory_not_found(&name, &namespace))
128 })?;
129 tracing::info!(
130 "restore --version omitted; using latest non-restore version: {}",
131 v
132 );
133 v
134 }
135 };
136
137 let version_row: (String, String, String, String, String) = {
138 let mut stmt = conn.prepare(
139 "SELECT name, type, description, body, metadata
140 FROM memory_versions
141 WHERE memory_id = ?1 AND version = ?2",
142 )?;
143
144 stmt.query_row(params![memory_id, target_version], |r| {
145 Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?))
146 })
147 .map_err(|_| AppError::NotFound(errors_msg::version_not_found(target_version, &name)))?
148 };
149
150 let (old_name, old_type, old_description, old_body, old_metadata) = version_row;
151
152 output::emit_progress_i18n(
156 "Re-computing embedding for restored memory...",
157 crate::i18n::validation::runtime_pt::restore_recomputing_embedding(),
158 );
159 let embedding = crate::daemon::embed_passage_or_local(&paths.models, &old_body)?;
160 let snippet: String = old_body.chars().take(300).collect();
161
162 let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
163
164 let affected = if let Some(ts) = args.expected_updated_at {
166 tx.execute(
167 "UPDATE memories SET name=?2, type=?3, description=?4, body=?5, body_hash=?6, deleted_at=NULL
168 WHERE id=?1 AND updated_at=?7",
169 rusqlite::params![
170 memory_id,
171 old_name,
172 old_type,
173 old_description,
174 old_body,
175 blake3::hash(old_body.as_bytes()).to_hex().to_string(),
176 ts
177 ],
178 )?
179 } else {
180 tx.execute(
181 "UPDATE memories SET name=?2, type=?3, description=?4, body=?5, body_hash=?6, deleted_at=NULL
182 WHERE id=?1",
183 rusqlite::params![
184 memory_id,
185 old_name,
186 old_type,
187 old_description,
188 old_body,
189 blake3::hash(old_body.as_bytes()).to_hex().to_string()
190 ],
191 )?
192 };
193
194 if affected == 0 {
195 return Err(AppError::Conflict(errors_msg::concurrent_process_conflict()));
196 }
197
198 let next_v = versions::next_version(&tx, memory_id)?;
199
200 versions::insert_version(
201 &tx,
202 memory_id,
203 next_v,
204 &old_name,
205 &old_type,
206 &old_description,
207 &old_body,
208 &old_metadata,
209 None,
210 "restore",
211 )?;
212
213 memories::upsert_vec(
215 &tx, memory_id, &namespace, &old_type, &embedding, &old_name, &snippet,
216 )?;
217
218 tx.commit()?;
219
220 output::emit_json(&RestoreResponse {
221 action: "restored".to_string(),
222 memory_id,
223 name: old_name,
224 version: next_v,
225 restored_from: target_version,
226 elapsed_ms: start.elapsed().as_millis() as u64,
227 })?;
228
229 Ok(())
230}
231
232#[cfg(test)]
233mod tests {
234 use crate::errors::AppError;
235
236 #[test]
237 fn optimistic_lock_conflict_returns_exit_3() {
238 let err = AppError::Conflict(
239 "optimistic lock conflict: expected updated_at=50, but current is 99".to_string(),
240 );
241 assert_eq!(err.exit_code(), 3);
242 assert!(err.to_string().contains("conflict"));
243 }
244
245 #[test]
246 fn restore_response_includes_action_field() {
247 let resp = super::RestoreResponse {
248 action: "restored".to_string(),
249 memory_id: 1,
250 name: "test-mem".to_string(),
251 version: 3,
252 restored_from: 2,
253 elapsed_ms: 42,
254 };
255 let json = serde_json::to_value(&resp).expect("serialization failed");
256 assert_eq!(json["action"], "restored");
257 assert_eq!(json["memory_id"], 1);
258 assert_eq!(json["version"], 3);
259 assert_eq!(json["restored_from"], 2);
260 }
261}