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 {
28 #[arg(long)]
30 pub name: Option<String>,
31 #[arg(long)]
33 pub namespace: Option<String>,
34 #[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 #[arg(long, hide = true)]
47 pub older_than_seconds: Option<u64>,
48 #[arg(long, default_value_t = false)]
50 pub dry_run: bool,
51 #[arg(long, default_value_t = false)]
55 pub yes: bool,
56 #[arg(long, default_value_t = false)]
58 pub now: bool,
59 #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
61 pub json: bool,
62 #[arg(long)]
64 pub db: Option<String>,
65}
66
67#[derive(Serialize)]
69pub struct PurgeResponse {
70 pub action: String,
72 pub purged_count: usize,
74 pub bytes_freed: i64,
76 pub oldest_deleted_at: Option<i64>,
78 pub retention_days_used: u32,
80 pub dry_run: bool,
82 pub namespace: Option<String>,
84 pub cutoff_epoch: i64,
86 pub warnings: Vec<String>,
88 pub elapsed_ms: u64,
90 #[serde(skip_serializing_if = "Option::is_none")]
95 pub message: Option<String>,
96}
97
98pub 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 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 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 assert!(!json.contains("\"message\""));
468 }
469
470 #[test]
471 fn purge_response_serializes_message_when_present() {
472 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}