Skip to main content

orbok_db/repo/
cleanup.rs

1//! Cleanup execution against the catalog (RFC-001 §9, RFC-011).
2//!
3//! Every entry point takes an [`orbok_core::CleanupPlan`]; safe (ordinary)
4//! cleanup re-validates that the plan cannot touch persistent catalog
5//! data before any row is deleted. Source files on disk are never
6//! touched by any path in this module.
7
8use crate::catalog::{Catalog, db_err};
9use orbok_core::{CleanupAction, CleanupPlan, OrbokError, OrbokResult, now_iso8601};
10use rusqlite::params;
11
12/// Outcome of a cleanup run.
13#[derive(Debug, Clone, Default)]
14pub struct CleanupOutcome {
15    pub deleted_rows: u64,
16}
17
18/// Executes catalog-side cleanup. Cache-engine payload cleanup is the
19/// responsibility of `orbok-cache` (Appendix A §12), driven by the same
20/// plan at the service layer.
21pub struct CleanupExecutor<'a> {
22    catalog: &'a Catalog,
23}
24
25impl<'a> CleanupExecutor<'a> {
26    pub fn new(catalog: &'a Catalog) -> Self {
27        Self { catalog }
28    }
29
30    /// Run a *safe* cleanup action. Rejects any plan that includes the
31    /// persistent catalog class (RFC-001: "Ordinary cleanup cannot
32    /// delete persistent source settings").
33    pub fn run_safe(&self, plan: &CleanupPlan) -> OrbokResult<CleanupOutcome> {
34        plan.assert_safe_for_ordinary_cleanup()?;
35        match plan.action {
36            CleanupAction::ClearExpiredSearchCache => self.clear_expired_search_cache(),
37            CleanupAction::ClearSnippetCache => self.clear_snippet_cache(),
38            CleanupAction::ClearTemporaryExtraction => Ok(CleanupOutcome::default()),
39            CleanupAction::RemoveReplacedStaleIndexes => self.remove_replaced_stale_indexes(),
40            _ => Err(OrbokError::CleanupWouldTouchPersistentData),
41        }
42    }
43
44    /// Destructive catalog reset (RFC-001 §8.3). Requires a confirmed
45    /// `ResetCatalog` plan. Removes sources, file catalog, chunks,
46    /// indexes, caches, jobs, and search history; cascades do most of
47    /// the work. Optionally preserves settings (RFC-011/§12.4).
48    pub fn run_reset_catalog(
49        &self,
50        plan: &CleanupPlan,
51        keep_settings: bool,
52    ) -> OrbokResult<CleanupOutcome> {
53        if plan.action != CleanupAction::ResetCatalog {
54            return Err(OrbokError::Database(
55                "reset requires a ResetCatalog plan".into(),
56            ));
57        }
58        let mut conn = self.catalog.lock();
59        let tx = conn.transaction().map_err(db_err)?;
60        let mut deleted = 0u64;
61        // sources cascade to files -> extraction_records -> chunks ->
62        // chunk_locations / embeddings / keyword_index_records.
63        for table in [
64            "sources",
65            "index_jobs",
66            "search_queries",
67            "snippet_cache",
68            "app_events",
69            "storage_accounting",
70            "cache_engines",
71            "models",
72        ] {
73            deleted += tx
74                .execute(&format!("DELETE FROM {table}"), [])
75                .map_err(db_err)? as u64;
76        }
77        if !keep_settings {
78            deleted += tx.execute("DELETE FROM app_settings", []).map_err(db_err)? as u64;
79        }
80        // contentless FTS: clear via the special delete-all command.
81        tx.execute("INSERT INTO chunk_fts(chunk_fts) VALUES('delete-all')", [])
82            .map_err(db_err)?;
83        tx.commit().map_err(db_err)?;
84        Ok(CleanupOutcome {
85            deleted_rows: deleted,
86        })
87    }
88
89    fn clear_expired_search_cache(&self) -> OrbokResult<CleanupOutcome> {
90        let now = now_iso8601();
91        let conn = self.catalog.lock();
92        let mut deleted = conn
93            .execute(
94                "DELETE FROM search_result_cache WHERE expires_at IS NOT NULL AND expires_at < ?1",
95                params![now],
96            )
97            .map_err(db_err)? as u64;
98        deleted += conn
99            .execute(
100                "DELETE FROM search_queries WHERE expires_at IS NOT NULL AND expires_at < ?1",
101                params![now],
102            )
103            .map_err(db_err)? as u64;
104        Ok(CleanupOutcome {
105            deleted_rows: deleted,
106        })
107    }
108
109    fn clear_snippet_cache(&self) -> OrbokResult<CleanupOutcome> {
110        let conn = self.catalog.lock();
111        let deleted = conn
112            .execute("DELETE FROM snippet_cache", [])
113            .map_err(db_err)? as u64;
114        Ok(CleanupOutcome {
115            deleted_rows: deleted,
116        })
117    }
118
119    /// Remove index records already superseded: chunks whose status is
120    /// 'stale' or 'deleted' and that have an active replacement are safe
121    /// to drop (RFC-001 §8.1 "obsolete replaced indexes"). v1 removes
122    /// stale/deleted chunk rows whose file has at least one active chunk.
123    fn remove_replaced_stale_indexes(&self) -> OrbokResult<CleanupOutcome> {
124        let conn = self.catalog.lock();
125        let deleted = conn
126            .execute(
127                "DELETE FROM chunks WHERE chunk_status IN ('stale','deleted') AND file_id IN \
128                 (SELECT file_id FROM chunks WHERE chunk_status = 'active')",
129                [],
130            )
131            .map_err(db_err)? as u64;
132        Ok(CleanupOutcome {
133            deleted_rows: deleted,
134        })
135    }
136}