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