sqlite_graphrag/commands/
edit.rs1use crate::errors::AppError;
4use crate::i18n::errors_msg;
5use crate::output;
6use crate::paths::AppPaths;
7use crate::storage::connection::open_rw;
8use crate::storage::{memories, versions};
9use serde::Serialize;
10
11#[derive(clap::Args)]
12#[command(after_long_help = "EXAMPLES:\n \
13 # Edit body inline\n \
14 sqlite-graphrag edit onboarding --body \"updated content\"\n\n \
15 # Edit body from a file\n \
16 sqlite-graphrag edit onboarding --body-file ./updated.md\n\n \
17 # Edit body from stdin (pipe)\n \
18 cat updated.md | sqlite-graphrag edit onboarding --body-stdin\n\n \
19 # Update only the description\n \
20 sqlite-graphrag edit onboarding --description \"new short description\"")]
21pub struct EditArgs {
22 #[arg(
24 value_name = "NAME",
25 conflicts_with = "name",
26 help = "Memory name to edit; alternative to --name"
27 )]
28 pub name_positional: Option<String>,
29 #[arg(long)]
31 pub name: Option<String>,
32 #[arg(long, conflicts_with_all = ["body_file", "body_stdin"])]
34 pub body: Option<String>,
35 #[arg(long, conflicts_with_all = ["body", "body_stdin"])]
37 pub body_file: Option<std::path::PathBuf>,
38 #[arg(long, conflicts_with_all = ["body", "body_file"])]
40 pub body_stdin: bool,
41 #[arg(long)]
43 pub description: Option<String>,
44 #[arg(long, value_enum, help = "Change memory type")]
46 pub memory_type: Option<crate::cli::MemoryType>,
47 #[arg(
48 long,
49 value_name = "EPOCH_OR_RFC3339",
50 value_parser = crate::parsers::parse_expected_updated_at,
51 long_help = "Optimistic lock: reject if updated_at does not match. \
52Accepts Unix epoch (e.g. 1700000000) or RFC 3339 (e.g. 2026-04-19T12:00:00Z)."
53 )]
54 pub expected_updated_at: Option<i64>,
55 #[arg(
56 long,
57 help = "Namespace (env: SQLITE_GRAPHRAG_NAMESPACE, default: global)"
58 )]
59 pub namespace: Option<String>,
60 #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
61 pub json: bool,
62 #[arg(long, env = "SQLITE_GRAPHRAG_DB_PATH")]
63 pub db: Option<String>,
64}
65
66#[derive(Serialize)]
67struct EditResponse {
68 memory_id: i64,
69 name: String,
70 action: String,
71 version: i64,
72 elapsed_ms: u64,
74}
75
76pub fn run(args: EditArgs) -> Result<(), AppError> {
77 use crate::constants::*;
78
79 let inicio = std::time::Instant::now();
80 let name = args.name_positional.or(args.name).ok_or_else(|| {
82 AppError::Validation("name required: pass as positional argument or via --name".to_string())
83 })?;
84 let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
85
86 let paths = AppPaths::resolve(args.db.as_deref())?;
87 crate::storage::connection::ensure_db_ready(&paths)?;
88 let mut conn = open_rw(&paths.db)?;
89
90 let (memory_id, current_updated_at, _current_version) =
91 memories::find_by_name(&conn, &namespace, &name)?
92 .ok_or_else(|| AppError::NotFound(errors_msg::memory_not_found(&name, &namespace)))?;
93
94 if let Some(expected) = args.expected_updated_at {
95 if expected != current_updated_at {
96 return Err(AppError::Conflict(errors_msg::optimistic_lock_conflict(
97 expected,
98 current_updated_at,
99 )));
100 }
101 }
102
103 let mut raw_body: Option<String> = None;
104 if args.body.is_some() || args.body_file.is_some() || args.body_stdin {
105 let b = if let Some(b) = args.body {
106 b
107 } else if let Some(path) = &args.body_file {
108 std::fs::read_to_string(path).map_err(AppError::Io)?
109 } else {
110 crate::stdin_helper::read_stdin_with_timeout(60)?
111 };
112 if b.len() > MAX_MEMORY_BODY_LEN {
113 return Err(AppError::LimitExceeded(
114 crate::i18n::validation::body_exceeds(MAX_MEMORY_BODY_LEN),
115 ));
116 }
117 raw_body = Some(b);
118 }
119
120 if let Some(ref desc) = args.description {
121 if desc.len() > MAX_MEMORY_DESCRIPTION_LEN {
122 return Err(AppError::Validation(
123 crate::i18n::validation::description_exceeds(MAX_MEMORY_DESCRIPTION_LEN),
124 ));
125 }
126 }
127
128 let row = memories::read_by_name(&conn, &namespace, &name)?
129 .ok_or_else(|| AppError::Internal(anyhow::anyhow!("memory row not found after check")))?;
130
131 let body_changed = raw_body.is_some();
132 let new_body = raw_body.unwrap_or(row.body.clone());
133 let new_description = args.description.unwrap_or(row.description.clone());
134 let new_hash = blake3::hash(new_body.as_bytes()).to_hex().to_string();
135 let memory_type = args
136 .memory_type
137 .map(|t| t.as_str().to_string())
138 .unwrap_or_else(|| row.memory_type.clone());
139 let type_changed = memory_type != row.memory_type;
140 let metadata = row.metadata.clone();
141
142 let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
143
144 let affected = if let Some(ts) = args.expected_updated_at {
145 tx.execute(
146 "UPDATE memories SET description=?2, body=?3, body_hash=?4, type=?5
147 WHERE id=?1 AND updated_at=?6 AND deleted_at IS NULL",
148 rusqlite::params![
149 memory_id,
150 new_description,
151 new_body,
152 new_hash,
153 memory_type,
154 ts
155 ],
156 )?
157 } else {
158 tx.execute(
159 "UPDATE memories SET description=?2, body=?3, body_hash=?4, type=?5
160 WHERE id=?1 AND deleted_at IS NULL",
161 rusqlite::params![memory_id, new_description, new_body, new_hash, memory_type],
162 )?
163 };
164
165 if affected == 0 {
166 return Err(AppError::Conflict(
167 "optimistic lock conflict: memory was modified by another process".to_string(),
168 ));
169 }
170
171 if body_changed || type_changed {
172 output::emit_progress_i18n(
173 "Re-computing embedding for edited body...",
174 crate::i18n::validation::runtime_pt::edit_recomputing_embedding(),
175 );
176 let embedding = crate::daemon::embed_passage_or_local(&paths.models, &new_body)?;
177 let snippet: String = new_body.chars().take(300).collect();
178 memories::upsert_vec(
179 &tx,
180 memory_id,
181 &namespace,
182 &memory_type,
183 &embedding,
184 &name,
185 &snippet,
186 )?;
187 }
188
189 let next_v = versions::next_version(&tx, memory_id)?;
190
191 versions::insert_version(
192 &tx,
193 memory_id,
194 next_v,
195 &name,
196 &memory_type,
197 &new_description,
198 &new_body,
199 &metadata,
200 None,
201 "edit",
202 )?;
203
204 memories::sync_fts_after_update(
205 &tx,
206 memory_id,
207 &row.name,
208 &row.description,
209 &row.body,
210 &row.name,
211 &new_description,
212 &new_body,
213 )?;
214
215 tx.commit()?;
216
217 conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
218
219 output::emit_json(&EditResponse {
220 memory_id,
221 name,
222 action: "updated".to_string(),
223 version: next_v,
224 elapsed_ms: inicio.elapsed().as_millis() as u64,
225 })?;
226
227 Ok(())
228}
229
230#[cfg(test)]
231mod tests {
232 use super::*;
233
234 #[test]
235 fn edit_response_serializes_all_fields() {
236 let resp = EditResponse {
237 memory_id: 42,
238 name: "my-memory".to_string(),
239 action: "updated".to_string(),
240 version: 3,
241 elapsed_ms: 7,
242 };
243 let json = serde_json::to_value(&resp).expect("serialization failed");
244 assert_eq!(json["memory_id"], 42i64);
245 assert_eq!(json["name"], "my-memory");
246 assert_eq!(json["action"], "updated");
247 assert_eq!(json["version"], 3i64);
248 assert!(json["elapsed_ms"].is_number());
249 }
250
251 #[test]
252 fn edit_response_action_contains_updated() {
253 let resp = EditResponse {
254 memory_id: 1,
255 name: "n".to_string(),
256 action: "updated".to_string(),
257 version: 1,
258 elapsed_ms: 0,
259 };
260 assert_eq!(
261 resp.action, "updated",
262 "action must be 'updated' for successful edits"
263 );
264 }
265
266 #[test]
267 fn edit_body_exceeds_limit_returns_error() {
268 let limit = crate::constants::MAX_MEMORY_BODY_LEN;
269 let large_body: String = "a".repeat(limit + 1);
270 assert!(
271 large_body.len() > limit,
272 "body above limit must have length > MAX_MEMORY_BODY_LEN"
273 );
274 }
275
276 #[test]
277 fn edit_description_exceeds_limit_returns_error() {
278 let limit = crate::constants::MAX_MEMORY_DESCRIPTION_LEN;
279 let large_desc: String = "d".repeat(limit + 1);
280 assert!(
281 large_desc.len() > limit,
282 "description above limit must have length > MAX_MEMORY_DESCRIPTION_LEN"
283 );
284 }
285}