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")]
20pub struct ForgetArgs {
21    /// Memory name as a positional argument. Alternative to `--name`.
22    #[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    /// Memory name to soft-delete. The row is preserved with `deleted_at` set, recoverable via `restore`.
29    /// Use `purge` to permanently remove soft-deleted memories.
30    #[arg(long)]
31    pub name: Option<String>,
32    #[arg(long, default_value = "global")]
33    pub namespace: Option<String>,
34    #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
35    pub json: bool,
36    #[arg(long, env = "SQLITE_GRAPHRAG_DB_PATH")]
37    pub db: Option<String>,
38}
39
40#[derive(Serialize)]
41struct ForgetResponse {
42    /// Outcome of the forget operation: `soft_deleted`, `already_deleted`, or `not_found`.
43    action: String,
44    /// True only when this invocation actively transitioned the memory from live to soft-deleted.
45    forgotten: bool,
46    name: String,
47    namespace: String,
48    /// Unix epoch seconds when the memory was soft-deleted; `None` when `action="not_found"`.
49    #[serde(skip_serializing_if = "Option::is_none")]
50    deleted_at: Option<i64>,
51    /// RFC 3339 UTC timestamp parallel to `deleted_at` for ISO 8601 parsers.
52    #[serde(skip_serializing_if = "Option::is_none")]
53    deleted_at_iso: Option<String>,
54    /// Total execution time in milliseconds from handler start to serialisation.
55    elapsed_ms: u64,
56}
57
58pub fn run(args: ForgetArgs) -> Result<(), AppError> {
59    let start = std::time::Instant::now();
60    // Resolve name from positional or --name flag; both are optional, at least one is required.
61    let name = args.name_positional.or(args.name).ok_or_else(|| {
62        AppError::Validation("name required: pass as positional argument or via --name".to_string())
63    })?;
64    let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
65    let paths = AppPaths::resolve(args.db.as_deref())?;
66    crate::storage::connection::ensure_db_ready(&paths)?;
67
68    let conn = open_rw(&paths.db)?;
69
70    // Probe state without filtering on `deleted_at` so we can distinguish
71    // `not_found` (no row) from `already_deleted` (row with deleted_at set)
72    // from the live case (deleted_at IS NULL) handled by `soft_delete`.
73    let probe: Option<(i64, Option<i64>)> = conn
74        .query_row(
75            "SELECT id, deleted_at FROM memories WHERE namespace = ?1 AND name = ?2",
76            params![namespace, name],
77            |r| Ok((r.get::<_, i64>(0)?, r.get::<_, Option<i64>>(1)?)),
78        )
79        .optional()?;
80
81    let (action, forgotten, deleted_at, memory_id) = match probe {
82        None => ("not_found", false, None, None),
83        Some((id, Some(existing))) => ("already_deleted", false, Some(existing), Some(id)),
84        Some((id, None)) => {
85            let ok = memories::soft_delete(&conn, &namespace, &name)?;
86            if !ok {
87                // Race: row was concurrently soft-deleted between probe and update.
88                // Re-read to get the current `deleted_at`.
89                let current: Option<i64> = conn
90                    .query_row(
91                        "SELECT deleted_at FROM memories WHERE id = ?1",
92                        params![id],
93                        |r| r.get::<_, Option<i64>>(0),
94                    )
95                    .optional()?
96                    .flatten();
97                ("already_deleted", false, current, Some(id))
98            } else {
99                let ts: Option<i64> = conn
100                    .query_row(
101                        "SELECT deleted_at FROM memories WHERE id = ?1",
102                        params![id],
103                        |r| r.get::<_, Option<i64>>(0),
104                    )
105                    .optional()?
106                    .flatten();
107                ("soft_deleted", true, ts, Some(id))
108            }
109        }
110    };
111
112    if forgotten {
113        if let Some(id) = memory_id {
114            // FTS5 external-content: manual `DELETE FROM fts_memories WHERE rowid=?`
115            // corrupts the index. The correct cleanup happens via the `trg_fts_ad` trigger
116            // when `purge` physically removes the row from `memories`. Between soft-delete
117            // and purge, FTS queries filter `m.deleted_at IS NULL` in the JOIN.
118            if let Err(e) = memories::delete_vec(&conn, id) {
119                tracing::warn!(memory_id = id, error = %e, "vec cleanup failed — orphan vector left");
120            }
121        }
122    }
123
124    let deleted_at_iso = deleted_at.map(crate::tz::epoch_to_iso);
125    let response = ForgetResponse {
126        action: action.to_string(),
127        forgotten,
128        name: name.clone(),
129        namespace: namespace.clone(),
130        deleted_at,
131        deleted_at_iso,
132        elapsed_ms: start.elapsed().as_millis() as u64,
133    };
134    output::emit_json(&response)?;
135
136    if action == "not_found" {
137        return Err(AppError::NotFound(errors_msg::memory_not_found(
138            &name, &namespace,
139        )));
140    }
141
142    Ok(())
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    #[test]
150    fn forget_response_serializes_basic_fields() {
151        let resp = ForgetResponse {
152            action: "soft_deleted".to_string(),
153            forgotten: true,
154            name: "my-memory".to_string(),
155            namespace: "global".to_string(),
156            deleted_at: Some(1_700_000_000),
157            deleted_at_iso: Some("2023-11-14T22:13:20+00:00".to_string()),
158            elapsed_ms: 5,
159        };
160        let json = serde_json::to_value(&resp).expect("serialization failed");
161        assert_eq!(json["action"], "soft_deleted");
162        assert_eq!(json["forgotten"], true);
163        assert_eq!(json["name"], "my-memory");
164        assert_eq!(json["namespace"], "global");
165        assert_eq!(json["deleted_at"], 1_700_000_000i64);
166        assert!(json["deleted_at_iso"].is_string());
167        assert!(json["elapsed_ms"].is_number());
168    }
169
170    #[test]
171    fn forget_response_action_soft_deleted_implies_forgotten_true() {
172        let resp = ForgetResponse {
173            action: "soft_deleted".to_string(),
174            forgotten: true,
175            name: "test".to_string(),
176            namespace: "ns".to_string(),
177            deleted_at: Some(42),
178            deleted_at_iso: Some(crate::tz::epoch_to_iso(42)),
179            elapsed_ms: 1,
180        };
181        assert_eq!(resp.action, "soft_deleted");
182        assert!(resp.forgotten);
183        assert_eq!(resp.deleted_at, Some(42));
184        assert!(resp.deleted_at_iso.is_some());
185    }
186
187    #[test]
188    fn forget_response_already_deleted_preserves_timestamp() {
189        let resp = ForgetResponse {
190            action: "already_deleted".to_string(),
191            forgotten: false,
192            name: "abc".to_string(),
193            namespace: "my-project".to_string(),
194            deleted_at: Some(1_650_000_000),
195            deleted_at_iso: Some(crate::tz::epoch_to_iso(1_650_000_000)),
196            elapsed_ms: 2,
197        };
198        let json = serde_json::to_value(&resp).expect("serialization failed");
199        assert_eq!(json["action"], "already_deleted");
200        assert_eq!(json["forgotten"], false);
201        assert_eq!(json["deleted_at"], 1_650_000_000i64);
202        assert!(json["deleted_at_iso"].is_string());
203    }
204
205    #[test]
206    fn forget_response_not_found_omits_deleted_at_fields() {
207        let resp = ForgetResponse {
208            action: "not_found".to_string(),
209            forgotten: false,
210            name: "phantom".to_string(),
211            namespace: "global".to_string(),
212            deleted_at: None,
213            deleted_at_iso: None,
214            elapsed_ms: 0,
215        };
216        let json = serde_json::to_value(&resp).expect("serialization failed");
217        assert_eq!(json["action"], "not_found");
218        assert_eq!(json["forgotten"], false);
219        // skip_serializing_if = "Option::is_none" means both fields are absent
220        assert!(json.get("deleted_at").is_none());
221        assert!(json.get("deleted_at_iso").is_none());
222        assert_eq!(json["elapsed_ms"], 0u64);
223    }
224}