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(
45 long,
46 value_name = "EPOCH_OR_RFC3339",
47 value_parser = crate::parsers::parse_expected_updated_at,
48 long_help = "Optimistic lock: reject if updated_at does not match. \
49Accepts Unix epoch (e.g. 1700000000) or RFC 3339 (e.g. 2026-04-19T12:00:00Z)."
50 )]
51 pub expected_updated_at: Option<i64>,
52 #[arg(
53 long,
54 help = "Namespace (env: SQLITE_GRAPHRAG_NAMESPACE, default: global)"
55 )]
56 pub namespace: Option<String>,
57 #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
58 pub json: bool,
59 #[arg(long, env = "SQLITE_GRAPHRAG_DB_PATH")]
60 pub db: Option<String>,
61}
62
63#[derive(Serialize)]
64struct EditResponse {
65 memory_id: i64,
66 name: String,
67 action: String,
68 version: i64,
69 elapsed_ms: u64,
71}
72
73pub fn run(args: EditArgs) -> Result<(), AppError> {
74 use crate::constants::*;
75
76 let inicio = std::time::Instant::now();
77 let name = args.name_positional.or(args.name).ok_or_else(|| {
79 AppError::Validation("name required: pass as positional argument or via --name".to_string())
80 })?;
81 let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
82
83 let paths = AppPaths::resolve(args.db.as_deref())?;
84 crate::storage::connection::ensure_db_ready(&paths)?;
85 let mut conn = open_rw(&paths.db)?;
86
87 let (memory_id, current_updated_at, _current_version) =
88 memories::find_by_name(&conn, &namespace, &name)?
89 .ok_or_else(|| AppError::NotFound(errors_msg::memory_not_found(&name, &namespace)))?;
90
91 if let Some(expected) = args.expected_updated_at {
92 if expected != current_updated_at {
93 return Err(AppError::Conflict(errors_msg::optimistic_lock_conflict(
94 expected,
95 current_updated_at,
96 )));
97 }
98 }
99
100 let mut raw_body: Option<String> = None;
101 if args.body.is_some() || args.body_file.is_some() || args.body_stdin {
102 let b = if let Some(b) = args.body {
103 b
104 } else if let Some(path) = &args.body_file {
105 std::fs::read_to_string(path).map_err(AppError::Io)?
106 } else {
107 crate::stdin_helper::read_stdin_with_timeout(60)?
108 };
109 if b.len() > MAX_MEMORY_BODY_LEN {
110 return Err(AppError::LimitExceeded(
111 crate::i18n::validation::body_exceeds(MAX_MEMORY_BODY_LEN),
112 ));
113 }
114 raw_body = Some(b);
115 }
116
117 if let Some(ref desc) = args.description {
118 if desc.len() > MAX_MEMORY_DESCRIPTION_LEN {
119 return Err(AppError::Validation(
120 crate::i18n::validation::description_exceeds(MAX_MEMORY_DESCRIPTION_LEN),
121 ));
122 }
123 }
124
125 let row = memories::read_by_name(&conn, &namespace, &name)?
126 .ok_or_else(|| AppError::Internal(anyhow::anyhow!("memory row not found after check")))?;
127
128 let new_body = raw_body.unwrap_or(row.body.clone());
129 let new_description = args.description.unwrap_or(row.description.clone());
130 let new_hash = blake3::hash(new_body.as_bytes()).to_hex().to_string();
131 let memory_type = row.memory_type.clone();
132 let metadata = row.metadata.clone();
133
134 let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
135
136 let affected = if let Some(ts) = args.expected_updated_at {
137 tx.execute(
138 "UPDATE memories SET description=?2, body=?3, body_hash=?4
139 WHERE id=?1 AND updated_at=?5 AND deleted_at IS NULL",
140 rusqlite::params![memory_id, new_description, new_body, new_hash, ts],
141 )?
142 } else {
143 tx.execute(
144 "UPDATE memories SET description=?2, body=?3, body_hash=?4
145 WHERE id=?1 AND deleted_at IS NULL",
146 rusqlite::params![memory_id, new_description, new_body, new_hash],
147 )?
148 };
149
150 if affected == 0 {
151 return Err(AppError::Conflict(
152 "optimistic lock conflict: memory was modified by another process".to_string(),
153 ));
154 }
155
156 let next_v = versions::next_version(&tx, memory_id)?;
157
158 versions::insert_version(
159 &tx,
160 memory_id,
161 next_v,
162 &name,
163 &memory_type,
164 &new_description,
165 &new_body,
166 &metadata,
167 None,
168 "edit",
169 )?;
170
171 memories::sync_fts_after_update(
172 &tx,
173 memory_id,
174 &row.name,
175 &row.description,
176 &row.body,
177 &row.name,
178 &new_description,
179 &new_body,
180 )?;
181
182 tx.commit()?;
183
184 conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
185
186 output::emit_json(&EditResponse {
187 memory_id,
188 name,
189 action: "updated".to_string(),
190 version: next_v,
191 elapsed_ms: inicio.elapsed().as_millis() as u64,
192 })?;
193
194 Ok(())
195}
196
197#[cfg(test)]
198mod tests {
199 use super::*;
200
201 #[test]
202 fn edit_response_serializes_all_fields() {
203 let resp = EditResponse {
204 memory_id: 42,
205 name: "my-memory".to_string(),
206 action: "updated".to_string(),
207 version: 3,
208 elapsed_ms: 7,
209 };
210 let json = serde_json::to_value(&resp).expect("serialization failed");
211 assert_eq!(json["memory_id"], 42i64);
212 assert_eq!(json["name"], "my-memory");
213 assert_eq!(json["action"], "updated");
214 assert_eq!(json["version"], 3i64);
215 assert!(json["elapsed_ms"].is_number());
216 }
217
218 #[test]
219 fn edit_response_action_contains_updated() {
220 let resp = EditResponse {
221 memory_id: 1,
222 name: "n".to_string(),
223 action: "updated".to_string(),
224 version: 1,
225 elapsed_ms: 0,
226 };
227 assert_eq!(
228 resp.action, "updated",
229 "action must be 'updated' for successful edits"
230 );
231 }
232
233 #[test]
234 fn edit_body_exceeds_limit_returns_error() {
235 let limit = crate::constants::MAX_MEMORY_BODY_LEN;
236 let large_body: String = "a".repeat(limit + 1);
237 assert!(
238 large_body.len() > limit,
239 "body above limit must have length > MAX_MEMORY_BODY_LEN"
240 );
241 }
242
243 #[test]
244 fn edit_description_exceeds_limit_returns_error() {
245 let limit = crate::constants::MAX_MEMORY_DESCRIPTION_LEN;
246 let large_desc: String = "d".repeat(limit + 1);
247 assert!(
248 large_desc.len() > limit,
249 "description above limit must have length > MAX_MEMORY_DESCRIPTION_LEN"
250 );
251 }
252}