Skip to main content

sqlite_graphrag/commands/
forget.rs

1//! Handler for the `forget` CLI subcommand.
2
3use 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")]
20/// Forget args.
21pub struct ForgetArgs {
22    /// Memory name as a positional argument. Alternative to `--name`.
23    #[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    /// Memory name to soft-delete. The row is preserved with `deleted_at` set, recoverable via `restore`.
30    /// Use `purge` to permanently remove soft-deleted memories.
31    #[arg(long)]
32    pub name: Option<String>,
33    #[arg(long, help = "Namespace (flag / XDG namespace.default / global)")]
34    /// Namespace scope.
35    pub namespace: Option<String>,
36    /// Emit machine-readable JSON on stdout.
37    #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
38    pub json: bool,
39    /// Path to the SQLite database file.
40    #[arg(long)]
41    pub db: Option<String>,
42}
43
44#[derive(Serialize)]
45struct ForgetResponse {
46    /// Outcome of the forget operation: `soft_deleted`, `already_deleted`, or `not_found`.
47    action: String,
48    /// True only when this invocation actively transitioned the memory from live to soft-deleted.
49    forgotten: bool,
50    name: String,
51    namespace: String,
52    /// Unix epoch seconds when the memory was soft-deleted; `None` when `action="not_found"`.
53    #[serde(skip_serializing_if = "Option::is_none")]
54    deleted_at: Option<i64>,
55    /// RFC 3339 UTC timestamp parallel to `deleted_at` for ISO 8601 parsers.
56    #[serde(skip_serializing_if = "Option::is_none")]
57    deleted_at_iso: Option<String>,
58    /// Total execution time in milliseconds from handler start to serialisation.
59    elapsed_ms: u64,
60}
61
62/// Run.
63pub fn run(args: ForgetArgs) -> Result<(), AppError> {
64    let start = std::time::Instant::now();
65    tracing::debug!(target: "forget", name = ?args.name_positional.as_deref().or(args.name.as_deref()), "soft-deleting memory");
66    // Resolve name from positional or --name flag; both are optional, at least one is required.
67    let name = args.name_positional.or(args.name).ok_or_else(|| {
68        AppError::Validation(crate::i18n::validation::name_required_positional_or_flag())
69    })?;
70    let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
71    let paths = AppPaths::resolve(args.db.as_deref())?;
72    crate::storage::connection::ensure_db_ready(&paths)?;
73
74    let conn = open_rw(&paths.db)?;
75
76    // Probe state without filtering on `deleted_at` so we can distinguish
77    // `not_found` (no row) from `already_deleted` (row with deleted_at set)
78    // from the live case (deleted_at IS NULL) handled by `soft_delete`.
79    let probe: Option<(i64, Option<i64>)> = conn
80        .query_row(
81            "SELECT id, deleted_at FROM memories WHERE namespace = ?1 AND name = ?2",
82            params![namespace, name],
83            |r| Ok((r.get::<_, i64>(0)?, r.get::<_, Option<i64>>(1)?)),
84        )
85        .optional()?;
86
87    let (action, forgotten, deleted_at, memory_id) = match probe {
88        None => ("not_found", false, None, None),
89        Some((id, Some(existing))) => ("already_deleted", false, Some(existing), Some(id)),
90        Some((id, None)) => {
91            // G39 Passo 4 (v1.0.69): remove the embedding vector BEFORE the
92            // soft-delete so we do not leave a `vec_memories` row that will
93            // show up as `vec_memories_orphaned` in `health --json`. The
94            // operation is best-effort: a failure is logged but does not
95            // abort the soft-delete (the user-visible action is the same).
96            if let Err(e) = memories::delete_vec(&conn, id) {
97                tracing::warn!(
98                    target: "forget",
99                    memory_id = id,
100                    error = %e,
101                    "vec cleanup before soft-delete failed — orphan vector may be left",
102                );
103            }
104            let ok = memories::soft_delete(&conn, &namespace, &name)?;
105            if !ok {
106                // Race: row was concurrently soft-deleted between probe and update.
107                // Re-read to get the current `deleted_at`.
108                let current: Option<i64> = conn
109                    .query_row(
110                        "SELECT deleted_at FROM memories WHERE id = ?1",
111                        params![id],
112                        |r| r.get::<_, Option<i64>>(0),
113                    )
114                    .optional()?
115                    .flatten();
116                ("already_deleted", false, current, Some(id))
117            } else {
118                let ts: Option<i64> = conn
119                    .query_row(
120                        "SELECT deleted_at FROM memories WHERE id = ?1",
121                        params![id],
122                        |r| r.get::<_, Option<i64>>(0),
123                    )
124                    .optional()?
125                    .flatten();
126                ("soft_deleted", true, ts, Some(id))
127            }
128        }
129    };
130
131    // NOTE: delete_vec is already called BEFORE soft_delete (line 94) inside
132    // the `Some((id, None))` arm above.  A second call here is redundant and
133    // produces spurious log warnings when the vector row no longer exists.
134
135    // GAP-SG-13: cascade-clean the enrich-queue sidecar so a freshly forgotten
136    // memory never lingers as a pending/dead-letter row keyed to a now-deleted
137    // memory. Best-effort and a no-op when the queue file is absent.
138    if forgotten {
139        if let Some(id) = memory_id {
140            crate::commands::enrich::cleanup_queue_entry(&paths.db, id, &name);
141        }
142    }
143
144    conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
145
146    if action == "not_found" {
147        return Err(AppError::NotFound(errors_msg::memory_not_found(
148            &name, &namespace,
149        )));
150    }
151
152    let deleted_at_iso = deleted_at.map(crate::tz::epoch_to_iso);
153    let response = ForgetResponse {
154        action: action.to_string(),
155        forgotten,
156        name: name.clone(),
157        namespace: namespace.clone(),
158        deleted_at,
159        deleted_at_iso,
160        elapsed_ms: start.elapsed().as_millis() as u64,
161    };
162    output::emit_json(&response)?;
163
164    Ok(())
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    #[test]
172    fn forget_response_serializes_basic_fields() {
173        let resp = ForgetResponse {
174            action: "soft_deleted".to_string(),
175            forgotten: true,
176            name: "my-memory".to_string(),
177            namespace: "global".to_string(),
178            deleted_at: Some(1_700_000_000),
179            deleted_at_iso: Some("2023-11-14T22:13:20+00:00".to_string()),
180            elapsed_ms: 5,
181        };
182        let json = serde_json::to_value(&resp).expect("serialization failed");
183        assert_eq!(json["action"], "soft_deleted");
184        assert_eq!(json["forgotten"], true);
185        assert_eq!(json["name"], "my-memory");
186        assert_eq!(json["namespace"], "global");
187        assert_eq!(json["deleted_at"], 1_700_000_000i64);
188        assert!(json["deleted_at_iso"].is_string());
189        assert!(json["elapsed_ms"].is_number());
190    }
191
192    #[test]
193    fn forget_response_action_soft_deleted_implies_forgotten_true() {
194        let resp = ForgetResponse {
195            action: "soft_deleted".to_string(),
196            forgotten: true,
197            name: "test".to_string(),
198            namespace: "ns".to_string(),
199            deleted_at: Some(42),
200            deleted_at_iso: Some(crate::tz::epoch_to_iso(42)),
201            elapsed_ms: 1,
202        };
203        assert_eq!(resp.action, "soft_deleted");
204        assert!(resp.forgotten);
205        assert_eq!(resp.deleted_at, Some(42));
206        assert!(resp.deleted_at_iso.is_some());
207    }
208
209    #[test]
210    fn forget_response_already_deleted_preserves_timestamp() {
211        let resp = ForgetResponse {
212            action: "already_deleted".to_string(),
213            forgotten: false,
214            name: "abc".to_string(),
215            namespace: "my-project".to_string(),
216            deleted_at: Some(1_650_000_000),
217            deleted_at_iso: Some(crate::tz::epoch_to_iso(1_650_000_000)),
218            elapsed_ms: 2,
219        };
220        let json = serde_json::to_value(&resp).expect("serialization failed");
221        assert_eq!(json["action"], "already_deleted");
222        assert_eq!(json["forgotten"], false);
223        assert_eq!(json["deleted_at"], 1_650_000_000i64);
224        assert!(json["deleted_at_iso"].is_string());
225    }
226
227    #[test]
228    fn forget_response_not_found_omits_deleted_at_fields() {
229        let resp = ForgetResponse {
230            action: "not_found".to_string(),
231            forgotten: false,
232            name: "phantom".to_string(),
233            namespace: "global".to_string(),
234            deleted_at: None,
235            deleted_at_iso: None,
236            elapsed_ms: 0,
237        };
238        let json = serde_json::to_value(&resp).expect("serialization failed");
239        assert_eq!(json["action"], "not_found");
240        assert_eq!(json["forgotten"], false);
241        // skip_serializing_if = "Option::is_none" means both fields are absent
242        assert!(json.get("deleted_at").is_none());
243        assert!(json.get("deleted_at_iso").is_none());
244        assert_eq!(json["elapsed_ms"], 0u64);
245    }
246}