sqlite_graphrag/commands/
restore.rs1use crate::errors::AppError;
2use crate::i18n::erros;
3use crate::output;
4use crate::output::JsonOutputFormat;
5use crate::paths::AppPaths;
6use crate::storage::connection::open_rw;
7use crate::storage::memories;
8use crate::storage::versions;
9use rusqlite::params;
10use rusqlite::OptionalExtension;
11use serde::Serialize;
12
13#[derive(clap::Args)]
14pub struct RestoreArgs {
15 #[arg(long)]
16 pub name: String,
17 #[arg(long)]
18 pub version: i64,
19 #[arg(long, default_value = "global")]
20 pub namespace: Option<String>,
21 #[arg(
23 long,
24 value_name = "EPOCH_OR_RFC3339",
25 value_parser = crate::parsers::parse_expected_updated_at,
26 long_help = "Optimistic lock: reject if updated_at does not match. \
27Accepts Unix epoch (e.g. 1700000000) or RFC 3339 (e.g. 2026-04-19T12:00:00Z)."
28 )]
29 pub expected_updated_at: Option<i64>,
30 #[arg(long, value_enum, default_value_t = JsonOutputFormat::Json)]
32 pub format: JsonOutputFormat,
33 #[arg(long, help = "No-op; JSON is always emitted on stdout")]
34 pub json: bool,
35 #[arg(long, env = "SQLITE_GRAPHRAG_DB_PATH")]
36 pub db: Option<String>,
37}
38
39#[derive(Serialize)]
40struct RestoreResponse {
41 memory_id: i64,
42 name: String,
43 version: i64,
44 restored_from: i64,
45 elapsed_ms: u64,
47}
48
49pub fn run(args: RestoreArgs) -> Result<(), AppError> {
50 let inicio = std::time::Instant::now();
51 let _ = args.format;
52 let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
53 let paths = AppPaths::resolve(args.db.as_deref())?;
54 let mut conn = open_rw(&paths.db)?;
55
56 let result: Option<(i64, i64)> = conn
58 .query_row(
59 "SELECT id, updated_at FROM memories WHERE namespace = ?1 AND name = ?2",
60 params![namespace, args.name],
61 |r| Ok((r.get(0)?, r.get(1)?)),
62 )
63 .optional()?;
64 let (memory_id, current_updated_at) = result
65 .ok_or_else(|| AppError::NotFound(erros::memoria_nao_encontrada(&args.name, &namespace)))?;
66
67 if let Some(expected) = args.expected_updated_at {
68 if expected != current_updated_at {
69 return Err(AppError::Conflict(erros::conflito_optimistic_lock(
70 expected,
71 current_updated_at,
72 )));
73 }
74 }
75
76 let version_row: (String, String, String, String, String) = {
77 let mut stmt = conn.prepare(
78 "SELECT name, type, description, body, metadata
79 FROM memory_versions
80 WHERE memory_id = ?1 AND version = ?2",
81 )?;
82
83 stmt.query_row(params![memory_id, args.version], |r| {
84 Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?))
85 })
86 .map_err(|_| AppError::NotFound(erros::versao_nao_encontrada(args.version, &args.name)))?
87 };
88
89 let (old_name, old_type, old_description, old_body, old_metadata) = version_row;
90
91 output::emit_progress_i18n(
95 "Re-computing embedding for restored memory...",
96 "Recalculando embedding da memória restaurada...",
97 );
98 let embedding = crate::daemon::embed_passage_or_local(&paths.models, &old_body)?;
99 let snippet: String = old_body.chars().take(300).collect();
100
101 let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
102
103 let affected = if let Some(ts) = args.expected_updated_at {
105 tx.execute(
106 "UPDATE memories SET name=?2, type=?3, description=?4, body=?5, body_hash=?6, deleted_at=NULL
107 WHERE id=?1 AND updated_at=?7",
108 rusqlite::params![
109 memory_id,
110 old_name,
111 old_type,
112 old_description,
113 old_body,
114 blake3::hash(old_body.as_bytes()).to_hex().to_string(),
115 ts
116 ],
117 )?
118 } else {
119 tx.execute(
120 "UPDATE memories SET name=?2, type=?3, description=?4, body=?5, body_hash=?6, deleted_at=NULL
121 WHERE id=?1",
122 rusqlite::params![
123 memory_id,
124 old_name,
125 old_type,
126 old_description,
127 old_body,
128 blake3::hash(old_body.as_bytes()).to_hex().to_string()
129 ],
130 )?
131 };
132
133 if affected == 0 {
134 return Err(AppError::Conflict(erros::conflito_processo_concorrente()));
135 }
136
137 let next_v = versions::next_version(&tx, memory_id)?;
138
139 versions::insert_version(
140 &tx,
141 memory_id,
142 next_v,
143 &old_name,
144 &old_type,
145 &old_description,
146 &old_body,
147 &old_metadata,
148 None,
149 "restore",
150 )?;
151
152 memories::upsert_vec(
154 &tx, memory_id, &namespace, &old_type, &embedding, &old_name, &snippet,
155 )?;
156
157 tx.commit()?;
158
159 output::emit_json(&RestoreResponse {
160 memory_id,
161 name: old_name,
162 version: next_v,
163 restored_from: args.version,
164 elapsed_ms: inicio.elapsed().as_millis() as u64,
165 })?;
166
167 Ok(())
168}
169
170#[cfg(test)]
171mod testes {
172 use crate::errors::AppError;
173
174 #[test]
175 fn optimistic_lock_conflict_retorna_exit_3() {
176 let err = AppError::Conflict(
177 "optimistic lock conflict: expected updated_at=50, but current is 99".to_string(),
178 );
179 assert_eq!(err.exit_code(), 3);
180 assert!(err.to_string().contains("conflict"));
181 }
182}