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 {
21 #[arg(
23 value_name = "NAME",
24 conflicts_with = "name",
25 help = "Memory name to soft-delete; alternative to --name"
26 )]
27 pub name_positional: Option<String>,
28 #[arg(long)]
31 pub name: Option<String>,
32 #[arg(
33 long,
34 help = "Namespace (env: SQLITE_GRAPHRAG_NAMESPACE, default: global)"
35 )]
36 pub namespace: Option<String>,
37 #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
38 pub json: bool,
39 #[arg(long, env = "SQLITE_GRAPHRAG_DB_PATH")]
40 pub db: Option<String>,
41}
42
43#[derive(Serialize)]
44struct ForgetResponse {
45 action: String,
47 forgotten: bool,
49 name: String,
50 namespace: String,
51 #[serde(skip_serializing_if = "Option::is_none")]
53 deleted_at: Option<i64>,
54 #[serde(skip_serializing_if = "Option::is_none")]
56 deleted_at_iso: Option<String>,
57 elapsed_ms: u64,
59}
60
61pub fn run(args: ForgetArgs) -> Result<(), AppError> {
62 let start = std::time::Instant::now();
63 tracing::debug!(target: "forget", name = ?args.name_positional.as_deref().or(args.name.as_deref()), "soft-deleting memory");
64 let name = args.name_positional.or(args.name).ok_or_else(|| {
66 AppError::Validation("name required: pass as positional argument or via --name".to_string())
67 })?;
68 let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
69 let paths = AppPaths::resolve(args.db.as_deref())?;
70 crate::storage::connection::ensure_db_ready(&paths)?;
71
72 let conn = open_rw(&paths.db)?;
73
74 let probe: Option<(i64, Option<i64>)> = conn
78 .query_row(
79 "SELECT id, deleted_at FROM memories WHERE namespace = ?1 AND name = ?2",
80 params![namespace, name],
81 |r| Ok((r.get::<_, i64>(0)?, r.get::<_, Option<i64>>(1)?)),
82 )
83 .optional()?;
84
85 let (action, forgotten, deleted_at, _memory_id) = match probe {
86 None => ("not_found", false, None, None),
87 Some((id, Some(existing))) => ("already_deleted", false, Some(existing), Some(id)),
88 Some((id, None)) => {
89 if let Err(e) = memories::delete_vec(&conn, id) {
95 tracing::warn!(
96 target: "forget",
97 memory_id = id,
98 error = %e,
99 "vec cleanup before soft-delete failed — orphan vector may be left",
100 );
101 }
102 let ok = memories::soft_delete(&conn, &namespace, &name)?;
103 if !ok {
104 let current: Option<i64> = conn
107 .query_row(
108 "SELECT deleted_at FROM memories WHERE id = ?1",
109 params![id],
110 |r| r.get::<_, Option<i64>>(0),
111 )
112 .optional()?
113 .flatten();
114 ("already_deleted", false, current, Some(id))
115 } else {
116 let ts: Option<i64> = conn
117 .query_row(
118 "SELECT deleted_at FROM memories WHERE id = ?1",
119 params![id],
120 |r| r.get::<_, Option<i64>>(0),
121 )
122 .optional()?
123 .flatten();
124 ("soft_deleted", true, ts, Some(id))
125 }
126 }
127 };
128
129 conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
134
135 if action == "not_found" {
136 return Err(AppError::NotFound(errors_msg::memory_not_found(
137 &name, &namespace,
138 )));
139 }
140
141 let deleted_at_iso = deleted_at.map(crate::tz::epoch_to_iso);
142 let response = ForgetResponse {
143 action: action.to_string(),
144 forgotten,
145 name: name.clone(),
146 namespace: namespace.clone(),
147 deleted_at,
148 deleted_at_iso,
149 elapsed_ms: start.elapsed().as_millis() as u64,
150 };
151 output::emit_json(&response)?;
152
153 Ok(())
154}
155
156#[cfg(test)]
157mod tests {
158 use super::*;
159
160 #[test]
161 fn forget_response_serializes_basic_fields() {
162 let resp = ForgetResponse {
163 action: "soft_deleted".to_string(),
164 forgotten: true,
165 name: "my-memory".to_string(),
166 namespace: "global".to_string(),
167 deleted_at: Some(1_700_000_000),
168 deleted_at_iso: Some("2023-11-14T22:13:20+00:00".to_string()),
169 elapsed_ms: 5,
170 };
171 let json = serde_json::to_value(&resp).expect("serialization failed");
172 assert_eq!(json["action"], "soft_deleted");
173 assert_eq!(json["forgotten"], true);
174 assert_eq!(json["name"], "my-memory");
175 assert_eq!(json["namespace"], "global");
176 assert_eq!(json["deleted_at"], 1_700_000_000i64);
177 assert!(json["deleted_at_iso"].is_string());
178 assert!(json["elapsed_ms"].is_number());
179 }
180
181 #[test]
182 fn forget_response_action_soft_deleted_implies_forgotten_true() {
183 let resp = ForgetResponse {
184 action: "soft_deleted".to_string(),
185 forgotten: true,
186 name: "test".to_string(),
187 namespace: "ns".to_string(),
188 deleted_at: Some(42),
189 deleted_at_iso: Some(crate::tz::epoch_to_iso(42)),
190 elapsed_ms: 1,
191 };
192 assert_eq!(resp.action, "soft_deleted");
193 assert!(resp.forgotten);
194 assert_eq!(resp.deleted_at, Some(42));
195 assert!(resp.deleted_at_iso.is_some());
196 }
197
198 #[test]
199 fn forget_response_already_deleted_preserves_timestamp() {
200 let resp = ForgetResponse {
201 action: "already_deleted".to_string(),
202 forgotten: false,
203 name: "abc".to_string(),
204 namespace: "my-project".to_string(),
205 deleted_at: Some(1_650_000_000),
206 deleted_at_iso: Some(crate::tz::epoch_to_iso(1_650_000_000)),
207 elapsed_ms: 2,
208 };
209 let json = serde_json::to_value(&resp).expect("serialization failed");
210 assert_eq!(json["action"], "already_deleted");
211 assert_eq!(json["forgotten"], false);
212 assert_eq!(json["deleted_at"], 1_650_000_000i64);
213 assert!(json["deleted_at_iso"].is_string());
214 }
215
216 #[test]
217 fn forget_response_not_found_omits_deleted_at_fields() {
218 let resp = ForgetResponse {
219 action: "not_found".to_string(),
220 forgotten: false,
221 name: "phantom".to_string(),
222 namespace: "global".to_string(),
223 deleted_at: None,
224 deleted_at_iso: None,
225 elapsed_ms: 0,
226 };
227 let json = serde_json::to_value(&resp).expect("serialization failed");
228 assert_eq!(json["action"], "not_found");
229 assert_eq!(json["forgotten"], false);
230 assert!(json.get("deleted_at").is_none());
232 assert!(json.get("deleted_at_iso").is_none());
233 assert_eq!(json["elapsed_ms"], 0u64);
234 }
235}