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