1use 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 #[arg(long)]
31 pub namespace: Option<String>,
32 #[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 #[arg(long, hide = true)]
45 pub older_than_seconds: Option<u64>,
46 #[arg(long, default_value_t = false)]
48 pub dry_run: bool,
49 #[arg(long, default_value_t = false)]
53 pub yes: bool,
54 #[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 pub elapsed_ms: u64,
76 #[serde(skip_serializing_if = "Option::is_none")]
81 pub message: Option<String>,
82}
83
84pub 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 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 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 assert!(!json.contains("\"message\""));
454 }
455
456 #[test]
457 fn purge_response_serializes_message_when_present() {
458 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}