const MIGRATION_CONTEXT: &str = "FTS5 schema migration failed";
use crate::sqlite::Database;
use tempfile::TempDir;
fn put_store_in_need_of_fts_rebuild(path: &std::path::Path, wal: bool) {
let conn = rusqlite::Connection::open(path).unwrap();
if wal {
conn.execute_batch("PRAGMA journal_mode=WAL;").unwrap();
}
conn.execute_batch("DROP TABLE IF EXISTS memories_fts;")
.unwrap();
conn.execute_batch(
"CREATE VIRTUAL TABLE memories_fts USING fts5(
content,
tokenize='porter unicode61',
content_rowid='rowid',
content='memories'
);",
)
.unwrap();
conn.pragma_update(None, "user_version", 6).unwrap();
}
struct Contended {
_dir: TempDir,
db: Database,
lock_conn: rusqlite::Connection,
}
fn release_writer(contended: &Contended) {
contended
.lock_conn
.execute("ROLLBACK", [])
.expect("release the writer");
}
fn project_id_columns(db: &Database) -> i64 {
db.conn()
.query_row(
"SELECT COUNT(*) FROM pragma_table_info('memories_fts') WHERE name = 'project_id'",
[],
|r| r.get(0),
)
.expect("read the memories_fts column roster after releasing the writer")
}
fn seed_store_under_exclusive_writer(filename: &str, wal: bool) -> Contended {
let dir = TempDir::new().unwrap();
let path = dir.path().join(filename);
{
let db = Database::open(&path).unwrap();
db.insert(
"proj1",
"needs the rebuild",
&vec![0.1f32; 384],
None,
"fact",
"active",
)
.unwrap();
}
put_store_in_need_of_fts_rebuild(&path, wal);
let db = Database::open(&path).unwrap();
let lock_conn = rusqlite::Connection::open(&path).unwrap();
lock_conn.execute("BEGIN EXCLUSIVE", []).unwrap();
lock_conn
.execute(
"INSERT INTO memories_fts(rowid, content) VALUES (9999, 'hold')",
[],
)
.unwrap();
db.set_busy_timeout(std::time::Duration::ZERO).unwrap();
Contended {
_dir: dir,
db,
lock_conn,
}
}
fn contend_and_release(contended: &Contended, why: &str) -> crate::sqlite::Error {
let err = contended.db.initialize_fts().expect_err(why);
release_writer(contended);
err
}
#[test]
fn a_lock_during_the_fts_schema_migration_stays_the_locked_verdict() {
let contended = seed_store_under_exclusive_writer("locked-migration.db", true);
let err = contend_and_release(&contended, "the rebuild cannot take the write lock");
assert!(
matches!(err, crate::sqlite::Error::Locked),
"a busy rebuild must keep the storage Locked verdict, got {err:?}",
);
let public: crate::errors::Error = err.into();
assert!(
public.is_locked(),
"the verdict must survive the public conversion, got {public:?}",
);
assert_eq!(
project_id_columns(&contended.db),
0,
"the rebuild was reached and blocked — it did not complete",
);
}
#[test]
fn a_lock_before_the_fts_rebuild_now_errors_in_delete_journal_mode() {
let contended = seed_store_under_exclusive_writer("locked-migration-delete.db", false);
let mode: String = contended
.db
.conn()
.query_row("PRAGMA journal_mode;", [], |r| r.get(0))
.expect("read the journal-mode premise");
assert_eq!(
mode, "delete",
"the premise of this test is vipune's default journal mode",
);
let err = contend_and_release(
&contended,
"#278: a locked sqlite_master read must surface as an error, not 'no FTS table'",
);
assert!(
matches!(err, crate::sqlite::Error::Locked),
"a busy precheck must keep the storage Locked verdict, got {err:?}",
);
let public: crate::errors::Error = err.into();
assert!(
public.is_locked(),
"the verdict must survive the public conversion, got {public:?}",
);
assert_eq!(
project_id_columns(&contended.db),
0,
"the rebuild never ran — the error came from the precheck, not from a \
half-completed migration",
);
}
#[test]
fn initialize_fts_reports_ok_when_the_fts_table_legitimately_does_not_exist() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("no-fts.db");
{
let db = Database::open(&path).unwrap();
db.insert(
"proj1",
"a row with no fts index",
&vec![0.1f32; 384],
None,
"fact",
"active",
)
.unwrap();
}
let conn = rusqlite::Connection::open(&path).unwrap();
conn.execute_batch("DROP TABLE IF EXISTS memories_fts;")
.unwrap();
let fts_triggers: Vec<String> = conn
.prepare(
"SELECT name FROM sqlite_master WHERE type='trigger' AND name LIKE 'memories_fts_%'",
)
.unwrap()
.query_map([], |r| r.get(0))
.unwrap()
.collect::<rusqlite::Result<Vec<String>>>()
.unwrap();
assert!(
!fts_triggers.is_empty(),
"the premise: the store has FTS triggers to drop, got {fts_triggers:?}",
);
for name in &fts_triggers {
conn.execute_batch(&format!("DROP TRIGGER IF EXISTS {name};"))
.unwrap();
}
conn.pragma_update(None, "user_version", 6).unwrap();
let db = Database::from_conn(conn);
let absent: i64 = db
.conn()
.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='memories_fts'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(absent, 0, "the premise: the FTS table is genuinely absent");
let leftover_triggers: i64 = db
.conn()
.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type='trigger' AND name LIKE 'memories_fts_%'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(
leftover_triggers, 0,
"no trigger may survive pointing at the dropped FTS table — the next write \
to `memories` would fail outright rather than reach the precheck",
);
db.initialize_fts()
.expect("a genuinely absent FTS table is Ok, not an error");
}
#[test]
fn an_unclassified_fts_migration_failure_keeps_its_context_and_its_message() {
let plain = crate::sqlite::Error::Sqlite("no such table: nope".to_string())
.with_context(MIGRATION_CONTEXT);
match plain {
crate::sqlite::Error::Sqlite(msg) => {
assert_eq!(msg, "FTS5 schema migration failed: no such table: nope");
}
other => panic!("expected Sqlite with the context prepended, got {other:?}"),
}
let locked = crate::sqlite::Error::Locked.with_context(MIGRATION_CONTEXT);
assert!(
matches!(locked, crate::sqlite::Error::Locked),
"context must never flatten the locked verdict, got {locked:?}"
);
}
#[test]
fn with_context_leaves_every_non_sqlite_variant_untouched() {
type Kept = (crate::sqlite::Error, fn(&crate::sqlite::Error) -> bool);
let cases: Vec<Kept> = vec![
(
crate::sqlite::Error::CorruptEmbedding {
id: "mem-1".to_string(),
reason: "not enough bytes".to_string(),
},
|e| {
matches!(e, crate::sqlite::Error::CorruptEmbedding { id, reason }
if id == "mem-1" && reason == "not enough bytes")
},
),
(
crate::sqlite::Error::InvalidBlobSize {
expected: crate::embedding::EMBEDDING_DIMS * 4,
actual: 4,
},
|e| {
matches!(
e,
crate::sqlite::Error::InvalidBlobSize {
expected,
actual: 4
} if *expected == crate::embedding::EMBEDDING_DIMS * 4
)
},
),
(
crate::sqlite::Error::MismatchedDimensions {
expected: 384,
actual: 768,
},
|e| {
matches!(
e,
crate::sqlite::Error::MismatchedDimensions {
expected: 384,
actual: 768
}
)
},
),
(crate::sqlite::Error::Locked, |e| {
matches!(e, crate::sqlite::Error::Locked)
}),
];
for (err, is_kept) in cases {
let expected = err.to_string();
let after = err.with_context(MIGRATION_CONTEXT);
assert!(
is_kept(&after),
"a non-Sqlite variant must survive with_context with its payload, got {after:?}"
);
assert_eq!(
after.to_string(),
expected,
"with_context must not rewrite a non-Sqlite variant's rendering"
);
}
}