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
132const MIGRATION_V4: &str = r#"
135ALTER TABLE backups ADD COLUMN restore_meta TEXT;
136"#;
137
138const 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
166const 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
178const 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
200const 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
257pub 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
275pub 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
285pub fn apply_pragmas(conn: &Connection) -> Result<(), rusqlite::Error> {
287 conn.pragma_update(None, "foreign_keys", "ON")?;
288 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
296pub 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 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}