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