Skip to main content

sqlite_graphrag/commands/
purge.rs

1//! Handler for the `purge` 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 serde::Serialize;
9
10#[derive(clap::Args)]
11#[command(after_long_help = "EXAMPLES:\n  \
12    # Permanently delete soft-deleted memories older than 90 days (default retention)\n  \
13    sqlite-graphrag purge\n\n  \
14    # Custom retention window in days\n  \
15    sqlite-graphrag purge --retention-days 30\n\n  \
16    # Purge ALL soft-deleted memories regardless of age\n  \
17    sqlite-graphrag purge --retention-days 0\n\n  \
18    # Preview what would be purged without deleting\n  \
19    sqlite-graphrag purge --dry-run\n\n  \
20    # Purge a specific memory by name (positional or --name, never both)\n  \
21    sqlite-graphrag purge old-memory --namespace my-project\n  \
22    sqlite-graphrag purge --name old-memory --namespace my-project\n\n\
23NOTES:\n  \
24    `--yes` only confirms intent and does NOT override `--retention-days`.\n  \
25    To wipe every soft-deleted memory immediately, use `--yes --now`\n  \
26    (alias for `--retention-days 0`) or pair `--yes` with `--retention-days 0`.")]
27/// Purge args.
28pub struct PurgeArgs {
29    /// Memory name as a positional argument. Alternative to `--name`.
30    ///
31    /// GAP-SG-272: matches the spelling `delete-entity`, `reclassify`,
32    /// `rename-entity` and `split-body` already accept. The conflict is declared
33    /// here only, because clap's conflicts are symmetric and stating it once
34    /// keeps the two spellings from drifting apart. Both spellings stay optional
35    /// because omitting the name is what selects the bulk mode driven by
36    /// `--retention-days`.
37    #[arg(
38        value_name = "NAME",
39        conflicts_with = "name",
40        help = "Memory name (kebab-case slug); alternative to --name"
41    )]
42    pub name_positional: Option<String>,
43    /// Soft-deleted memory to purge, narrowing the run to that single name.
44    ///
45    /// Mutually exclusive with the `NAME` positional. When neither spelling is
46    /// supplied, `purge` runs in bulk mode and removes every soft-deleted memory
47    /// of the namespace that is older than `--retention-days`. When one of them
48    /// is supplied, the retention window still applies: a memory newer than the
49    /// cutoff is not purged and the command fails as not found.
50    #[arg(long)]
51    pub name: Option<String>,
52    /// Namespace to purge. Defaults to contextual namespace (`config` / flag / global).
53    #[arg(long)]
54    pub namespace: Option<String>,
55    /// Retention days: memories with deleted_at older than (now - retention_days*86400) will be
56    /// permanently removed. Default: PURGE_RETENTION_DAYS_DEFAULT (90). Use 0 to purge all
57    /// soft-deleted memories regardless of age. Alias: `--max-age-days`.
58    #[arg(
59        long,
60        alias = "days",
61        alias = "max-age-days",
62        value_name = "DAYS",
63        default_value_t = crate::constants::PURGE_RETENTION_DAYS_DEFAULT
64    )]
65    pub retention_days: u32,
66    /// [DEPRECATED in v2.0.0] Legacy alias — use --retention-days instead.
67    #[arg(long, hide = true)]
68    pub older_than_seconds: Option<u64>,
69    /// Does not execute DELETE: computes and reports what WOULD be purged.
70    #[arg(long, default_value_t = false)]
71    pub dry_run: bool,
72    /// Confirms destructive intent for tools that require explicit acknowledgement.
73    /// Does NOT override `--retention-days`: combine with `--now` or `--retention-days 0`
74    /// to wipe every soft-deleted memory regardless of age.
75    #[arg(long, default_value_t = false)]
76    pub yes: bool,
77    /// Equivalent to `--retention-days 0` (purge all soft-deleted, any age).
78    #[arg(long, default_value_t = false)]
79    pub now: bool,
80    /// Emit machine-readable JSON on stdout.
81    #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
82    pub json: bool,
83    /// Path to the SQLite database file.
84    #[arg(long)]
85    pub db: Option<String>,
86}
87
88/// Purge response.
89#[derive(Serialize)]
90pub struct PurgeResponse {
91    /// Action.
92    pub action: String,
93    /// Purged count.
94    pub purged_count: usize,
95    /// Bytes freed.
96    pub bytes_freed: i64,
97    /// Oldest deleted at.
98    pub oldest_deleted_at: Option<i64>,
99    /// Retention days used.
100    pub retention_days_used: u32,
101    /// Show what would happen without making changes.
102    pub dry_run: bool,
103    /// Namespace scope.
104    pub namespace: Option<String>,
105    /// Cutoff epoch.
106    pub cutoff_epoch: i64,
107    /// Warnings.
108    pub warnings: Vec<String>,
109    /// Total execution time in milliseconds from handler start to serialisation.
110    pub elapsed_ms: u64,
111    /// Human-readable explanation surfaced when nothing was purged so callers
112    /// understand the retention semantics. Present only when
113    /// `purged_count == 0` (M2 in v1.0.32) — kept absent otherwise to preserve
114    /// the existing JSON contract.
115    #[serde(skip_serializing_if = "Option::is_none")]
116    pub message: Option<String>,
117}
118
119/// Permanently delete soft-deleted memories that have exceeded the retention window.
120///
121/// Only memories with `deleted_at IS NOT NULL AND deleted_at <= cutoff_epoch` are affected.
122/// When `--dry-run` is set the DELETE is skipped and the response reflects candidates only.
123pub fn run(args: PurgeArgs) -> Result<(), AppError> {
124    let started = std::time::Instant::now();
125
126    // GAP-SG-272: resolved once, so the two spellings cannot disagree between the
127    // metric query, the not-found guard and the DELETE. `or` cannot mask a
128    // conflict here, because clap already refused the invocation that supplied
129    // both spellings. `None` keeps the bulk retention mode intact.
130    let designated_name: Option<&str> = args.name_positional.as_deref().or(args.name.as_deref());
131
132    let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
133    let paths = AppPaths::resolve(args.db.as_deref())?;
134
135    crate::storage::connection::ensure_db_ready(&paths)?;
136
137    let mut warnings: Vec<String> = Vec::with_capacity(1);
138    let now = current_epoch()?;
139
140    let retention_days = if args.now { 0 } else { args.retention_days };
141    let cutoff_epoch = if let Some(secs) = args.older_than_seconds {
142        warnings.push(
143            "--older-than-seconds is deprecated; use --retention-days in v2.0.0+".to_string(),
144        );
145        now - secs as i64
146    } else {
147        now - (retention_days as i64) * 86_400
148    };
149
150    let namespace_opt: Option<&str> = Some(namespace.as_str());
151
152    let mut conn = open_rw(&paths.db)?;
153
154    let (bytes_freed, oldest_deleted_at, candidates_count) =
155        compute_metrics(&conn, cutoff_epoch, namespace_opt, designated_name)?;
156
157    if candidates_count == 0 && designated_name.is_some() {
158        return Err(AppError::NotFound(
159            errors_msg::soft_deleted_memory_not_found(
160                designated_name.unwrap_or_default(),
161                &namespace,
162            ),
163        ));
164    }
165
166    if !args.dry_run && !args.yes {
167        return Err(AppError::Validation(
168            "destructive operation: pass --yes to confirm purge (use --dry-run to preview)"
169                .to_string(),
170        ));
171    }
172
173    if !args.dry_run {
174        let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
175        execute_purge(
176            &tx,
177            &paths.db,
178            &namespace,
179            designated_name,
180            cutoff_epoch,
181            &mut warnings,
182        )?;
183        tx.commit()?;
184        conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
185    }
186
187    let message = if candidates_count == 0 {
188        Some(format!(
189            "no soft-deleted memories older than {retention_days} day(s); use --now or --retention-days 0 to purge all soft-deleted memories regardless of age"
190        ))
191    } else {
192        None
193    };
194
195    output::emit_json(&PurgeResponse {
196        action: if args.dry_run {
197            "dry_run".to_string()
198        } else {
199            "purged".to_string()
200        },
201        purged_count: candidates_count,
202        bytes_freed,
203        oldest_deleted_at,
204        retention_days_used: retention_days,
205        dry_run: args.dry_run,
206        namespace: Some(namespace),
207        cutoff_epoch,
208        warnings,
209        elapsed_ms: started.elapsed().as_millis() as u64,
210        message,
211    })?;
212
213    Ok(())
214}
215
216fn current_epoch() -> Result<i64, AppError> {
217    let now = std::time::SystemTime::now()
218        .duration_since(std::time::UNIX_EPOCH)
219        .map_err(|err| AppError::Internal(anyhow::anyhow!("system clock error: {err}")))?;
220    Ok(now.as_secs() as i64)
221}
222
223fn compute_metrics(
224    conn: &rusqlite::Connection,
225    cutoff_epoch: i64,
226    namespace_opt: Option<&str>,
227    name: Option<&str>,
228) -> Result<(i64, Option<i64>, usize), AppError> {
229    let (bytes_freed, oldest_deleted_at): (i64, Option<i64>) = if let Some(name) = name {
230        conn.query_row(
231            "SELECT COALESCE(SUM(LENGTH(COALESCE(body,'')) + LENGTH(COALESCE(description,'')) + LENGTH(name)), 0),
232                    MIN(deleted_at)
233             FROM memories
234             WHERE deleted_at IS NOT NULL AND deleted_at <= ?1
235                   AND (?2 IS NULL OR namespace = ?2)
236                   AND name = ?3",
237            rusqlite::params![cutoff_epoch, namespace_opt, name],
238            |r| Ok((r.get::<_, i64>(0)?, r.get::<_, Option<i64>>(1)?)),
239        )?
240    } else {
241        conn.query_row(
242            "SELECT COALESCE(SUM(LENGTH(COALESCE(body,'')) + LENGTH(COALESCE(description,'')) + LENGTH(name)), 0),
243                    MIN(deleted_at)
244             FROM memories
245             WHERE deleted_at IS NOT NULL AND deleted_at <= ?1
246                   AND (?2 IS NULL OR namespace = ?2)",
247            rusqlite::params![cutoff_epoch, namespace_opt],
248            |r| Ok((r.get::<_, i64>(0)?, r.get::<_, Option<i64>>(1)?)),
249        )?
250    };
251
252    let count: usize = if let Some(name) = name {
253        conn.query_row(
254            "SELECT COUNT(*) FROM memories
255             WHERE deleted_at IS NOT NULL AND deleted_at <= ?1
256                   AND (?2 IS NULL OR namespace = ?2)
257                   AND name = ?3",
258            rusqlite::params![cutoff_epoch, namespace_opt, name],
259            |r| r.get::<_, usize>(0),
260        )?
261    } else {
262        conn.query_row(
263            "SELECT COUNT(*) FROM memories
264             WHERE deleted_at IS NOT NULL AND deleted_at <= ?1
265                   AND (?2 IS NULL OR namespace = ?2)",
266            rusqlite::params![cutoff_epoch, namespace_opt],
267            |r| r.get::<_, usize>(0),
268        )?
269    };
270
271    Ok((bytes_freed, oldest_deleted_at, count))
272}
273
274fn execute_purge(
275    tx: &rusqlite::Transaction,
276    db_path: &std::path::Path,
277    namespace: &str,
278    name: Option<&str>,
279    cutoff_epoch: i64,
280    warnings: &mut Vec<String>,
281) -> Result<(), AppError> {
282    let candidates = select_candidates(tx, namespace, name, cutoff_epoch)?;
283
284    for (memory_id, name) in &candidates {
285        // GAP-SG-13: cascade-clean the enrich-queue sidecar for each purged
286        // memory (best-effort; no-op when the queue file is absent).
287        crate::commands::enrich::cleanup_queue_entry(db_path, *memory_id, name);
288        if let Err(err) = tx.execute(
289            "DELETE FROM vec_chunks WHERE memory_id = ?1",
290            rusqlite::params![memory_id],
291        ) {
292            warnings.push(format!(
293                "failed to clean vec_chunks for memory_id {memory_id}: {err}"
294            ));
295        }
296        if let Err(err) = tx.execute(
297            "DELETE FROM vec_memories WHERE memory_id = ?1",
298            rusqlite::params![memory_id],
299        ) {
300            warnings.push(format!(
301                "failed to clean vec_memories for memory_id {memory_id}: {err}"
302            ));
303        }
304        tx.execute(
305            "DELETE FROM memories WHERE id = ?1 AND namespace = ?2 AND deleted_at IS NOT NULL",
306            rusqlite::params![memory_id, namespace],
307        )?;
308    }
309
310    Ok(())
311}
312
313fn select_candidates(
314    conn: &rusqlite::Connection,
315    namespace: &str,
316    name: Option<&str>,
317    cutoff_epoch: i64,
318) -> Result<Vec<(i64, String)>, AppError> {
319    let query = if name.is_some() {
320        "SELECT id, name FROM memories
321         WHERE namespace = ?1 AND name = ?2 AND deleted_at IS NOT NULL AND deleted_at <= ?3
322         ORDER BY deleted_at ASC"
323    } else {
324        "SELECT id, name FROM memories
325         WHERE namespace = ?1 AND deleted_at IS NOT NULL AND deleted_at <= ?2
326         ORDER BY deleted_at ASC"
327    };
328
329    let mut stmt = conn.prepare_cached(query)?;
330    let rows = if let Some(name) = name {
331        stmt.query_map(rusqlite::params![namespace, name, cutoff_epoch], |row| {
332            Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
333        })?
334        .collect::<Result<Vec<_>, _>>()?
335    } else {
336        stmt.query_map(rusqlite::params![namespace, cutoff_epoch], |row| {
337            Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
338        })?
339        .collect::<Result<Vec<_>, _>>()?
340    };
341    Ok(rows)
342}
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347    use rusqlite::Connection;
348
349    fn setup_test_db() -> Connection {
350        let conn = Connection::open_in_memory().expect("failed to open in-memory db");
351        conn.execute_batch(
352            "CREATE TABLE memories (
353                id INTEGER PRIMARY KEY AUTOINCREMENT,
354                name TEXT NOT NULL,
355                namespace TEXT NOT NULL DEFAULT 'global',
356                description TEXT,
357                body TEXT,
358                deleted_at INTEGER
359            );
360            CREATE TABLE IF NOT EXISTS vec_chunks (memory_id INTEGER);
361            CREATE TABLE IF NOT EXISTS vec_memories (memory_id INTEGER);",
362        )
363        .expect("failed to create test tables");
364        conn
365    }
366
367    fn insert_deleted_memory(
368        conn: &Connection,
369        name: &str,
370        namespace: &str,
371        body: &str,
372        deleted_at: i64,
373    ) -> i64 {
374        conn.execute(
375            "INSERT INTO memories (name, namespace, body, deleted_at) VALUES (?1, ?2, ?3, ?4)",
376            rusqlite::params![name, namespace, body, deleted_at],
377        )
378        .expect("failed to insert test memory");
379        conn.last_insert_rowid()
380    }
381
382    #[test]
383    fn retention_days_used_default_is_90() {
384        assert_eq!(crate::constants::PURGE_RETENTION_DAYS_DEFAULT, 90u32);
385    }
386
387    #[test]
388    fn compute_metrics_bytes_freed_positive_for_populated_body() {
389        let conn = setup_test_db();
390        let now = current_epoch().expect("epoch failed");
391        let old_epoch = now - 100 * 86_400;
392        insert_deleted_memory(&conn, "mem-test", "global", "memory body", old_epoch);
393
394        let cutoff = now - 30 * 86_400;
395        let (bytes, oldest, count) =
396            compute_metrics(&conn, cutoff, Some("global"), None).expect("compute_metrics failed");
397
398        assert!(bytes > 0, "bytes_freed must be > 0 for populated body");
399        assert!(oldest.is_some(), "oldest_deleted_at must be Some");
400        assert_eq!(count, 1);
401    }
402
403    #[test]
404    fn compute_metrics_returns_zero_without_candidates() {
405        let conn = setup_test_db();
406        let now = current_epoch().expect("epoch failed");
407        let cutoff = now - 90 * 86_400;
408
409        let (bytes, oldest, count) =
410            compute_metrics(&conn, cutoff, Some("global"), None).expect("compute_metrics failed");
411
412        assert_eq!(bytes, 0);
413        assert!(oldest.is_none());
414        assert_eq!(count, 0);
415    }
416
417    #[test]
418    fn dry_run_does_not_delete_records() {
419        let conn = setup_test_db();
420        let now = current_epoch().expect("epoch failed");
421        let old_epoch = now - 200 * 86_400;
422        insert_deleted_memory(&conn, "mem-dry", "global", "dry run content", old_epoch);
423
424        let cutoff = now - 30 * 86_400;
425        let (_, _, count_before) =
426            compute_metrics(&conn, cutoff, Some("global"), None).expect("compute_metrics failed");
427        assert_eq!(count_before, 1, "must have 1 candidate before dry run");
428
429        let (_, _, count_after) =
430            compute_metrics(&conn, cutoff, Some("global"), None).expect("compute_metrics failed");
431        assert_eq!(
432            count_after, 1,
433            "dry_run must not remove records: count must remain 1"
434        );
435    }
436
437    #[test]
438    fn oldest_deleted_at_returns_smallest_epoch() {
439        let conn = setup_test_db();
440        let now = current_epoch().expect("epoch failed");
441        let epoch_old = now - 300 * 86_400;
442        let epoch_recent = now - 200 * 86_400;
443
444        insert_deleted_memory(&conn, "mem-a", "global", "body-a", epoch_old);
445        insert_deleted_memory(&conn, "mem-b", "global", "body-b", epoch_recent);
446
447        let cutoff = now - 30 * 86_400;
448        let (_, oldest, count) =
449            compute_metrics(&conn, cutoff, Some("global"), None).expect("compute_metrics failed");
450
451        assert_eq!(count, 2);
452        assert_eq!(
453            oldest,
454            Some(epoch_old),
455            "oldest_deleted_at must be the oldest epoch"
456        );
457    }
458
459    #[test]
460    fn purge_args_namespace_accepts_none_without_default() {
461        // P1-C: namespace must be None when not provided, allowing resolve_namespace
462        // to consult SQLITE_GRAPHRAG_NAMESPACE before falling back to "global".
463        // The field was `default_value = "global"` before P1-C; with that removed,
464        // resolve_namespace(None) consults the env var correctly.
465        let resolved = crate::namespace::resolve_namespace(None)
466            .expect("resolve_namespace(None) must return Ok");
467        assert_eq!(
468            resolved, "global",
469            "without env var, resolve_namespace(None) must fall back to 'global'"
470        );
471    }
472
473    #[test]
474    fn purge_response_serializes_all_new_fields() {
475        let resp = PurgeResponse {
476            action: "purged".to_string(),
477            purged_count: 3,
478            bytes_freed: 1024,
479            oldest_deleted_at: Some(1_700_000_000),
480            retention_days_used: 90,
481            dry_run: false,
482            namespace: Some("global".to_string()),
483            cutoff_epoch: 1_710_000_000,
484            warnings: vec![],
485            elapsed_ms: 42,
486            message: None,
487        };
488        let json = serde_json::to_string(&resp).expect("serialization failed");
489        assert!(json.contains("bytes_freed"));
490        assert!(json.contains("oldest_deleted_at"));
491        assert!(json.contains("retention_days_used"));
492        assert!(json.contains("dry_run"));
493        assert!(json.contains("elapsed_ms"));
494        // M2: when no purge happened, `message` is omitted to keep payloads stable.
495        assert!(!json.contains("\"message\""));
496    }
497
498    #[test]
499    fn purge_response_serializes_message_when_present() {
500        // M2 (v1.0.32): zero purges include a human-readable hint message.
501        let resp = PurgeResponse {
502            action: "purged".to_string(),
503            purged_count: 0,
504            bytes_freed: 0,
505            oldest_deleted_at: None,
506            retention_days_used: 90,
507            dry_run: false,
508            namespace: Some("global".to_string()),
509            cutoff_epoch: 1_710_000_000,
510            warnings: vec![],
511            elapsed_ms: 5,
512            message: Some(
513                "no soft-deleted memories older than 90 day(s); use --retention-days 0 to purge all soft-deleted memories regardless of age"
514                    .to_string(),
515            ),
516        };
517        let json = serde_json::to_string(&resp).expect("serialization failed");
518        assert!(json.contains("\"message\""));
519        assert!(json.contains("--retention-days 0"));
520    }
521}