Skip to main content

aft/db/
mod.rs

1use rusqlite::{Connection, OpenFlags, TransactionBehavior};
2use std::fmt;
3use std::fs;
4use std::path::Path;
5use std::time::Duration;
6
7pub mod backups;
8pub mod bash_tasks;
9pub mod bash_watches;
10pub mod compression_events;
11pub mod removal;
12pub mod standing_roots;
13pub mod state;
14
15pub const CURRENT_SCHEMA_VERSION: u32 = 8;
16
17const MIGRATION_V1: &str = r#"
18CREATE TABLE IF NOT EXISTS schema_version (
19  version INTEGER NOT NULL PRIMARY KEY
20);
21
22CREATE TABLE IF NOT EXISTS bash_tasks (
23  harness      TEXT NOT NULL,
24  session_id   TEXT NOT NULL,
25  task_id      TEXT NOT NULL,
26  project_key  TEXT NOT NULL,
27  command      TEXT NOT NULL,
28  cwd          TEXT NOT NULL,
29  status       TEXT NOT NULL,
30  exit_code    INTEGER,
31  pid          INTEGER,
32  pgid         INTEGER,
33  started_at   INTEGER NOT NULL,
34  completed_at INTEGER,
35  stdout_path  TEXT,
36  stderr_path  TEXT,
37  compressed   INTEGER NOT NULL DEFAULT 1,
38  timeout_ms   INTEGER,
39  completion_delivered INTEGER NOT NULL DEFAULT 0,
40  output_bytes INTEGER,
41  metadata     TEXT,
42  PRIMARY KEY (harness, session_id, task_id)
43);
44CREATE INDEX IF NOT EXISTS idx_bash_tasks_project_key ON bash_tasks(project_key);
45CREATE INDEX IF NOT EXISTS idx_bash_tasks_status      ON bash_tasks(status);
46CREATE INDEX IF NOT EXISTS idx_bash_tasks_session_status ON bash_tasks(harness, session_id, status);
47
48CREATE TABLE IF NOT EXISTS compression_events (
49  id                INTEGER PRIMARY KEY AUTOINCREMENT,
50  harness           TEXT NOT NULL,
51  session_id        TEXT,
52  project_key       TEXT NOT NULL,
53  tool              TEXT NOT NULL,
54  task_id           TEXT,
55  command           TEXT,
56  compressor        TEXT NOT NULL,
57  original_bytes    INTEGER NOT NULL,
58  compressed_bytes  INTEGER NOT NULL,
59  original_tokens   INTEGER NOT NULL,
60  compressed_tokens INTEGER NOT NULL,
61  created_at        INTEGER NOT NULL
62);
63CREATE INDEX IF NOT EXISTS idx_compression_session         ON compression_events(harness, session_id);
64CREATE INDEX IF NOT EXISTS idx_compression_session_created ON compression_events(harness, session_id, created_at);
65CREATE INDEX IF NOT EXISTS idx_compression_project_key     ON compression_events(project_key);
66
67CREATE TABLE IF NOT EXISTS backups (
68  id            INTEGER PRIMARY KEY AUTOINCREMENT,
69  backup_id     TEXT,
70  harness       TEXT NOT NULL,
71  session_id    TEXT NOT NULL,
72  project_key   TEXT NOT NULL,
73  op_id         TEXT,
74  order_blob    BLOB NOT NULL,
75  file_path     TEXT NOT NULL,
76  path_hash     TEXT NOT NULL,
77  backup_path   TEXT,
78  kind          TEXT NOT NULL,
79  description   TEXT,
80  created_at    INTEGER NOT NULL,
81  is_tombstone  INTEGER NOT NULL DEFAULT 0
82);
83CREATE INDEX IF NOT EXISTS idx_backups_session_path  ON backups(harness, session_id, path_hash);
84CREATE INDEX IF NOT EXISTS idx_backups_session_op    ON backups(harness, session_id, op_id) WHERE op_id IS NOT NULL;
85CREATE INDEX IF NOT EXISTS idx_backups_session_order ON backups(harness, session_id, order_blob DESC);
86CREATE INDEX IF NOT EXISTS idx_backups_session_path_order ON backups(harness, session_id, path_hash, order_blob DESC);
87
88CREATE TABLE IF NOT EXISTS harness_state (
89  harness    TEXT NOT NULL,
90  key        TEXT NOT NULL,
91  value      TEXT NOT NULL,
92  updated_at INTEGER NOT NULL,
93  PRIMARY KEY (harness, key)
94);
95
96CREATE TABLE IF NOT EXISTS host_state (
97  key        TEXT NOT NULL PRIMARY KEY,
98  value      TEXT NOT NULL,
99  updated_at INTEGER NOT NULL
100);
101"#;
102
103const MIGRATION_V2: &str = r#"
104DELETE FROM compression_events
105WHERE id NOT IN (
106  SELECT MIN(id)
107  FROM compression_events
108  GROUP BY
109    harness,
110    COALESCE(session_id, char(0)),
111    project_key,
112    tool,
113    COALESCE(task_id, char(0))
114);
115
116CREATE UNIQUE INDEX IF NOT EXISTS idx_compression_event_identity
117ON compression_events (
118  harness,
119  COALESCE(session_id, char(0)),
120  project_key,
121  tool,
122  COALESCE(task_id, char(0))
123);
124"#;
125
126const MIGRATION_V3: &str = r#"
127CREATE INDEX IF NOT EXISTS idx_bash_tasks_project_lookup
128ON bash_tasks (harness, project_key, task_id, started_at DESC);
129"#;
130
131// V4 adds the restore_meta column to backups (Unix mode / created_dirs /
132// link_target for DB-fallback restores when the meta.json sidecar is gone).
133const MIGRATION_V4: &str = r#"
134ALTER TABLE backups ADD COLUMN restore_meta TEXT;
135"#;
136
137// V5 persists async bash_notify / bash_watch pattern registrations so a
138// bridge/daemon restart can re-arm watches and deliver gap matches.
139const MIGRATION_V5: &str = r#"
140CREATE TABLE IF NOT EXISTS bash_pattern_watches (
141  harness        TEXT NOT NULL,
142  session_id     TEXT NOT NULL,
143  task_id        TEXT NOT NULL,
144  watch_id       TEXT NOT NULL,
145  pattern_kind   TEXT NOT NULL,
146  pattern        TEXT NOT NULL,
147  once           INTEGER NOT NULL DEFAULT 1,
148  created_at     INTEGER NOT NULL,
149  stdout_offset  INTEGER NOT NULL DEFAULT 0,
150  stderr_offset  INTEGER NOT NULL DEFAULT 0,
151  pty_offset     INTEGER NOT NULL DEFAULT 0,
152  scanning       INTEGER NOT NULL DEFAULT 1,
153  pending_match  INTEGER NOT NULL DEFAULT 0,
154  match_text     TEXT,
155  match_offset   INTEGER,
156  match_context  TEXT,
157  PRIMARY KEY (harness, session_id, task_id, watch_id)
158);
159CREATE INDEX IF NOT EXISTS idx_bash_pattern_watches_session
160  ON bash_pattern_watches (harness, session_id);
161CREATE INDEX IF NOT EXISTS idx_bash_pattern_watches_task
162  ON bash_pattern_watches (harness, session_id, task_id);
163"#;
164
165// Removal-time health reads the existing task and backup tables. These indexes
166// keep its seven-day aggregation and non-terminal task lookup off full history.
167const MIGRATION_V6: &str = r#"
168CREATE INDEX IF NOT EXISTS idx_bash_tasks_started_activity
169  ON bash_tasks (started_at, project_key, harness, session_id);
170CREATE INDEX IF NOT EXISTS idx_bash_tasks_non_terminal_pid
171  ON bash_tasks (pid)
172  WHERE status NOT IN ('completed', 'failed', 'killed', 'timed_out');
173CREATE INDEX IF NOT EXISTS idx_backups_created_activity
174  ON backups (created_at, project_key, harness, session_id);
175"#;
176
177// Standing roots are deliberately machine-scoped. Do not add harness, session,
178// or daemon columns: daemon and daemonless CLI share one durable path pin.
179const MIGRATION_V7: &str = r#"
180CREATE TABLE IF NOT EXISTS standing_roots (
181  literal_path           TEXT NOT NULL PRIMARY KEY,
182  resolved_target        TEXT NOT NULL,
183  resolved_git_toplevel  TEXT,
184  scoped_relative_path   TEXT
185);
186
187CREATE TABLE IF NOT EXISTS standing_root_freshness (
188  literal_path          TEXT NOT NULL,
189  index_kind            TEXT NOT NULL CHECK (index_kind IN ('search', 'semantic', 'callgraph')),
190  needs_strict_verify   INTEGER NOT NULL CHECK (needs_strict_verify IN (0, 1)),
191  strict_verified_at    INTEGER,
192  PRIMARY KEY (literal_path, index_kind),
193  FOREIGN KEY (literal_path) REFERENCES standing_roots(literal_path) ON DELETE CASCADE
194);
195CREATE INDEX IF NOT EXISTS idx_standing_root_freshness_needs_verify
196  ON standing_root_freshness (needs_strict_verify, literal_path);
197"#;
198
199// Fate-unknown tasks are terminal but deliberately distinct from command failure:
200// the daemon cannot reconstruct an exit result after finding the recorded process dead.
201const MIGRATION_V8: &str = r#"
202DROP INDEX IF EXISTS idx_bash_tasks_non_terminal_pid;
203CREATE INDEX idx_bash_tasks_non_terminal_pid
204  ON bash_tasks (pid)
205  WHERE status NOT IN ('completed', 'failed', 'killed', 'timed_out', 'fate_unknown');
206"#;
207
208#[derive(Debug)]
209pub enum OpenError {
210    Io(std::io::Error),
211    Sqlite(rusqlite::Error),
212    DowngradeRefused {
213        db_version: u32,
214        supported: u32,
215    },
216    MigrationFailed {
217        from: u32,
218        to: u32,
219        error: rusqlite::Error,
220    },
221}
222
223impl fmt::Display for OpenError {
224    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
225        match self {
226            OpenError::Io(error) => write!(f, "database I/O error: {error}"),
227            OpenError::Sqlite(error) => write!(f, "sqlite error: {error}"),
228            OpenError::DowngradeRefused {
229                db_version,
230                supported,
231            } => write!(
232                f,
233                "database schema version {db_version} is newer than supported version {supported}"
234            ),
235            OpenError::MigrationFailed { from, to, error } => {
236                write!(f, "database migration {from}->{to} failed: {error}")
237            }
238        }
239    }
240}
241
242impl std::error::Error for OpenError {}
243
244impl From<std::io::Error> for OpenError {
245    fn from(error: std::io::Error) -> Self {
246        OpenError::Io(error)
247    }
248}
249
250impl From<rusqlite::Error> for OpenError {
251    fn from(error: rusqlite::Error) -> Self {
252        OpenError::Sqlite(error)
253    }
254}
255
256/// Open or create the AFT SQLite database at the given path.
257///
258/// Applies per-connection PRAGMAs, runs schema migrations from the DB's
259/// current schema version up to [`CURRENT_SCHEMA_VERSION`], and returns the
260/// configured connection.
261pub fn open(path: &Path) -> Result<Connection, OpenError> {
262    if let Some(parent) = path.parent() {
263        if !parent.as_os_str().is_empty() {
264            fs::create_dir_all(parent)?;
265        }
266    }
267
268    let mut conn = Connection::open(path)?;
269    apply_pragmas(&conn)?;
270    run_migrations(&mut conn)?;
271    Ok(conn)
272}
273
274/// Open an existing AFT database without creating, migrating, or mutating it.
275///
276/// Doctor uses this path for removal-time reporting, so checking state cannot
277/// itself create an AFT database or race a running bridge's write transaction.
278pub fn open_readonly(path: &Path) -> Result<Connection, OpenError> {
279    let conn = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
280    conn.busy_timeout(Duration::from_secs(5))?;
281    Ok(conn)
282}
283
284/// Apply the per-connection PRAGMAs required for every AFT SQLite connection.
285pub fn apply_pragmas(conn: &Connection) -> Result<(), rusqlite::Error> {
286    conn.pragma_update(None, "foreign_keys", "ON")?;
287    // Set the wait policy before WAL can acquire its journal lock. Otherwise a
288    // concurrently opening daemon may fail immediately instead of honoring it.
289    conn.pragma_update(None, "busy_timeout", 5000)?;
290    conn.pragma_update(None, "journal_mode", "WAL")?;
291    conn.pragma_update(None, "synchronous", "NORMAL")?;
292    Ok(())
293}
294
295/// Run forward-only migrations up to [`CURRENT_SCHEMA_VERSION`].
296///
297/// Returns the post-migration schema version. Refuses to open databases created
298/// by newer AFT versions.
299pub fn run_migrations(conn: &mut Connection) -> Result<u32, OpenError> {
300    conn.execute_batch(
301        "CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL PRIMARY KEY);",
302    )?;
303
304    let db_version = current_schema_version(conn)?;
305    if db_version > CURRENT_SCHEMA_VERSION {
306        return Err(OpenError::DowngradeRefused {
307            db_version,
308            supported: CURRENT_SCHEMA_VERSION,
309        });
310    }
311
312    for version in (db_version + 1)..=CURRENT_SCHEMA_VERSION {
313        apply_migration(conn, version)?;
314    }
315
316    Ok(current_schema_version(conn)?)
317}
318
319fn current_schema_version(conn: &Connection) -> Result<u32, rusqlite::Error> {
320    conn.query_row(
321        "SELECT COALESCE(MAX(version), 0) FROM schema_version",
322        [],
323        |row| row.get::<_, u32>(0),
324    )
325}
326
327fn apply_migration(conn: &mut Connection, version: u32) -> Result<(), OpenError> {
328    let from = version - 1;
329    let tx = conn
330        .transaction_with_behavior(TransactionBehavior::Immediate)
331        .map_err(|error| OpenError::MigrationFailed {
332            from,
333            to: version,
334            error,
335        })?;
336
337    let result = match version {
338        1 => tx.execute_batch(MIGRATION_V1),
339        2 => tx.execute_batch(MIGRATION_V2),
340        3 => tx.execute_batch(MIGRATION_V3),
341        4 => apply_migration_v4(&tx),
342        5 => tx.execute_batch(MIGRATION_V5),
343        6 => tx.execute_batch(MIGRATION_V6),
344        7 => tx.execute_batch(MIGRATION_V7),
345        8 => tx.execute_batch(MIGRATION_V8),
346        _ => Ok(()),
347    }
348    .and_then(|()| {
349        tx.execute("DELETE FROM schema_version", [])?;
350        tx.execute(
351            "INSERT OR REPLACE INTO schema_version (version) VALUES (?1)",
352            [version],
353        )?;
354        tx.commit()
355    });
356
357    result.map_err(|error| OpenError::MigrationFailed {
358        from,
359        to: version,
360        error,
361    })
362}
363
364fn apply_migration_v4(conn: &Connection) -> rusqlite::Result<()> {
365    let mut stmt = conn.prepare("PRAGMA table_info(backups)")?;
366    let columns = stmt
367        .query_map([], |row| row.get::<_, String>(1))?
368        .collect::<rusqlite::Result<Vec<_>>>()?;
369    drop(stmt);
370
371    if !columns.iter().any(|column| column == "restore_meta") {
372        conn.execute_batch(MIGRATION_V4)?;
373    }
374    Ok(())
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380    use rusqlite::params;
381    use tempfile::tempdir;
382
383    const EXPECTED_TABLES: &[&str] = &[
384        "schema_version",
385        "bash_tasks",
386        "bash_pattern_watches",
387        "compression_events",
388        "backups",
389        "harness_state",
390        "host_state",
391        "standing_roots",
392        "standing_root_freshness",
393    ];
394
395    const EXPECTED_INDEXES: &[&str] = &[
396        "idx_bash_tasks_project_key",
397        "idx_bash_tasks_status",
398        "idx_bash_tasks_session_status",
399        "idx_bash_tasks_project_lookup",
400        "idx_bash_pattern_watches_session",
401        "idx_bash_pattern_watches_task",
402        "idx_bash_tasks_started_activity",
403        "idx_bash_tasks_non_terminal_pid",
404        "idx_backups_created_activity",
405        "idx_compression_session",
406        "idx_compression_session_created",
407        "idx_compression_project_key",
408        "idx_compression_event_identity",
409        "idx_backups_session_path",
410        "idx_backups_session_op",
411        "idx_backups_session_order",
412        "idx_backups_session_path_order",
413        "idx_standing_root_freshness_needs_verify",
414    ];
415
416    #[test]
417    fn open_fresh_db_creates_all_tables() {
418        let dir = tempdir().unwrap();
419        let conn = open(&dir.path().join("aft.db")).unwrap();
420
421        let tables = sqlite_names(&conn, "table");
422        for table in EXPECTED_TABLES {
423            assert!(tables.contains(&table.to_string()), "missing table {table}");
424        }
425    }
426
427    #[test]
428    fn open_fresh_db_creates_all_indexes() {
429        let dir = tempdir().unwrap();
430        let conn = open(&dir.path().join("aft.db")).unwrap();
431
432        let indexes = sqlite_names(&conn, "index");
433        for index in EXPECTED_INDEXES {
434            assert!(
435                indexes.contains(&index.to_string()),
436                "missing index {index}"
437            );
438        }
439    }
440
441    #[test]
442    fn open_existing_db_is_idempotent() {
443        let dir = tempdir().unwrap();
444        let path = dir.path().join("aft.db");
445
446        let conn = open(&path).unwrap();
447        let first_version = schema_version(&conn);
448        drop(conn);
449
450        let conn = open(&path).unwrap();
451        assert_eq!(schema_version(&conn), first_version);
452    }
453
454    #[test]
455    fn pragmas_applied_correctly() {
456        let dir = tempdir().unwrap();
457        let conn = open(&dir.path().join("aft.db")).unwrap();
458
459        let foreign_keys: i64 = conn
460            .query_row("PRAGMA foreign_keys", [], |row| row.get(0))
461            .unwrap();
462        let journal_mode: String = conn
463            .query_row("PRAGMA journal_mode", [], |row| row.get(0))
464            .unwrap();
465        let busy_timeout: i64 = conn
466            .query_row("PRAGMA busy_timeout", [], |row| row.get(0))
467            .unwrap();
468        let synchronous: i64 = conn
469            .query_row("PRAGMA synchronous", [], |row| row.get(0))
470            .unwrap();
471
472        assert_eq!(foreign_keys, 1);
473        assert_eq!(journal_mode, "wal");
474        assert_eq!(busy_timeout, 5000);
475        assert_eq!(synchronous, 1);
476    }
477
478    #[test]
479    fn downgrade_refused() {
480        let dir = tempdir().unwrap();
481        let path = dir.path().join("aft.db");
482        let conn = open(&path).unwrap();
483        conn.execute("INSERT OR REPLACE INTO schema_version VALUES (999)", [])
484            .unwrap();
485        drop(conn);
486
487        match open(&path).unwrap_err() {
488            OpenError::DowngradeRefused {
489                db_version,
490                supported,
491            } => {
492                assert_eq!(db_version, 999);
493                assert_eq!(supported, CURRENT_SCHEMA_VERSION);
494            }
495            error => panic!("expected downgrade refusal, got {error:?}"),
496        }
497    }
498
499    #[test]
500    fn migration_runner_advances_version() {
501        let dir = tempdir().unwrap();
502        let conn = open(&dir.path().join("aft.db")).unwrap();
503
504        assert_eq!(schema_version(&conn), CURRENT_SCHEMA_VERSION);
505    }
506
507    #[test]
508    fn migration_v2_deduplicates_compression_events_and_adds_unique_index() {
509        let dir = tempdir().unwrap();
510        let path = dir.path().join("aft.db");
511
512        let conn = Connection::open(&path).unwrap();
513        conn.execute_batch(MIGRATION_V1).unwrap();
514        conn.execute("DELETE FROM schema_version", []).unwrap();
515        conn.execute("INSERT INTO schema_version (version) VALUES (1)", [])
516            .unwrap();
517        insert_compression_event(
518            &conn,
519            1,
520            "opencode",
521            Some("session-1"),
522            "project-key",
523            "bash",
524            Some("task-1"),
525        )
526        .unwrap();
527        insert_compression_event(
528            &conn,
529            2,
530            "opencode",
531            Some("session-1"),
532            "project-key",
533            "bash",
534            Some("task-1"),
535        )
536        .unwrap();
537        insert_compression_event(&conn, 3, "opencode", None, "project-key", "bash", None).unwrap();
538        drop(conn);
539
540        let conn = open(&path).unwrap();
541
542        assert_eq!(schema_version(&conn), CURRENT_SCHEMA_VERSION);
543        let ids = compression_event_ids(&conn);
544        assert_eq!(ids, vec![1, 3]);
545        let indexes = sqlite_names(&conn, "index");
546        assert!(
547            indexes.contains(&"idx_compression_event_identity".to_string()),
548            "missing v2 unique compression event identity index"
549        );
550        assert_unique_constraint(insert_compression_event(
551            &conn,
552            4,
553            "opencode",
554            Some("session-1"),
555            "project-key",
556            "bash",
557            Some("task-1"),
558        ));
559    }
560
561    #[test]
562    fn migration_v3_upgrades_existing_v2_database() {
563        let dir = tempdir().unwrap();
564        let path = dir.path().join("aft.db");
565        let conn = Connection::open(&path).unwrap();
566        conn.execute_batch(MIGRATION_V1).unwrap();
567        conn.execute_batch(MIGRATION_V2).unwrap();
568        conn.execute("DELETE FROM schema_version", []).unwrap();
569        conn.execute("INSERT INTO schema_version (version) VALUES (2)", [])
570            .unwrap();
571        drop(conn);
572
573        let conn = open(&path).unwrap();
574
575        // A v2 database migrates all the way to the current version; V3 creates
576        // the bash-task lookup index on the way.
577        assert_eq!(schema_version(&conn), CURRENT_SCHEMA_VERSION);
578        assert!(sqlite_names(&conn, "index").contains(&"idx_bash_tasks_project_lookup".to_string()));
579    }
580
581    #[test]
582    fn bash_task_project_lookup_uses_composite_filter_and_order_index() {
583        let dir = tempdir().unwrap();
584        let conn = open(&dir.path().join("aft.db")).unwrap();
585        let mut statement = conn
586            .prepare(
587                "EXPLAIN QUERY PLAN
588                 SELECT harness, session_id, task_id, project_key, command, cwd, status,
589                        exit_code, pid, pgid, started_at, completed_at, stdout_path, stderr_path,
590                        compressed, timeout_ms, completion_delivered, output_bytes, metadata
591                 FROM bash_tasks
592                 WHERE harness = ?1 AND project_key = ?2 AND task_id = ?3
593                 ORDER BY started_at DESC
594                 LIMIT 1",
595            )
596            .unwrap();
597        let plan = statement
598            .query_map(params!["opencode", "project-key", "bash-task"], |row| {
599                row.get::<_, String>(3)
600            })
601            .unwrap()
602            .collect::<Result<Vec<_>, _>>()
603            .unwrap();
604
605        assert!(
606            plan.iter()
607                .any(|detail| detail.contains("idx_bash_tasks_project_lookup")),
608            "lookup plan did not use the composite index: {plan:?}"
609        );
610        assert!(
611            plan.iter()
612                .all(|detail| !detail.contains("USE TEMP B-TREE FOR ORDER BY")),
613            "lookup plan still sorts into a temporary B-tree: {plan:?}"
614        );
615    }
616
617    #[test]
618    fn migration_v4_adds_restore_metadata_to_v2_and_v3_databases() {
619        for initial_version in [2, 3] {
620            let dir = tempdir().unwrap();
621            let path = dir.path().join(format!("aft-v{initial_version}.db"));
622            let conn = Connection::open(&path).unwrap();
623            conn.execute_batch(MIGRATION_V1).unwrap();
624            conn.execute_batch(MIGRATION_V2).unwrap();
625            conn.execute("DELETE FROM schema_version", []).unwrap();
626            conn.execute(
627                "INSERT INTO schema_version (version) VALUES (?1)",
628                [initial_version],
629            )
630            .unwrap();
631            insert_backup(&conn, "legacy", &order_blob(1)).unwrap();
632            drop(conn);
633
634            let conn = open(&path).unwrap();
635
636            assert_eq!(schema_version(&conn), CURRENT_SCHEMA_VERSION);
637            assert!(table_columns(&conn, "backups").contains(&"restore_meta".to_string()));
638            let restore_meta: Option<String> = conn
639                .query_row(
640                    "SELECT restore_meta FROM backups WHERE backup_id = 'legacy'",
641                    [],
642                    |row| row.get(0),
643                )
644                .unwrap();
645            assert_eq!(restore_meta, None, "legacy rows stay nullable");
646        }
647    }
648
649    #[test]
650    fn migration_v4_is_idempotent_when_column_already_exists() {
651        let dir = tempdir().unwrap();
652        let path = dir.path().join("aft.db");
653        let conn = Connection::open(&path).unwrap();
654        conn.execute_batch(MIGRATION_V1).unwrap();
655        conn.execute_batch(MIGRATION_V2).unwrap();
656        conn.execute_batch(MIGRATION_V4).unwrap();
657        conn.execute("DELETE FROM schema_version", []).unwrap();
658        conn.execute("INSERT INTO schema_version (version) VALUES (3)", [])
659            .unwrap();
660        drop(conn);
661
662        let conn = open(&path).unwrap();
663
664        assert_eq!(schema_version(&conn), CURRENT_SCHEMA_VERSION);
665        assert_eq!(
666            table_columns(&conn, "backups")
667                .iter()
668                .filter(|column| column.as_str() == "restore_meta")
669                .count(),
670            1
671        );
672    }
673
674    #[test]
675    fn migration_v5_adds_bash_pattern_watches_table() {
676        let dir = tempdir().unwrap();
677        let path = dir.path().join("aft.db");
678        let conn = Connection::open(&path).unwrap();
679        conn.execute_batch(MIGRATION_V1).unwrap();
680        conn.execute_batch(MIGRATION_V2).unwrap();
681        conn.execute_batch(MIGRATION_V3).unwrap();
682        conn.execute_batch(MIGRATION_V4).unwrap();
683        conn.execute("DELETE FROM schema_version", []).unwrap();
684        conn.execute("INSERT INTO schema_version (version) VALUES (4)", [])
685            .unwrap();
686        drop(conn);
687
688        let conn = open(&path).unwrap();
689
690        assert_eq!(schema_version(&conn), CURRENT_SCHEMA_VERSION);
691        assert!(sqlite_names(&conn, "table").contains(&"bash_pattern_watches".to_string()));
692        assert!(sqlite_names(&conn, "index").contains(&"idx_bash_pattern_watches_task".to_string()));
693    }
694
695    #[test]
696    fn migration_v7_adds_machine_scoped_standing_root_tables() {
697        let dir = tempdir().unwrap();
698        let path = dir.path().join("aft.db");
699        let conn = Connection::open(&path).unwrap();
700        conn.execute_batch(MIGRATION_V1).unwrap();
701        conn.execute_batch(MIGRATION_V2).unwrap();
702        conn.execute_batch(MIGRATION_V3).unwrap();
703        conn.execute_batch(MIGRATION_V4).unwrap();
704        conn.execute_batch(MIGRATION_V5).unwrap();
705        conn.execute_batch(MIGRATION_V6).unwrap();
706        conn.execute("DELETE FROM schema_version", []).unwrap();
707        conn.execute("INSERT INTO schema_version (version) VALUES (6)", [])
708            .unwrap();
709        drop(conn);
710
711        let conn = open(&path).unwrap();
712        assert_eq!(schema_version(&conn), CURRENT_SCHEMA_VERSION);
713        assert!(sqlite_names(&conn, "table").contains(&"standing_roots".to_string()));
714        assert!(sqlite_names(&conn, "table").contains(&"standing_root_freshness".to_string()));
715        assert!(sqlite_names(&conn, "index")
716            .contains(&"idx_standing_root_freshness_needs_verify".to_string()));
717    }
718
719    #[test]
720    fn migration_v6_adds_removal_health_indexes() {
721        let dir = tempdir().unwrap();
722        let path = dir.path().join("aft.db");
723        let conn = Connection::open(&path).unwrap();
724        conn.execute_batch(MIGRATION_V1).unwrap();
725        conn.execute_batch(MIGRATION_V2).unwrap();
726        conn.execute_batch(MIGRATION_V3).unwrap();
727        conn.execute_batch(MIGRATION_V4).unwrap();
728        conn.execute_batch(MIGRATION_V5).unwrap();
729        conn.execute("DELETE FROM schema_version", []).unwrap();
730        conn.execute("INSERT INTO schema_version (version) VALUES (5)", [])
731            .unwrap();
732        drop(conn);
733
734        let conn = open(&path).unwrap();
735
736        let indexes = sqlite_names(&conn, "index");
737        for index in [
738            "idx_bash_tasks_started_activity",
739            "idx_bash_tasks_non_terminal_pid",
740            "idx_backups_created_activity",
741        ] {
742            assert!(
743                indexes.contains(&index.to_string()),
744                "missing v6 index {index}"
745            );
746        }
747    }
748
749    #[test]
750    fn open_readonly_does_not_create_a_missing_database() {
751        let dir = tempdir().unwrap();
752        let path = dir.path().join("missing-aft.db");
753
754        assert!(open_readonly(&path).is_err());
755        assert!(!path.exists());
756    }
757
758    #[test]
759    fn migration_runner_no_op_when_current() {
760        let dir = tempdir().unwrap();
761        let path = dir.path().join("aft.db");
762
763        let conn = open(&path).unwrap();
764        assert_eq!(schema_version_row_count(&conn), 1);
765        drop(conn);
766
767        let conn = open(&path).unwrap();
768        assert_eq!(schema_version(&conn), CURRENT_SCHEMA_VERSION);
769        assert_eq!(schema_version_row_count(&conn), 1);
770    }
771
772    #[test]
773    fn harness_state_compound_pk_works() {
774        let dir = tempdir().unwrap();
775        let conn = open(&dir.path().join("aft.db")).unwrap();
776
777        conn.execute(
778            "INSERT INTO harness_state (harness, key, value, updated_at) VALUES (?1, ?2, ?3, ?4)",
779            params!["opencode", "warned_tools", "{}", 1_i64],
780        )
781        .unwrap();
782        let duplicate = conn.execute(
783            "INSERT INTO harness_state (harness, key, value, updated_at) VALUES (?1, ?2, ?3, ?4)",
784            params!["opencode", "warned_tools", "{}", 2_i64],
785        );
786        assert_unique_constraint(duplicate);
787
788        conn.execute(
789            "INSERT INTO harness_state (harness, key, value, updated_at) VALUES (?1, ?2, ?3, ?4)",
790            params!["pi", "warned_tools", "{}", 3_i64],
791        )
792        .unwrap();
793    }
794
795    #[test]
796    fn host_state_simple_pk_works() {
797        let dir = tempdir().unwrap();
798        let conn = open(&dir.path().join("aft.db")).unwrap();
799
800        conn.execute(
801            "INSERT INTO host_state (key, value, updated_at) VALUES (?1, ?2, ?3)",
802            params!["trusted_filter_projects", "[]", 1_i64],
803        )
804        .unwrap();
805        let duplicate = conn.execute(
806            "INSERT INTO host_state (key, value, updated_at) VALUES (?1, ?2, ?3)",
807            params!["trusted_filter_projects", "[]", 2_i64],
808        );
809        assert_unique_constraint(duplicate);
810    }
811
812    #[test]
813    fn bash_tasks_compound_pk_works() {
814        let dir = tempdir().unwrap();
815        let conn = open(&dir.path().join("aft.db")).unwrap();
816
817        insert_bash_task(&conn, "opencode", "session-1", "bash-12345678").unwrap();
818        let duplicate = insert_bash_task(&conn, "opencode", "session-1", "bash-12345678");
819        assert_unique_constraint(duplicate);
820
821        insert_bash_task(&conn, "pi", "session-1", "bash-12345678").unwrap();
822    }
823
824    #[test]
825    fn backups_order_blob_sort() {
826        let dir = tempdir().unwrap();
827        let conn = open(&dir.path().join("aft.db")).unwrap();
828
829        let one = order_blob(1);
830        let two = order_blob(2);
831        let max = [0xFF; 16];
832
833        insert_backup(&conn, "one", &one).unwrap();
834        insert_backup(&conn, "two", &two).unwrap();
835        insert_backup(&conn, "max", &max).unwrap();
836
837        assert_eq!(backup_ids_ordered(&conn, "ASC"), vec!["one", "two", "max"]);
838        assert_eq!(backup_ids_ordered(&conn, "DESC"), vec!["max", "two", "one"]);
839    }
840
841    fn sqlite_names(conn: &Connection, kind: &str) -> Vec<String> {
842        let sql = match kind {
843            "table" => "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name",
844            "index" => "SELECT name FROM sqlite_master WHERE type='index' AND name NOT LIKE 'sqlite_%' ORDER BY name",
845            _ => panic!("unsupported sqlite_master kind: {kind}"),
846        };
847        let mut stmt = conn.prepare(sql).unwrap();
848        stmt.query_map([], |row| row.get::<_, String>(0))
849            .unwrap()
850            .collect::<Result<Vec<_>, _>>()
851            .unwrap()
852    }
853
854    fn table_columns(conn: &Connection, table: &str) -> Vec<String> {
855        let mut stmt = conn
856            .prepare(&format!("PRAGMA table_info({table})"))
857            .unwrap();
858        stmt.query_map([], |row| row.get::<_, String>(1))
859            .unwrap()
860            .collect::<Result<Vec<_>, _>>()
861            .unwrap()
862    }
863
864    fn schema_version(conn: &Connection) -> u32 {
865        conn.query_row("SELECT version FROM schema_version", [], |row| row.get(0))
866            .unwrap()
867    }
868
869    fn schema_version_row_count(conn: &Connection) -> i64 {
870        conn.query_row("SELECT COUNT(*) FROM schema_version", [], |row| row.get(0))
871            .unwrap()
872    }
873
874    fn assert_unique_constraint(result: rusqlite::Result<usize>) {
875        let error = result.expect_err("expected a unique constraint violation");
876        assert!(
877            error.to_string().contains("UNIQUE constraint failed"),
878            "expected UNIQUE constraint failure, got {error}"
879        );
880    }
881
882    fn insert_bash_task(
883        conn: &Connection,
884        harness: &str,
885        session_id: &str,
886        task_id: &str,
887    ) -> rusqlite::Result<usize> {
888        conn.execute(
889            "INSERT INTO bash_tasks (
890                harness, session_id, task_id, project_key, command, cwd, status, started_at
891             ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
892            params![
893                harness,
894                session_id,
895                task_id,
896                "project-key",
897                "echo ok",
898                "/tmp",
899                "running",
900                1_i64
901            ],
902        )
903    }
904
905    fn insert_compression_event(
906        conn: &Connection,
907        id: i64,
908        harness: &str,
909        session_id: Option<&str>,
910        project_key: &str,
911        tool: &str,
912        task_id: Option<&str>,
913    ) -> rusqlite::Result<usize> {
914        conn.execute(
915            "INSERT INTO compression_events (
916                id, harness, session_id, project_key, tool, task_id, command, compressor,
917                original_bytes, compressed_bytes, original_tokens, compressed_tokens, created_at
918             ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
919            params![
920                id,
921                harness,
922                session_id,
923                project_key,
924                tool,
925                task_id,
926                "echo ok",
927                "test-compressor",
928                100_i64,
929                50_i64,
930                20_i64,
931                10_i64,
932                id
933            ],
934        )
935    }
936
937    fn compression_event_ids(conn: &Connection) -> Vec<i64> {
938        let mut stmt = conn
939            .prepare("SELECT id FROM compression_events ORDER BY id")
940            .unwrap();
941        stmt.query_map([], |row| row.get::<_, i64>(0))
942            .unwrap()
943            .collect::<Result<Vec<_>, _>>()
944            .unwrap()
945    }
946
947    fn insert_backup(
948        conn: &Connection,
949        backup_id: &str,
950        order_blob: &[u8],
951    ) -> rusqlite::Result<usize> {
952        conn.execute(
953            "INSERT INTO backups (
954                backup_id, harness, session_id, project_key, order_blob, file_path,
955                path_hash, kind, created_at
956             ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
957            params![
958                backup_id,
959                "opencode",
960                "session-1",
961                "project-key",
962                order_blob,
963                "/tmp/file.txt",
964                "path-hash",
965                "content",
966                1_i64
967            ],
968        )
969    }
970
971    fn order_blob(value: u128) -> [u8; 16] {
972        value.to_be_bytes()
973    }
974
975    fn backup_ids_ordered(conn: &Connection, direction: &str) -> Vec<String> {
976        let sql = match direction {
977            "ASC" => "SELECT backup_id FROM backups ORDER BY order_blob ASC",
978            "DESC" => "SELECT backup_id FROM backups ORDER BY order_blob DESC",
979            _ => panic!("unsupported order direction: {direction}"),
980        };
981        let mut stmt = conn.prepare(sql).unwrap();
982        stmt.query_map([], |row| row.get::<_, String>(0))
983            .unwrap()
984            .collect::<Result<Vec<_>, _>>()
985            .unwrap()
986    }
987}