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 (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`.")]
27pub struct PurgeArgs {
29 #[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 #[arg(long)]
51 pub name: Option<String>,
52 #[arg(long)]
54 pub namespace: Option<String>,
55 #[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 #[arg(long, hide = true)]
68 pub older_than_seconds: Option<u64>,
69 #[arg(long, default_value_t = false)]
71 pub dry_run: bool,
72 #[arg(long, default_value_t = false)]
76 pub yes: bool,
77 #[arg(long, default_value_t = false)]
79 pub now: bool,
80 #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
82 pub json: bool,
83 #[arg(long)]
85 pub db: Option<String>,
86}
87
88#[derive(Serialize)]
90pub struct PurgeResponse {
91 pub action: String,
93 pub purged_count: usize,
95 pub bytes_freed: i64,
97 pub oldest_deleted_at: Option<i64>,
99 pub retention_days_used: u32,
101 pub dry_run: bool,
103 pub namespace: Option<String>,
105 pub cutoff_epoch: i64,
107 pub warnings: Vec<String>,
109 pub elapsed_ms: u64,
111 #[serde(skip_serializing_if = "Option::is_none")]
116 pub message: Option<String>,
117}
118
119pub fn run(args: PurgeArgs) -> Result<(), AppError> {
124 let started = std::time::Instant::now();
125
126 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 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 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 assert!(!json.contains("\"message\""));
496 }
497
498 #[test]
499 fn purge_response_serializes_message_when_present() {
500 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}