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(
34        long,
35        help = "Namespace (flag / XDG namespace.default / global)"
36    )]
37    /// Namespace scope.
38    pub namespace: Option<String>,
39    /// Emit machine-readable JSON on stdout.
40    #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
41    pub json: bool,
42    /// Path to the SQLite database file.
43    #[arg(long)]
44    pub db: Option<String>,
45}
46
47#[derive(Serialize)]
48struct ForgetResponse {
49    /// Outcome of the forget operation: `soft_deleted`, `already_deleted`, or `not_found`.
50    action: String,
51    /// True only when this invocation actively transitioned the memory from live to soft-deleted.
52    forgotten: bool,
53    name: String,
54    namespace: String,
55    /// Unix epoch seconds when the memory was soft-deleted; `None` when `action="not_found"`.
56    #[serde(skip_serializing_if = "Option::is_none")]
57    deleted_at: Option<i64>,
58    /// RFC 3339 UTC timestamp parallel to `deleted_at` for ISO 8601 parsers.
59    #[serde(skip_serializing_if = "Option::is_none")]
60    deleted_at_iso: Option<String>,
61    /// Total execution time in milliseconds from handler start to serialisation.
62    elapsed_ms: u64,
63}
64
65/// Run.
66pub 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    // Resolve name from positional or --name flag; both are optional, at least one is required.
70    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    // Probe state without filtering on `deleted_at` so we can distinguish
80    // `not_found` (no row) from `already_deleted` (row with deleted_at set)
81    // from the live case (deleted_at IS NULL) handled by `soft_delete`.
82    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            // G39 Passo 4 (v1.0.69): remove the embedding vector BEFORE the
95            // soft-delete so we do not leave a `vec_memories` row that will
96            // show up as `vec_memories_orphaned` in `health --json`. The
97            // operation is best-effort: a failure is logged but does not
98            // abort the soft-delete (the user-visible action is the same).
99            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                // Race: row was concurrently soft-deleted between probe and update.
110                // Re-read to get the current `deleted_at`.
111                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    // NOTE: delete_vec is already called BEFORE soft_delete (line 94) inside
135    // the `Some((id, None))` arm above.  A second call here is redundant and
136    // produces spurious log warnings when the vector row no longer exists.
137
138    // GAP-SG-13: cascade-clean the enrich-queue sidecar so a freshly forgotten
139    // memory never lingers as a pending/dead-letter row keyed to a now-deleted
140    // memory. Best-effort and a no-op when the queue file is absent.
141    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        // skip_serializing_if = "Option::is_none" means both fields are absent
245        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}