use crate::*;
#[test]
fn test_open_stores_embedding_dimension() {
let dir = tempfile::TempDir::new().unwrap();
let db_path = dir.path().join("test.db");
let db = Database::open(&db_path, 384).unwrap();
let stored: String = db
.get_metadata("embedding_dimension")
.unwrap()
.expect("dimension should be stored");
assert_eq!(stored, "384");
}
#[test]
fn test_open_with_different_dimension_clears_embeddings() {
let dir = tempfile::TempDir::new().unwrap();
let db_path = dir.path().join("test.db");
{
let db = Database::open(&db_path, 384).unwrap();
let sym = Symbol::new("foo", SymbolKind::Function, "a.py", 1, 10, 0, 100, None);
db.insert_symbol(&sym).unwrap();
db.upsert_symbol_content(&sym.id, "foo", "def foo():", "header")
.unwrap();
let eid = db.get_or_create_embedding_id(&sym.id).unwrap();
let bytes = vec![0u8; 384 * 4];
db.insert_embeddings(&[(eid, bytes)]).unwrap();
assert_eq!(db.embedding_count().unwrap(), 1);
}
{
let db = Database::open(&db_path, 768).unwrap();
assert_eq!(db.embedding_count().unwrap(), 0);
let stored: String = db
.get_metadata("embedding_dimension")
.unwrap()
.expect("dimension should be updated");
assert_eq!(stored, "768");
}
}
#[test]
fn test_open_same_dimension_preserves_embeddings() {
let dir = tempfile::TempDir::new().unwrap();
let db_path = dir.path().join("test.db");
{
let db = Database::open(&db_path, 384).unwrap();
let sym = Symbol::new("bar", SymbolKind::Function, "b.py", 1, 10, 0, 100, None);
db.insert_symbol(&sym).unwrap();
db.upsert_symbol_content(&sym.id, "bar", "def bar():", "header")
.unwrap();
let eid = db.get_or_create_embedding_id(&sym.id).unwrap();
let bytes = vec![0u8; 384 * 4];
db.insert_embeddings(&[(eid, bytes)]).unwrap();
}
{
let db = Database::open(&db_path, 384).unwrap();
assert_eq!(db.embedding_count().unwrap(), 1);
}
}
#[test]
fn test_default_dim_preserves_stored_non_default() {
let dir = tempfile::TempDir::new().unwrap();
let db_path = dir.path().join("test.db");
{
let db = Database::open(&db_path, 768).unwrap();
let sym = Symbol::new("baz", SymbolKind::Function, "c.py", 1, 10, 0, 100, None);
db.insert_symbol(&sym).unwrap();
db.upsert_symbol_content(&sym.id, "baz", "def baz():", "header")
.unwrap();
let eid = db.get_or_create_embedding_id(&sym.id).unwrap();
let bytes = vec![0u8; 768 * 4];
db.insert_embeddings(&[(eid, bytes)]).unwrap();
}
{
let db = Database::open(&db_path, DEFAULT_EMBEDDING_DIM).unwrap();
assert_eq!(db.embedding_count().unwrap(), 1);
let stored: i64 = db
.conn
.query_row(
"SELECT CAST(value AS INTEGER) FROM metadata WHERE key = 'embedding_dimension'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(stored, 768);
}
}
#[test]
fn test_explicit_non_default_dim_wipes_different_stored() {
let dir = tempfile::TempDir::new().unwrap();
let db_path = dir.path().join("test.db");
{
let db = Database::open(&db_path, 768).unwrap();
let sym = Symbol::new("qux", SymbolKind::Function, "d.py", 1, 10, 0, 100, None);
db.insert_symbol(&sym).unwrap();
db.upsert_symbol_content(&sym.id, "qux", "def qux():", "header")
.unwrap();
let eid = db.get_or_create_embedding_id(&sym.id).unwrap();
let bytes = vec![0u8; 768 * 4];
db.insert_embeddings(&[(eid, bytes)]).unwrap();
}
{
let db = Database::open(&db_path, 1536).unwrap();
assert_eq!(db.embedding_count().unwrap(), 0);
}
}
#[test]
fn test_reopen_same_dim_does_not_rewrite_metadata() {
let dir = tempfile::TempDir::new().unwrap();
let db_path = dir.path().join("test.db");
let _db = Database::open(&db_path, 384).unwrap();
let rowid_before: i64 = {
let conn = Connection::open(&db_path).unwrap();
conn.query_row(
"SELECT rowid FROM metadata WHERE key = 'embedding_dimension'",
[],
|row| row.get(0),
)
.unwrap()
};
let _db = Database::open(&db_path, 384).unwrap();
let rowid_after: i64 = {
let conn = Connection::open(&db_path).unwrap();
conn.query_row(
"SELECT rowid FROM metadata WHERE key = 'embedding_dimension'",
[],
|row| row.get(0),
)
.unwrap()
};
assert_eq!(
rowid_before, rowid_after,
"same-dim reopen should not rewrite the embedding_dimension row"
);
}
#[test]
fn test_retry_busy_returns_on_non_busy_error() {
let attempts = std::cell::Cell::new(0);
let result = retry_busy(|| -> std::result::Result<(), rusqlite::Error> {
attempts.set(attempts.get() + 1);
Err(rusqlite::Error::InvalidQuery)
});
assert!(matches!(result, Err(rusqlite::Error::InvalidQuery)));
assert_eq!(attempts.get(), 1, "non-busy errors must not retry");
}
#[test]
fn test_retry_busy_succeeds_after_transient_busy() {
let attempts = std::cell::Cell::new(0);
let result = retry_busy(|| -> std::result::Result<u32, rusqlite::Error> {
attempts.set(attempts.get() + 1);
if attempts.get() == 1 {
Err(rusqlite::Error::SqliteFailure(
rusqlite::ffi::Error {
code: rusqlite::ErrorCode::DatabaseBusy,
extended_code: 5,
},
Some("database is locked".to_string()),
))
} else {
Ok(42)
}
});
assert_eq!(result.unwrap(), 42);
assert_eq!(attempts.get(), 2);
}
#[test]
fn test_retry_busy_exhausts_and_propagates() {
let attempts = std::cell::Cell::new(0);
let result = retry_busy(|| -> std::result::Result<(), rusqlite::Error> {
attempts.set(attempts.get() + 1);
Err(rusqlite::Error::SqliteFailure(
rusqlite::ffi::Error {
code: rusqlite::ErrorCode::DatabaseBusy,
extended_code: 5,
},
Some("database is locked".to_string()),
))
});
assert!(matches!(
result,
Err(rusqlite::Error::SqliteFailure(
rusqlite::ffi::Error {
code: rusqlite::ErrorCode::DatabaseBusy,
..
},
_
))
));
assert_eq!(attempts.get(), MIGRATION_RETRY_BACKOFF_MS.len() + 1);
}
fn fp(provider: &str, model: &str, dim: usize) -> EmbeddingFingerprint {
EmbeddingFingerprint {
provider: provider.to_string(),
model: model.to_string(),
dimension: dim,
}
}
fn seed_embedding(db: &Database, dim: usize, sym_name: &str) {
let sym = Symbol::new(sym_name, SymbolKind::Function, "f.py", 1, 10, 0, 100, None);
db.insert_symbol(&sym).unwrap();
db.upsert_symbol_content(&sym.id, sym_name, "def f():", "header")
.unwrap();
let eid = db.get_or_create_embedding_id(&sym.id).unwrap();
let bytes = vec![0u8; dim * 4];
db.insert_embeddings(&[(eid, bytes)]).unwrap();
}
#[test]
fn test_fingerprint_match_is_noop() {
let dir = tempfile::TempDir::new().unwrap();
let db_path = dir.path().join("test.db");
let db = Database::open(&db_path, 384).unwrap();
let f = fp("local", "BGE-small-en-v1.5", 384);
db.reconcile_embedding_fingerprint(&f).unwrap();
seed_embedding(&db, 384, "foo");
db.reconcile_embedding_fingerprint(&f).unwrap();
assert_eq!(db.embedding_count().unwrap(), 1);
}
#[test]
fn test_fingerprint_provider_swap_wipes() {
let dir = tempfile::TempDir::new().unwrap();
let db_path = dir.path().join("test.db");
let db = Database::open(&db_path, 384).unwrap();
let f1 = fp("local", "BGE-small-en-v1.5", 384);
db.reconcile_embedding_fingerprint(&f1).unwrap();
seed_embedding(&db, 384, "bar");
assert_eq!(db.embedding_count().unwrap(), 1);
let f2 = fp("ollama", "BGE-small-en-v1.5", 384);
db.reconcile_embedding_fingerprint(&f2).unwrap();
assert_eq!(db.embedding_count().unwrap(), 0);
assert_eq!(
db.get_metadata("embedding_provider").unwrap().as_deref(),
Some("ollama")
);
}
#[test]
fn test_fingerprint_model_swap_wipes() {
let dir = tempfile::TempDir::new().unwrap();
let db_path = dir.path().join("test.db");
let db = Database::open(&db_path, 384).unwrap();
let f1 = fp("local", "BGE-small-en-v1.5", 384);
db.reconcile_embedding_fingerprint(&f1).unwrap();
seed_embedding(&db, 384, "baz");
assert_eq!(db.embedding_count().unwrap(), 1);
let f2 = fp("local", "AllMiniLML6V2", 384);
db.reconcile_embedding_fingerprint(&f2).unwrap();
assert_eq!(db.embedding_count().unwrap(), 0);
assert_eq!(
db.get_metadata("embedding_model").unwrap().as_deref(),
Some("AllMiniLML6V2")
);
}
#[test]
fn test_fingerprint_backfill_does_not_wipe() {
let dir = tempfile::TempDir::new().unwrap();
let db_path = dir.path().join("test.db");
let db = Database::open(&db_path, 384).unwrap();
seed_embedding(&db, 384, "qux");
assert!(db.get_metadata("embedding_provider").unwrap().is_none());
assert_eq!(db.embedding_count().unwrap(), 1);
let f = fp("local", "BGE-small-en-v1.5", 384);
db.reconcile_embedding_fingerprint(&f).unwrap();
assert_eq!(
db.embedding_count().unwrap(),
1,
"backfill must preserve existing embeddings"
);
assert_eq!(
db.get_metadata("embedding_provider").unwrap().as_deref(),
Some("local")
);
assert_eq!(
db.get_metadata("embedding_model").unwrap().as_deref(),
Some("BGE-small-en-v1.5")
);
}
#[test]
fn test_fingerprint_dim_change_wipes() {
let dir = tempfile::TempDir::new().unwrap();
let db_path = dir.path().join("test.db");
let db = Database::open(&db_path, 384).unwrap();
let f1 = fp("local", "BGE-small-en-v1.5", 384);
db.reconcile_embedding_fingerprint(&f1).unwrap();
seed_embedding(&db, 384, "quux");
assert_eq!(db.embedding_count().unwrap(), 1);
let f2 = fp("local", "BGELargeENV15", 1024);
db.reconcile_embedding_fingerprint(&f2).unwrap();
assert_eq!(db.embedding_count().unwrap(), 0);
let stored_dim: i64 = db
.conn
.query_row(
"SELECT CAST(value AS INTEGER) FROM metadata WHERE key = 'embedding_dimension'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(stored_dim, 1024);
assert!(
symbol_vec_exists(&db.conn).unwrap(),
"successful reconcile must recreate symbol_vec"
);
}
#[test]
fn test_open_readonly_succeeds_and_marks_read_only() {
let dir = tempfile::TempDir::new().unwrap();
let db_path = dir.path().join("test.db");
{
let db = Database::open(&db_path, 384).unwrap();
db.reconcile_embedding_fingerprint(&fp("local", "BGE-small-en-v1.5", 384))
.unwrap();
seed_embedding(&db, 384, "foo");
}
let reader = Database::open_readonly(&db_path).unwrap();
assert!(reader.is_read_only(), "open_readonly must set the flag");
let pinned = reader.pinned_attach().expect("read-only attach pins state");
assert_eq!(pinned.schema_version, SCHEMA_VERSION);
assert_eq!(
pinned.embedding,
Some(fp("local", "BGE-small-en-v1.5", 384))
);
}
#[test]
fn test_open_readonly_can_query_existing_data() {
let dir = tempfile::TempDir::new().unwrap();
let db_path = dir.path().join("test.db");
{
let db = Database::open(&db_path, 384).unwrap();
let sym = Symbol::new(
"callable",
SymbolKind::Function,
"a.py",
1,
10,
0,
100,
None,
);
db.insert_symbol(&sym).unwrap();
}
let reader = Database::open_readonly(&db_path).unwrap();
let count: i64 = reader
.conn
.query_row("SELECT COUNT(*) FROM symbols", [], |row| row.get(0))
.unwrap();
assert_eq!(count, 1, "reader sees primary's data");
}
#[test]
fn test_open_readonly_refuses_writes() {
let dir = tempfile::TempDir::new().unwrap();
let db_path = dir.path().join("test.db");
{
let _db = Database::open(&db_path, 384).unwrap();
}
let reader = Database::open_readonly(&db_path).unwrap();
let err = reader
.conn
.execute(
"INSERT OR REPLACE INTO metadata (key, value) VALUES ('x', 'y')",
[],
)
.unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("read") || msg.contains("readonly") || msg.contains("write"),
"read-only DB write should fail with a read-only-flavored error, got: {msg}"
);
}
#[test]
fn test_open_readonly_detects_schema_drift() {
let dir = tempfile::TempDir::new().unwrap();
let db_path = dir.path().join("test.db");
{
let db = Database::open(&db_path, 384).unwrap();
db.set_metadata("schema_version", "9999").unwrap();
}
let err = Database::open_readonly(&db_path).unwrap_err();
match err {
DbError::SchemaDrift { expected, stored } => {
assert_eq!(expected, SCHEMA_VERSION);
assert_eq!(stored, 9999);
}
other => panic!("expected SchemaDrift, got {other:?}"),
}
}
#[test]
fn test_open_readonly_does_not_run_migrations() {
let dir = tempfile::TempDir::new().unwrap();
let db_path = dir.path().join("test.db");
{
let db = Database::open(&db_path, 384).unwrap();
db.set_metadata("user_marker", "untouched").unwrap();
}
let _reader = Database::open_readonly(&db_path).unwrap();
let primary = Database::open(&db_path, 384).unwrap();
assert_eq!(
primary.get_metadata("user_marker").unwrap().as_deref(),
Some("untouched")
);
}
#[test]
fn test_open_default_is_not_read_only() {
let dir = tempfile::TempDir::new().unwrap();
let db_path = dir.path().join("test.db");
let db = Database::open(&db_path, 384).unwrap();
assert!(!db.is_read_only());
assert!(db.pinned_attach().is_none());
}
#[test]
fn test_open_existing_rw_opens_writable_and_skips_migrations() {
let dir = tempfile::TempDir::new().unwrap();
let db_path = dir.path().join("test.db");
{
let db = Database::open(&db_path, 384).unwrap();
db.set_metadata("marker", "preserved").unwrap();
}
let promoted = Database::open_existing_rw(&db_path).unwrap();
assert!(!promoted.is_read_only(), "open_existing_rw is RW");
assert!(promoted.pinned_attach().is_none(), "RW opens have no pin");
assert_eq!(
promoted.get_metadata("marker").unwrap().as_deref(),
Some("preserved")
);
promoted.set_metadata("write_check", "ok").unwrap();
}
#[test]
fn test_open_existing_rw_detects_schema_drift() {
let dir = tempfile::TempDir::new().unwrap();
let db_path = dir.path().join("test.db");
{
let db = Database::open(&db_path, 384).unwrap();
db.set_metadata("schema_version", "9999").unwrap();
}
let err = Database::open_existing_rw(&db_path).unwrap_err();
match err {
DbError::SchemaDrift { expected, stored } => {
assert_eq!(expected, SCHEMA_VERSION);
assert_eq!(stored, 9999);
}
other => panic!("expected SchemaDrift, got {other:?}"),
}
}
#[test]
fn test_database_open_alone_does_not_change_fingerprint() {
let dir = tempfile::TempDir::new().unwrap();
let db_path = dir.path().join("test.db");
let original_fp = fp("local", "BGE-small-en-v1.5", 384);
{
let db = Database::open(&db_path, 384).unwrap();
db.reconcile_embedding_fingerprint(&original_fp).unwrap();
seed_embedding(&db, 384, "guard");
}
{
let _db = Database::open(&db_path, 384).unwrap();
}
let db = Database::open(&db_path, 384).unwrap();
assert_eq!(
db.get_metadata("embedding_provider").unwrap().as_deref(),
Some("local")
);
assert_eq!(
db.get_metadata("embedding_model").unwrap().as_deref(),
Some("BGE-small-en-v1.5")
);
assert_eq!(db.embedding_count().unwrap(), 1);
}
#[test]
fn test_open_readonly_missing_schema_version_is_schema_drift() {
let dir = tempfile::TempDir::new().unwrap();
let db_path = dir.path().join("test.db");
{
let db = Database::open(&db_path, 384).unwrap();
db.conn
.execute("DELETE FROM metadata WHERE key = 'schema_version'", [])
.unwrap();
}
let err = Database::open_readonly(&db_path).unwrap_err();
match err {
DbError::SchemaDrift { expected, stored } => {
assert_eq!(expected, SCHEMA_VERSION);
assert_eq!(stored, 0, "missing row should surface as stored=0");
}
other => panic!("expected SchemaDrift, got {other:?}"),
}
}
#[test]
fn test_open_readonly_missing_metadata_table_is_schema_drift() {
let dir = tempfile::TempDir::new().unwrap();
let db_path = dir.path().join("test.db");
{
let conn = Connection::open(&db_path).unwrap();
conn.execute_batch("CREATE TABLE unrelated (x INTEGER);")
.unwrap();
}
let err = Database::open_readonly(&db_path).unwrap_err();
match err {
DbError::SchemaDrift { expected, stored } => {
assert_eq!(expected, SCHEMA_VERSION);
assert_eq!(stored, 0, "missing metadata table should be stored=0");
}
other => panic!("expected SchemaDrift, got {other:?}"),
}
}
#[test]
fn test_open_existing_rw_missing_schema_version_is_schema_drift() {
let dir = tempfile::TempDir::new().unwrap();
let db_path = dir.path().join("test.db");
{
let db = Database::open(&db_path, 384).unwrap();
db.conn
.execute("DELETE FROM metadata WHERE key = 'schema_version'", [])
.unwrap();
}
let err = Database::open_existing_rw(&db_path).unwrap_err();
match err {
DbError::SchemaDrift { expected, stored } => {
assert_eq!(expected, SCHEMA_VERSION);
assert_eq!(stored, 0);
}
other => panic!("expected SchemaDrift, got {other:?}"),
}
}
#[test]
fn test_reconcile_rebuilds_when_metadata_matches_but_symbol_vec_missing() {
let dir = tempfile::TempDir::new().unwrap();
let db_path = dir.path().join("test.db");
let f = fp("local", "BGE-small-en-v1.5", 384);
{
let db = Database::open(&db_path, 384).unwrap();
db.reconcile_embedding_fingerprint(&f).unwrap();
}
{
let db = Database::open(&db_path, 384).unwrap();
db.conn
.execute("DROP TABLE IF EXISTS symbol_vec", [])
.unwrap();
assert_eq!(
db.get_metadata("embedding_dimension").unwrap().as_deref(),
Some("384")
);
}
{
let db = Database::open(&db_path, 384).unwrap();
db.reconcile_embedding_fingerprint(&f).unwrap();
let exists: bool = db
.conn
.query_row(
"SELECT 1 FROM sqlite_master WHERE name='symbol_vec'",
[],
|row| row.get::<_, i64>(0),
)
.optional()
.unwrap()
.is_some();
assert!(
exists,
"reconcile must rebuild symbol_vec when missing, even on metadata match"
);
}
}
#[test]
fn test_handle_embedding_dimension_rebuilds_when_symbol_vec_missing() {
let dir = tempfile::TempDir::new().unwrap();
let db_path = dir.path().join("test.db");
{
let db = Database::open(&db_path, 384).unwrap();
db.conn
.execute("DROP TABLE IF EXISTS symbol_vec", [])
.unwrap();
}
let db = Database::open(&db_path, 384).unwrap();
let exists: bool = db
.conn
.query_row(
"SELECT 1 FROM sqlite_master WHERE name='symbol_vec'",
[],
|row| row.get::<_, i64>(0),
)
.optional()
.unwrap()
.is_some();
assert!(
exists,
"Database::open must rebuild symbol_vec when missing, even on metadata match"
);
}
#[test]
fn test_reconcile_fingerprint_rolls_back_on_midsequence_failure() {
let dir = tempfile::TempDir::new().unwrap();
let db_path = dir.path().join("test.db");
let initial_fp = fp("local", "BGE-small-en-v1.5", 384);
{
let db = Database::open(&db_path, 384).unwrap();
db.reconcile_embedding_fingerprint(&initial_fp).unwrap();
seed_embedding(&db, 384, "seed");
}
let new_fp = fp("ollama", "nomic-embed-text-v2", 384);
let outcome = {
let db = Database::open(&db_path, 384).unwrap();
RECONCILE_FAIL_AFTER_MODEL.with(|b| b.store(true, std::sync::atomic::Ordering::SeqCst));
db.reconcile_embedding_fingerprint(&new_fp)
};
assert!(outcome.is_err(), "injected SQLITE_FULL must surface as Err");
let post = Database::open(&db_path, 384).unwrap();
let stored_provider = post.get_metadata("embedding_provider").unwrap();
let stored_model = post.get_metadata("embedding_model").unwrap();
let stored_dim_str = post.get_metadata("embedding_dimension").unwrap();
let symbol_vec_exists = post
.conn
.query_row(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='symbol_vec'",
[],
|row| row.get::<_, i64>(0),
)
.optional()
.unwrap()
.is_some();
assert_eq!(
stored_provider.as_deref(),
Some("local"),
"failed reconcile must roll back provider"
);
assert_eq!(
stored_model.as_deref(),
Some("BGE-small-en-v1.5"),
"failed reconcile must roll back model"
);
assert_eq!(
stored_dim_str.as_deref(),
Some("384"),
"failed reconcile must roll back dimension"
);
assert!(
symbol_vec_exists,
"failed reconcile must roll back symbol_vec drop"
);
assert_eq!(
post.embedding_count().unwrap(),
1,
"failed reconcile must roll back the symbol_embedding_map DELETE"
);
}
#[test]
fn test_default_embedding_dim_constant() {
assert_eq!(DEFAULT_EMBEDDING_DIM, 384);
}
#[test]
fn test_destructive_migration_creates_backup() {
let tmp = tempfile::tempdir().unwrap();
let db_path = tmp.path().join("legacy.db");
{
register_sqlite_vec();
let conn = Connection::open(&db_path).unwrap();
conn.execute_batch(
"CREATE TABLE symbols (
id TEXT PRIMARY KEY, name TEXT, kind TEXT, file_path TEXT,
start_line INTEGER, end_line INTEGER, start_byte INTEGER, end_byte INTEGER,
parent_id TEXT, signature TEXT, visibility TEXT,
is_async BOOLEAN, docstring TEXT, in_degree INTEGER DEFAULT 0
);
CREATE TABLE edges (
id INTEGER PRIMARY KEY AUTOINCREMENT, source_id TEXT, target_name TEXT,
target_id TEXT, kind TEXT, file_path TEXT, line INTEGER
);
CREATE TABLE files (path TEXT PRIMARY KEY, last_modified REAL, hash TEXT,
language TEXT, num_symbols INTEGER);
CREATE TABLE metadata (key TEXT PRIMARY KEY, value TEXT);
INSERT INTO symbols (id, name, kind, file_path) VALUES ('s1', 'foo', 'function', 'a.py');
INSERT INTO metadata (key, value) VALUES ('schema_version', '2');",
)
.unwrap();
}
let _db = Database::open(&db_path, DEFAULT_EMBEDDING_DIM).unwrap();
let backups: Vec<_> = std::fs::read_dir(tmp.path())
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| {
e.file_name()
.to_string_lossy()
.starts_with("legacy.db.pre-v")
})
.collect();
assert_eq!(
backups.len(),
1,
"expected exactly one pre-migration backup, found {}",
backups.len()
);
}
#[test]
fn test_no_backup_for_fresh_database() {
let tmp = tempfile::tempdir().unwrap();
let db_path = tmp.path().join("fresh.db");
let _db = Database::open(&db_path, DEFAULT_EMBEDDING_DIM).unwrap();
let backups: Vec<_> = std::fs::read_dir(tmp.path())
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.file_name().to_string_lossy().contains(".pre-v"))
.collect();
assert!(
backups.is_empty(),
"fresh DB should not create a backup file"
);
}
#[test]
fn fresh_db_stamps_version_without_running_ladder() {
let tmp = tempfile::tempdir().unwrap();
let db_path = tmp.path().join("fresh.db");
let db = Database::open(&db_path, DEFAULT_EMBEDDING_DIM).unwrap();
db.set_metadata("last_commit", "deadbeef").unwrap();
drop(db);
let db = Database::open(&db_path, DEFAULT_EMBEDDING_DIM).unwrap();
let last_commit: Option<String> = db
.conn
.query_row(
"SELECT value FROM metadata WHERE key = 'last_commit'",
[],
|r| r.get(0),
)
.optional()
.unwrap();
assert_eq!(
last_commit,
Some("deadbeef".to_string()),
"fresh re-open must not run the v2→3 wipe"
);
let version: String = db
.conn
.query_row(
"SELECT value FROM metadata WHERE key = 'schema_version'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(version, SCHEMA_VERSION.to_string());
}
#[test]
fn populated_v1_db_runs_full_ladder_to_current() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("v1.sqlite");
{
let conn = Connection::open(&path).unwrap();
conn.execute_batch(
"CREATE TABLE symbols (
id TEXT PRIMARY KEY, name TEXT, kind TEXT, file_path TEXT,
start_line INTEGER, end_line INTEGER, start_byte INTEGER, end_byte INTEGER,
parent_id TEXT, signature TEXT, visibility TEXT, is_async BOOLEAN, docstring TEXT);
CREATE TABLE edges (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_id TEXT NOT NULL, target_name TEXT NOT NULL, target_id TEXT,
kind TEXT NOT NULL, file_path TEXT NOT NULL, line INTEGER);
CREATE TABLE files (path TEXT PRIMARY KEY);
CREATE TABLE metadata (key TEXT PRIMARY KEY, value TEXT);
INSERT INTO symbols (id, name, kind, file_path) VALUES ('s:1', 'foo', 'function', 'a.py');
INSERT INTO edges (source_id, target_name, target_id, kind, file_path, line)
VALUES ('s:1', 'foo', 's:1', 'calls', 'a.py', 1);",
)
.unwrap();
}
let db = Database::open(&path, DEFAULT_EMBEDDING_DIM).unwrap();
let version: String = db
.conn
.query_row(
"SELECT value FROM metadata WHERE key = 'schema_version'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(version, SCHEMA_VERSION.to_string());
assert!(
db.conn
.prepare("SELECT resolution_source FROM edges LIMIT 0")
.is_ok(),
"resolution_source must be added by the real upgrade"
);
let symbol_count: i64 = db
.conn
.query_row("SELECT COUNT(*) FROM symbols", [], |r| r.get(0))
.unwrap();
assert_eq!(symbol_count, 0, "v2→3 wipe must run for a populated v1 DB");
}
#[test]
fn test_busy_timeout_pragma_is_set() {
let tmp = tempfile::tempdir().unwrap();
let db_path = tmp.path().join("timeout.db");
let db = Database::open(&db_path, DEFAULT_EMBEDDING_DIM).unwrap();
let timeout: i64 = db
.conn
.query_row("PRAGMA busy_timeout;", [], |row| row.get(0))
.unwrap();
assert_eq!(timeout, BUSY_TIMEOUT_MS as i64);
}
#[test]
fn test_busy_timeout_makes_second_writer_retry_instead_of_aborting() {
let tmp = tempfile::tempdir().unwrap();
let db_path = tmp.path().join("concurrent.db");
let _ = Database::open(&db_path, DEFAULT_EMBEDDING_DIM).unwrap();
let holder = Database::open(&db_path, DEFAULT_EMBEDDING_DIM).unwrap();
holder
.conn
.execute_batch("BEGIN IMMEDIATE; INSERT INTO metadata (key, value) VALUES ('a', '1');")
.unwrap();
let attempt_write = |timeout_ms: u32| -> std::time::Duration {
let conn = Connection::open(&db_path).unwrap();
conn.execute_batch(&format!("PRAGMA busy_timeout={timeout_ms};"))
.unwrap();
let start = std::time::Instant::now();
let res = conn.execute("INSERT INTO metadata (key, value) VALUES ('b', '2');", []);
assert!(res.is_err(), "write must fail while the lock is held");
start.elapsed()
};
assert!(
attempt_write(0) < std::time::Duration::from_millis(150),
"with busy_timeout=0 the writer must fail immediately"
);
assert!(
attempt_write(300) >= std::time::Duration::from_millis(250),
"with a non-zero busy_timeout the writer must retry, not abort"
);
holder.conn.execute_batch("COMMIT;").unwrap();
}