sqlite_graphrag/commands/
forget.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;
9use rusqlite::{params, OptionalExtension};
10use serde::Serialize;
11
12#[derive(clap::Args)]
13#[command(after_long_help = "EXAMPLES:\n \
14 # Soft-delete a memory by name (positional form)\n \
15 sqlite-graphrag forget onboarding\n\n \
16 # Soft-delete using the named flag form\n \
17 sqlite-graphrag forget --name onboarding\n\n \
18 # Soft-delete from a specific namespace\n \
19 sqlite-graphrag forget onboarding --namespace my-project")]
20pub struct ForgetArgs {
22 #[arg(
24 value_name = "NAME",
25 conflicts_with = "name",
26 help = "Memory name to soft-delete; alternative to --name"
27 )]
28 pub name_positional: Option<String>,
29 #[arg(long)]
32 pub name: Option<String>,
33 #[arg(
34 long,
35 help = "Namespace (flag / XDG namespace.default / global)"
36 )]
37 pub namespace: Option<String>,
39 #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
41 pub json: bool,
42 #[arg(long)]
44 pub db: Option<String>,
45}
46
47#[derive(Serialize)]
48struct ForgetResponse {
49 action: String,
51 forgotten: bool,
53 name: String,
54 namespace: String,
55 #[serde(skip_serializing_if = "Option::is_none")]
57 deleted_at: Option<i64>,
58 #[serde(skip_serializing_if = "Option::is_none")]
60 deleted_at_iso: Option<String>,
61 elapsed_ms: u64,
63}
64
65pub fn run(args: ForgetArgs) -> Result<(), AppError> {
67 let start = std::time::Instant::now();
68 tracing::debug!(target: "forget", name = ?args.name_positional.as_deref().or(args.name.as_deref()), "soft-deleting memory");
69 let name = args.name_positional.or(args.name).ok_or_else(|| {
71 AppError::Validation(crate::i18n::validation::name_required_positional_or_flag())
72 })?;
73 let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
74 let paths = AppPaths::resolve(args.db.as_deref())?;
75 crate::storage::connection::ensure_db_ready(&paths)?;
76
77 let conn = open_rw(&paths.db)?;
78
79 let probe: Option<(i64, Option<i64>)> = conn
83 .query_row(
84 "SELECT id, deleted_at FROM memories WHERE namespace = ?1 AND name = ?2",
85 params![namespace, name],
86 |r| Ok((r.get::<_, i64>(0)?, r.get::<_, Option<i64>>(1)?)),
87 )
88 .optional()?;
89
90 let (action, forgotten, deleted_at, memory_id) = match probe {
91 None => ("not_found", false, None, None),
92 Some((id, Some(existing))) => ("already_deleted", false, Some(existing), Some(id)),
93 Some((id, None)) => {
94 if let Err(e) = memories::delete_vec(&conn, id) {
100 tracing::warn!(
101 target: "forget",
102 memory_id = id,
103 error = %e,
104 "vec cleanup before soft-delete failed — orphan vector may be left",
105 );
106 }
107 let ok = memories::soft_delete(&conn, &namespace, &name)?;
108 if !ok {
109 let current: Option<i64> = conn
112 .query_row(
113 "SELECT deleted_at FROM memories WHERE id = ?1",
114 params![id],
115 |r| r.get::<_, Option<i64>>(0),
116 )
117 .optional()?
118 .flatten();
119 ("already_deleted", false, current, Some(id))
120 } else {
121 let ts: Option<i64> = conn
122 .query_row(
123 "SELECT deleted_at FROM memories WHERE id = ?1",
124 params![id],
125 |r| r.get::<_, Option<i64>>(0),
126 )
127 .optional()?
128 .flatten();
129 ("soft_deleted", true, ts, Some(id))
130 }
131 }
132 };
133
134 if forgotten {
142 if let Some(id) = memory_id {
143 crate::commands::enrich::cleanup_queue_entry(&paths.db, id, &name);
144 }
145 }
146
147 conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
148
149 if action == "not_found" {
150 return Err(AppError::NotFound(errors_msg::memory_not_found(
151 &name, &namespace,
152 )));
153 }
154
155 let deleted_at_iso = deleted_at.map(crate::tz::epoch_to_iso);
156 let response = ForgetResponse {
157 action: action.to_string(),
158 forgotten,
159 name: name.clone(),
160 namespace: namespace.clone(),
161 deleted_at,
162 deleted_at_iso,
163 elapsed_ms: start.elapsed().as_millis() as u64,
164 };
165 output::emit_json(&response)?;
166
167 Ok(())
168}
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173
174 #[test]
175 fn forget_response_serializes_basic_fields() {
176 let resp = ForgetResponse {
177 action: "soft_deleted".to_string(),
178 forgotten: true,
179 name: "my-memory".to_string(),
180 namespace: "global".to_string(),
181 deleted_at: Some(1_700_000_000),
182 deleted_at_iso: Some("2023-11-14T22:13:20+00:00".to_string()),
183 elapsed_ms: 5,
184 };
185 let json = serde_json::to_value(&resp).expect("serialization failed");
186 assert_eq!(json["action"], "soft_deleted");
187 assert_eq!(json["forgotten"], true);
188 assert_eq!(json["name"], "my-memory");
189 assert_eq!(json["namespace"], "global");
190 assert_eq!(json["deleted_at"], 1_700_000_000i64);
191 assert!(json["deleted_at_iso"].is_string());
192 assert!(json["elapsed_ms"].is_number());
193 }
194
195 #[test]
196 fn forget_response_action_soft_deleted_implies_forgotten_true() {
197 let resp = ForgetResponse {
198 action: "soft_deleted".to_string(),
199 forgotten: true,
200 name: "test".to_string(),
201 namespace: "ns".to_string(),
202 deleted_at: Some(42),
203 deleted_at_iso: Some(crate::tz::epoch_to_iso(42)),
204 elapsed_ms: 1,
205 };
206 assert_eq!(resp.action, "soft_deleted");
207 assert!(resp.forgotten);
208 assert_eq!(resp.deleted_at, Some(42));
209 assert!(resp.deleted_at_iso.is_some());
210 }
211
212 #[test]
213 fn forget_response_already_deleted_preserves_timestamp() {
214 let resp = ForgetResponse {
215 action: "already_deleted".to_string(),
216 forgotten: false,
217 name: "abc".to_string(),
218 namespace: "my-project".to_string(),
219 deleted_at: Some(1_650_000_000),
220 deleted_at_iso: Some(crate::tz::epoch_to_iso(1_650_000_000)),
221 elapsed_ms: 2,
222 };
223 let json = serde_json::to_value(&resp).expect("serialization failed");
224 assert_eq!(json["action"], "already_deleted");
225 assert_eq!(json["forgotten"], false);
226 assert_eq!(json["deleted_at"], 1_650_000_000i64);
227 assert!(json["deleted_at_iso"].is_string());
228 }
229
230 #[test]
231 fn forget_response_not_found_omits_deleted_at_fields() {
232 let resp = ForgetResponse {
233 action: "not_found".to_string(),
234 forgotten: false,
235 name: "phantom".to_string(),
236 namespace: "global".to_string(),
237 deleted_at: None,
238 deleted_at_iso: None,
239 elapsed_ms: 0,
240 };
241 let json = serde_json::to_value(&resp).expect("serialization failed");
242 assert_eq!(json["action"], "not_found");
243 assert_eq!(json["forgotten"], false);
244 assert!(json.get("deleted_at").is_none());
246 assert!(json.get("deleted_at_iso").is_none());
247 assert_eq!(json["elapsed_ms"], 0u64);
248 }
249}