use fathomdb_schema::{check_migration_accretion, migrate_with_steps, MIGRATIONS, SCHEMA_VERSION};
use rusqlite::Connection;
use std::sync::Once;
fn register_sqlite_vec_once() {
static REGISTER: Once = Once::new();
REGISTER.call_once(|| unsafe {
let entrypoint: unsafe extern "C" fn(
*mut rusqlite::ffi::sqlite3,
*mut *mut std::os::raw::c_char,
*const rusqlite::ffi::sqlite3_api_routines,
) -> std::os::raw::c_int = std::mem::transmute(sqlite_vec::sqlite3_vec_init as *const ());
rusqlite::ffi::sqlite3_auto_extension(Some(entrypoint));
});
}
fn user_version(conn: &Connection) -> u32 {
conn.query_row("PRAGMA user_version", [], |row| row.get::<_, u32>(0)).unwrap()
}
fn set_user_version(conn: &Connection, version: u32) {
conn.pragma_update(None, "user_version", version).unwrap();
}
fn table_shape(conn: &Connection, table: &str) -> Vec<(String, String, i64, Option<String>)> {
conn.prepare(&format!("PRAGMA table_info({table})"))
.unwrap()
.query_map([], |row| {
Ok((
row.get::<_, String>(1)?, row.get::<_, String>(2)?, row.get::<_, i64>(3)?, row.get::<_, Option<String>>(4)?, ))
})
.unwrap()
.filter_map(Result::ok)
.collect()
}
fn index_set(conn: &Connection, table: &str) -> Vec<(String, Option<String>)> {
let mut out: Vec<(String, Option<String>)> = conn
.prepare("SELECT name, sql FROM sqlite_master WHERE type='index' AND tbl_name=?1")
.unwrap()
.query_map([table], |row| Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?)))
.unwrap()
.filter_map(Result::ok)
.collect();
out.sort();
out
}
#[test]
fn s20_existence_columns_present_and_schema_version_is_head() {
register_sqlite_vec_once();
let conn = Connection::open_in_memory().unwrap();
set_user_version(&conn, 1);
migrate_with_steps(&conn, MIGRATIONS).expect("migration must succeed");
assert_eq!(user_version(&conn), SCHEMA_VERSION);
assert_eq!(
MIGRATIONS.last().expect("at least one migration").step_id,
26,
"step-26 (canonical FTS-hydration join indexes, Slice 19) must be the last (head) migration"
);
let shape = table_shape(&conn, "canonical_nodes");
let state = shape.iter().find(|c| c.0 == "state").expect("canonical_nodes must have `state`");
assert_eq!(state.1, "TEXT", "state must be TEXT");
assert_eq!(state.2, 1, "state must be NOT NULL");
assert_eq!(
state.3.as_deref(),
Some("'active'"),
"state must default to 'active' (every existing row back-fills active)"
);
let reason =
shape.iter().find(|c| c.0 == "reason").expect("canonical_nodes must have `reason`");
assert_eq!(reason.1, "TEXT", "reason must be TEXT");
assert_eq!(reason.2, 0, "reason must be nullable");
let idx = index_set(&conn, "canonical_nodes");
let active_idx = idx
.iter()
.find(|(name, _)| name == "canonical_nodes_state_active_idx")
.expect("canonical_nodes_state_active_idx must exist");
let sql = active_idx.1.as_deref().unwrap_or_default();
assert!(
sql.contains("state = 'active'"),
"canonical_nodes_state_active_idx must be partial on state='active', got: {sql}"
);
}
#[test]
fn s20_fresh_equals_upgrade_from_19() {
register_sqlite_vec_once();
let fresh = Connection::open_in_memory().unwrap();
set_user_version(&fresh, 1);
let steps_to_20: Vec<_> = MIGRATIONS.iter().filter(|m| m.step_id <= 20).cloned().collect();
migrate_with_steps(&fresh, &steps_to_20).expect("fresh migrate to 20");
assert_eq!(user_version(&fresh), 20);
let upgraded = Connection::open_in_memory().unwrap();
set_user_version(&upgraded, 1);
let steps_to_19: Vec<_> = MIGRATIONS.iter().filter(|m| m.step_id <= 19).cloned().collect();
migrate_with_steps(&upgraded, &steps_to_19).expect("migrate to v19");
assert_eq!(user_version(&upgraded), 19, "precondition: DB at old schema version 19");
upgraded
.execute(
"INSERT INTO canonical_nodes(write_cursor, kind, body, row_kind) \
VALUES(1, 'note', 'legacy body', 'leaf')",
[],
)
.expect("seed legacy row");
let step20_only: Vec<_> = MIGRATIONS.iter().filter(|m| m.step_id == 20).cloned().collect();
let report = migrate_with_steps(&upgraded, &step20_only).expect("forward migrate to v20");
assert_eq!(report.schema_version_before, 19);
assert_eq!(report.schema_version_after, 20);
let ran: Vec<u32> = report.migration_steps.iter().map(|s| s.step_id).collect();
assert_eq!(ran, vec![20], "only step-20 must run when starting from v19");
let (state, reason): (String, Option<String>) = upgraded
.query_row("SELECT state, reason FROM canonical_nodes WHERE write_cursor = 1", [], |r| {
Ok((r.get(0)?, r.get(1)?))
})
.expect("legacy row survives");
assert_eq!(state, "active", "every pre-existing row back-fills to state='active'");
assert_eq!(reason, None, "reason back-fills to NULL");
assert_eq!(
table_shape(&fresh, "canonical_nodes"),
table_shape(&upgraded, "canonical_nodes"),
"fresh-create at 20 must equal upgrade-from-19 (PRAGMA table_info)"
);
assert_eq!(
index_set(&fresh, "canonical_nodes"),
index_set(&upgraded, "canonical_nodes"),
"fresh-create at 20 must equal upgrade-from-19 (index set)"
);
}
#[test]
fn s20_passes_accretion_guard_only_with_marker() {
let step20 = MIGRATIONS
.iter()
.find(|m| m.step_id == 20)
.expect("step 20 (OPP-12 Phase-1 existence axis) must exist");
check_migration_accretion("020_existence_axis.sql", step20.sql)
.expect("step 20 must pass the accretion guard with its exemption marker");
let without_marker: String = step20
.sql
.lines()
.filter(|line| !line.contains("MIGRATION-ACCRETION-EXEMPTION"))
.collect::<Vec<_>>()
.join("\n");
check_migration_accretion("020_existence_axis.sql", &without_marker)
.expect_err("step 20 without the exemption marker must be rejected");
}