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