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
131const MIGRATION_V4: &str = r#"
134ALTER TABLE backups ADD COLUMN restore_meta TEXT;
135"#;
136
137const 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
165const 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
177const 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
199const 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
256pub 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
274pub 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
284pub fn apply_pragmas(conn: &Connection) -> Result<(), rusqlite::Error> {
286 conn.pragma_update(None, "foreign_keys", "ON")?;
287 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
295pub 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 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}