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(long, default_value = "global")]
40 pub namespace: Option<String>,
41 #[arg(
43 long,
44 value_name = "EPOCH_OR_RFC3339",
45 value_parser = crate::parsers::parse_expected_updated_at,
46 long_help = "Optimistic lock: reject if updated_at does not match. \
47Accepts Unix epoch (e.g. 1700000000) or RFC 3339 (e.g. 2026-04-19T12:00:00Z)."
48 )]
49 pub expected_updated_at: Option<i64>,
50 #[arg(long, value_enum, default_value_t = JsonOutputFormat::Json)]
52 pub format: JsonOutputFormat,
53 #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
54 pub json: bool,
55 #[arg(long, env = "SQLITE_GRAPHRAG_DB_PATH")]
56 pub db: Option<String>,
57}
58
59#[derive(Serialize)]
60struct RestoreResponse {
61 memory_id: i64,
62 name: String,
63 version: i64,
64 restored_from: i64,
65 elapsed_ms: u64,
67}
68
69pub fn run(args: RestoreArgs) -> Result<(), AppError> {
70 let start = std::time::Instant::now();
71 let _ = args.format;
72 let name = args
73 .name_positional
74 .as_deref()
75 .or(args.name.as_deref())
76 .ok_or_else(|| {
77 AppError::Validation(
78 "name required: pass as positional argument or via --name".to_string(),
79 )
80 })?
81 .to_string();
82 let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
83 let paths = AppPaths::resolve(args.db.as_deref())?;
84 let mut conn = open_rw(&paths.db)?;
85
86 let result: Option<(i64, i64)> = conn
88 .query_row(
89 "SELECT id, updated_at FROM memories WHERE namespace = ?1 AND name = ?2",
90 params![namespace, name],
91 |r| Ok((r.get(0)?, r.get(1)?)),
92 )
93 .optional()?;
94 let (memory_id, current_updated_at) = result
95 .ok_or_else(|| AppError::NotFound(errors_msg::memory_not_found(&name, &namespace)))?;
96
97 if let Some(expected) = args.expected_updated_at {
98 if expected != current_updated_at {
99 return Err(AppError::Conflict(errors_msg::optimistic_lock_conflict(
100 expected,
101 current_updated_at,
102 )));
103 }
104 }
105
106 let target_version: i64 = match args.version {
110 Some(v) => v,
111 None => {
112 let last: Option<i64> = conn
113 .query_row(
114 "SELECT MAX(version) FROM memory_versions
115 WHERE memory_id = ?1 AND change_reason != 'restore'",
116 params![memory_id],
117 |r| r.get(0),
118 )
119 .optional()?
120 .flatten();
121 let v = last.ok_or_else(|| {
122 AppError::NotFound(errors_msg::memory_not_found(&name, &namespace))
123 })?;
124 tracing::info!(
125 "restore --version omitted; using latest non-restore version: {}",
126 v
127 );
128 v
129 }
130 };
131
132 let version_row: (String, String, String, String, String) = {
133 let mut stmt = conn.prepare(
134 "SELECT name, type, description, body, metadata
135 FROM memory_versions
136 WHERE memory_id = ?1 AND version = ?2",
137 )?;
138
139 stmt.query_row(params![memory_id, target_version], |r| {
140 Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?))
141 })
142 .map_err(|_| AppError::NotFound(errors_msg::version_not_found(target_version, &name)))?
143 };
144
145 let (old_name, old_type, old_description, old_body, old_metadata) = version_row;
146
147 output::emit_progress_i18n(
151 "Re-computing embedding for restored memory...",
152 crate::i18n::validation::runtime_pt::restore_recomputing_embedding(),
153 );
154 let embedding = crate::daemon::embed_passage_or_local(&paths.models, &old_body)?;
155 let snippet: String = old_body.chars().take(300).collect();
156
157 let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
158
159 let affected = if let Some(ts) = args.expected_updated_at {
161 tx.execute(
162 "UPDATE memories SET name=?2, type=?3, description=?4, body=?5, body_hash=?6, deleted_at=NULL
163 WHERE id=?1 AND updated_at=?7",
164 rusqlite::params![
165 memory_id,
166 old_name,
167 old_type,
168 old_description,
169 old_body,
170 blake3::hash(old_body.as_bytes()).to_hex().to_string(),
171 ts
172 ],
173 )?
174 } else {
175 tx.execute(
176 "UPDATE memories SET name=?2, type=?3, description=?4, body=?5, body_hash=?6, deleted_at=NULL
177 WHERE id=?1",
178 rusqlite::params![
179 memory_id,
180 old_name,
181 old_type,
182 old_description,
183 old_body,
184 blake3::hash(old_body.as_bytes()).to_hex().to_string()
185 ],
186 )?
187 };
188
189 if affected == 0 {
190 return Err(AppError::Conflict(errors_msg::concurrent_process_conflict()));
191 }
192
193 let next_v = versions::next_version(&tx, memory_id)?;
194
195 versions::insert_version(
196 &tx,
197 memory_id,
198 next_v,
199 &old_name,
200 &old_type,
201 &old_description,
202 &old_body,
203 &old_metadata,
204 None,
205 "restore",
206 )?;
207
208 memories::upsert_vec(
210 &tx, memory_id, &namespace, &old_type, &embedding, &old_name, &snippet,
211 )?;
212
213 tx.commit()?;
214
215 output::emit_json(&RestoreResponse {
216 memory_id,
217 name: old_name,
218 version: next_v,
219 restored_from: target_version,
220 elapsed_ms: start.elapsed().as_millis() as u64,
221 })?;
222
223 Ok(())
224}
225
226#[cfg(test)]
227mod tests {
228 use crate::errors::AppError;
229
230 #[test]
231 fn optimistic_lock_conflict_returns_exit_3() {
232 let err = AppError::Conflict(
233 "optimistic lock conflict: expected updated_at=50, but current is 99".to_string(),
234 );
235 assert_eq!(err.exit_code(), 3);
236 assert!(err.to_string().contains("conflict"));
237 }
238}