Skip to main content

vfa_tui/persistence/
schema.rs

1//! SQLite schema migrations embedded as `const` strings.
2//!
3//! Each migration is a `(version, sql)` tuple. [`MIGRATIONS`] lists them in
4//! ascending order so [`IndexManager::migrate`] can apply them sequentially.
5
6/// Bootstrap: meta table that holds `schema_version` and `console_version`.
7/// Must be created before any migration can read the version.
8pub const BOOTSTRAP_SQL: &str = "
9CREATE TABLE IF NOT EXISTS schema_meta (
10    key   TEXT PRIMARY KEY,
11    value TEXT NOT NULL
12);
13";
14
15/// Migration 001: initial workspace-scan schema.
16///
17/// Creates:
18/// - `workspace_scans` — one row per workspace, keyed by path
19/// - `installed_assets` — per-asset detail rows referencing workspace_scans
20/// - `content_hashes` — SHA-256 cache for catalog files
21pub const MIGRATION_001_INITIAL_SCHEMA: &str = "
22PRAGMA journal_mode = WAL;
23PRAGMA busy_timeout = 5000;
24PRAGMA foreign_keys = ON;
25
26CREATE TABLE IF NOT EXISTS workspace_scans (
27    workspace_path     TEXT PRIMARY KEY,
28    workspace_name     TEXT NOT NULL,
29    last_scan_ts       TEXT NOT NULL,
30    scan_duration_ms   INTEGER NOT NULL,
31    asset_count        INTEGER NOT NULL,
32    fs_mtime_at_scan   INTEGER NOT NULL
33);
34
35CREATE TABLE IF NOT EXISTS installed_assets (
36    id               INTEGER PRIMARY KEY AUTOINCREMENT,
37    workspace_path   TEXT NOT NULL REFERENCES workspace_scans(workspace_path) ON DELETE CASCADE,
38    asset_id         TEXT NOT NULL,
39    asset_type       TEXT NOT NULL,
40    installed_path   TEXT NOT NULL,
41    content_hash     TEXT NOT NULL,
42    version          TEXT,
43    detection_method TEXT NOT NULL,
44    harness          TEXT NOT NULL,
45    scan_ts          TEXT NOT NULL,
46    UNIQUE(workspace_path, asset_id, harness)
47);
48
49CREATE INDEX IF NOT EXISTS idx_installed_assets_workspace
50    ON installed_assets(workspace_path);
51CREATE INDEX IF NOT EXISTS idx_installed_assets_asset_id
52    ON installed_assets(asset_id);
53
54CREATE TABLE IF NOT EXISTS content_hashes (
55    path         TEXT PRIMARY KEY,
56    hash         TEXT NOT NULL,
57    last_checked TEXT NOT NULL
58);
59";
60
61/// Migration 002: audit log with append-only triggers.
62///
63/// Creates:
64/// - `audit_log` — tamper-evident hash-chain audit records
65/// - `audit_log_no_update` trigger — rejects UPDATE
66/// - `audit_log_no_delete` trigger — rejects DELETE
67pub const MIGRATION_002_AUDIT_LOG: &str = "
68CREATE TABLE IF NOT EXISTS audit_log (
69    id          INTEGER PRIMARY KEY AUTOINCREMENT,
70    timestamp   TEXT    NOT NULL,
71    event_type  TEXT    NOT NULL,
72    subject     TEXT    NOT NULL,
73    details     TEXT    NOT NULL,
74    operator    TEXT    NOT NULL,
75    entry_hash  TEXT    NOT NULL,
76    prev_hash   TEXT    NOT NULL
77);
78
79CREATE INDEX IF NOT EXISTS idx_audit_log_timestamp
80    ON audit_log(timestamp);
81CREATE INDEX IF NOT EXISTS idx_audit_log_event_type
82    ON audit_log(event_type);
83CREATE INDEX IF NOT EXISTS idx_audit_log_subject
84    ON audit_log(subject);
85
86CREATE TRIGGER IF NOT EXISTS audit_log_no_update
87    BEFORE UPDATE ON audit_log
88BEGIN
89    SELECT RAISE(ABORT, 'audit_log is append-only: UPDATE not permitted');
90END;
91
92CREATE TRIGGER IF NOT EXISTS audit_log_no_delete
93    BEFORE DELETE ON audit_log
94BEGIN
95    SELECT RAISE(ABORT, 'audit_log is append-only: DELETE not permitted');
96END;
97";
98
99/// Migration 003: gate execution history and drift tracking.
100///
101/// Creates:
102/// - `gate_history` — per-gate execution records
103/// - `drift_history` — per-asset drift detection/resolution records
104pub const MIGRATION_003_GATE_HISTORY: &str = "
105CREATE TABLE IF NOT EXISTS gate_history (
106    id             INTEGER PRIMARY KEY AUTOINCREMENT,
107    gate_name      TEXT    NOT NULL,
108    status         TEXT    NOT NULL,
109    exit_code      INTEGER,
110    duration_ms    INTEGER NOT NULL,
111    timestamp      TEXT    NOT NULL,
112    catalog_hash   TEXT    NOT NULL,
113    output_excerpt TEXT
114);
115
116CREATE INDEX IF NOT EXISTS idx_gate_history_name
117    ON gate_history(gate_name);
118CREATE INDEX IF NOT EXISTS idx_gate_history_timestamp
119    ON gate_history(timestamp);
120
121CREATE TABLE IF NOT EXISTS drift_history (
122    id             INTEGER PRIMARY KEY AUTOINCREMENT,
123    workspace_path TEXT    NOT NULL,
124    asset_id       TEXT    NOT NULL,
125    drift_type     TEXT    NOT NULL,
126    first_detected TEXT    NOT NULL,
127    resolved_at    TEXT,
128    expected_hash  TEXT    NOT NULL,
129    actual_hash    TEXT    NOT NULL
130);
131
132CREATE INDEX IF NOT EXISTS idx_drift_workspace
133    ON drift_history(workspace_path);
134CREATE INDEX IF NOT EXISTS idx_drift_unresolved
135    ON drift_history(resolved_at)
136    WHERE resolved_at IS NULL;
137";
138
139/// Migration 004: coverage score cache (Req 3.6).
140///
141/// Creates:
142/// - `coverage_cache` — one row per workspace with its most recent coverage
143///   score and the timestamp it was computed, so the TUI/headless paths can
144///   display cached scores without recomputing on every launch.
145pub const MIGRATION_004_COVERAGE_CACHE: &str = "
146CREATE TABLE IF NOT EXISTS coverage_cache (
147    workspace_path TEXT    PRIMARY KEY,
148    workspace_name TEXT    NOT NULL,
149    coverage_score REAL    NOT NULL,
150    computed_at    TEXT    NOT NULL
151);
152
153CREATE INDEX IF NOT EXISTS idx_coverage_cache_name
154    ON coverage_cache(workspace_name);
155";
156
157/// All migrations in ascending order.
158///
159/// Each entry is `(target_schema_version, sql)`. The migration runner applies
160/// every entry whose version is greater than the current stored version.
161pub const MIGRATIONS: &[(u32, &str)] = &[
162    (1, MIGRATION_001_INITIAL_SCHEMA),
163    (2, MIGRATION_002_AUDIT_LOG),
164    (3, MIGRATION_003_GATE_HISTORY),
165    (4, MIGRATION_004_COVERAGE_CACHE),
166];
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171
172    #[test]
173    fn migrations_are_in_ascending_order() {
174        let versions: Vec<u32> = MIGRATIONS.iter().map(|(v, _)| *v).collect();
175        for window in versions.windows(2) {
176            assert!(
177                window[0] < window[1],
178                "migrations must be ordered: {versions:?}"
179            );
180        }
181    }
182
183    #[test]
184    fn bootstrap_creates_schema_meta() {
185        let conn = rusqlite::Connection::open_in_memory().unwrap();
186        conn.execute_batch(BOOTSTRAP_SQL).unwrap();
187        // Inserting a key must succeed.
188        conn.execute(
189            "INSERT OR REPLACE INTO schema_meta (key, value) VALUES ('schema_version', '0')",
190            [],
191        )
192        .unwrap();
193        let v: String = conn
194            .query_row(
195                "SELECT value FROM schema_meta WHERE key = 'schema_version'",
196                [],
197                |row| row.get(0),
198            )
199            .unwrap();
200        assert_eq!(v, "0");
201    }
202
203    #[test]
204    fn migration_001_creates_expected_tables() {
205        let conn = rusqlite::Connection::open_in_memory().unwrap();
206        conn.execute_batch(BOOTSTRAP_SQL).unwrap();
207        conn.execute_batch(MIGRATION_001_INITIAL_SCHEMA).unwrap();
208        // workspace_scans exists
209        conn.execute(
210            "INSERT INTO workspace_scans \
211             (workspace_path, workspace_name, last_scan_ts, scan_duration_ms, asset_count, fs_mtime_at_scan) \
212             VALUES ('/tmp/ws', 'ws', '2025-01-01T00:00:00Z', 100, 5, 1234567890)",
213            [],
214        )
215        .unwrap();
216        // installed_assets exists and references workspace_scans
217        conn.execute(
218            "INSERT INTO installed_assets \
219             (workspace_path, asset_id, asset_type, installed_path, content_hash, \
220              detection_method, harness, scan_ts) \
221             VALUES ('/tmp/ws', 'agent-1', 'agent', '/tmp/ws/.claude/agents/a.md', \
222                     'hash', 'directory_scan', 'claude', '2025-01-01T00:00:00Z')",
223            [],
224        )
225        .unwrap();
226    }
227
228    #[test]
229    fn migration_002_append_only_triggers() {
230        let conn = rusqlite::Connection::open_in_memory().unwrap();
231        conn.execute_batch(BOOTSTRAP_SQL).unwrap();
232        conn.execute_batch(MIGRATION_001_INITIAL_SCHEMA).unwrap();
233        conn.execute_batch(MIGRATION_002_AUDIT_LOG).unwrap();
234
235        // Insert succeeds.
236        conn.execute(
237            "INSERT INTO audit_log \
238             (timestamp, event_type, subject, details, operator, entry_hash, prev_hash) \
239             VALUES ('2025-01-01T00:00:00.000Z', 'policy_evaluation', 'ws', '{}', \
240                     'system', 'hash1', '')",
241            [],
242        )
243        .unwrap();
244
245        // UPDATE must be rejected.
246        let update_result =
247            conn.execute("UPDATE audit_log SET subject = 'tampered' WHERE id = 1", []);
248        assert!(
249            update_result.is_err(),
250            "UPDATE should be rejected by trigger"
251        );
252
253        // DELETE must be rejected.
254        let delete_result = conn.execute("DELETE FROM audit_log WHERE id = 1", []);
255        assert!(
256            delete_result.is_err(),
257            "DELETE should be rejected by trigger"
258        );
259    }
260
261    #[test]
262    fn migration_003_creates_gate_history() {
263        let conn = rusqlite::Connection::open_in_memory().unwrap();
264        conn.execute_batch(BOOTSTRAP_SQL).unwrap();
265        conn.execute_batch(MIGRATION_001_INITIAL_SCHEMA).unwrap();
266        conn.execute_batch(MIGRATION_002_AUDIT_LOG).unwrap();
267        conn.execute_batch(MIGRATION_003_GATE_HISTORY).unwrap();
268
269        conn.execute(
270            "INSERT INTO gate_history \
271             (gate_name, status, exit_code, duration_ms, timestamp, catalog_hash) \
272             VALUES ('lint', 'pass', 0, 1200, '2025-01-01T00:00:00Z', 'chash')",
273            [],
274        )
275        .unwrap();
276    }
277}