Skip to main content

seshat_storage/
db.rs

1//! Database lifecycle: open, WAL mode, migrations.
2
3use std::path::Path;
4use std::sync::{Arc, Mutex};
5
6use refinery::embed_migrations;
7use rusqlite::{Connection, params};
8
9use crate::StorageError;
10use crate::ir_serialization::{IR_SCHEMA_VERSION, deserialize_ir};
11use crate::repository::{extract_definitions, extract_imports};
12
13/// Report from [`wipe_stale_ir_cache`] describing what was cleared.
14///
15/// `stale_count` is the number of `files_ir` rows that were deleted because
16/// their `ir_schema_version` did not match [`IR_SCHEMA_VERSION`]. `cached_versions`
17/// lists the distinct cached versions that were encountered (sorted ascending),
18/// useful for diagnostic logging. When `stale_count` is zero the cache was
19/// already current and nothing was changed.
20#[derive(Debug, Clone, Default)]
21pub struct StaleIrWipeReport {
22    /// Number of `files_ir` rows deleted.
23    pub stale_count: u64,
24    /// Distinct `ir_schema_version` values found among stale rows.
25    pub cached_versions: Vec<u8>,
26    /// Number of `symbol_definitions` rows deleted for affected branches.
27    pub symbol_definitions_cleared: u64,
28    /// Number of `symbol_imports` rows deleted for affected branches.
29    pub symbol_imports_cleared: u64,
30}
31
32impl StaleIrWipeReport {
33    /// Whether anything was actually cleared.
34    pub fn is_empty(&self) -> bool {
35        self.stale_count == 0
36    }
37}
38
39// Embed migration files from the `migrations/` directory at compile time.
40embed_migrations!("migrations");
41
42/// Time SQLite waits for a held write lock before returning `SQLITE_BUSY`.
43const BUSY_TIMEOUT_MS: u64 = 5_000;
44
45/// Core database handle. Wraps an `Arc<Mutex<Connection>>` for write access.
46///
47/// # Usage
48/// ```no_run
49/// use seshat_storage::Database;
50/// let db = Database::open("seshat.db").unwrap();
51/// ```
52#[derive(Debug, Clone)]
53pub struct Database {
54    conn: Arc<Mutex<Connection>>,
55}
56
57impl Database {
58    /// Opens (or creates) a SQLite database at `path`, enables WAL mode,
59    /// and applies any pending migrations.
60    ///
61    /// For in-memory databases (testing), pass `":memory:"`.
62    pub fn open<P: AsRef<Path>>(path: P) -> Result<Self, StorageError> {
63        let path_ref = path.as_ref();
64        let path_str = path_ref.to_string_lossy().to_string();
65
66        let mut conn = Connection::open(path_ref).map_err(|e| StorageError::OpenError {
67            path: path_str.clone(),
68            reason: e.to_string(),
69        })?;
70
71        // Enable WAL mode for concurrent readers.
72        conn.pragma_update(None, "journal_mode", "WAL")
73            .map_err(|e| StorageError::OpenError {
74                path: path_str.clone(),
75                reason: format!("Failed to set WAL mode: {e}"),
76            })?;
77
78        // Wait up to 5 s for a held write lock instead of failing instantly with
79        // SQLITE_BUSY. Writers serialise on the same Mutex<Connection> within
80        // a process, but a separate process (e.g. `seshat scan` running while
81        // `seshat serve` is mid-sync) holds an OS-level lock that the Mutex
82        // does not see — busy_timeout is the standard SQLite remedy.
83        conn.busy_timeout(std::time::Duration::from_millis(BUSY_TIMEOUT_MS))
84            .map_err(|e| StorageError::OpenError {
85                path: path_str.clone(),
86                reason: format!("Failed to set busy_timeout: {e}"),
87            })?;
88
89        // Enable foreign key enforcement.
90        conn.pragma_update(None, "foreign_keys", "ON")
91            .map_err(|e| StorageError::OpenError {
92                path: path_str.clone(),
93                reason: format!("Failed to enable foreign keys: {e}"),
94            })?;
95
96        // Apply pending migrations.
97        migrations::runner()
98            .run(&mut conn)
99            .map_err(|e| StorageError::MigrationError(e.to_string()))?;
100
101        // Populate the symbol-index tables from any existing `files_ir`
102        // rows.  Gated on "symbol_definitions empty AND files_ir non-empty"
103        // so re-opening a populated DB is a no-op.
104        backfill_symbol_index(&conn).map_err(|e| {
105            StorageError::MigrationError(format!("V13 symbol-index backfill failed: {e}"))
106        })?;
107
108        Ok(Self {
109            conn: Arc::new(Mutex::new(conn)),
110        })
111    }
112
113    /// Returns a reference to the underlying connection wrapped in `Arc<Mutex<_>>`.
114    pub fn connection(&self) -> &Arc<Mutex<Connection>> {
115        &self.conn
116    }
117}
118
119/// Populate `symbol_definitions` and `symbol_imports` for every row in
120/// `files_ir` whose IR matches the current schema version.
121///
122/// Idempotent:
123/// - Skips the whole pass when `symbol_definitions` is already non-empty
124///   (any earlier successful backfill or scan will have inserted rows).
125/// - When it does run, it `DELETE`s the existing rows for each
126///   `(branch_id, file_path)` before inserting, so re-running on a partially
127///   populated DB still produces the right end state.
128///
129/// Stale IR rows (rows with an older `ir_schema_version`) are skipped — they
130/// will be re-scanned and indexed when the user next runs `seshat scan`,
131/// matching how the file-IR layer already treats them.
132fn backfill_symbol_index(conn: &Connection) -> Result<(), StorageError> {
133    // Gate on `symbol_definitions` only — `symbol_imports` is allowed to be
134    // legitimately empty for a project with no concrete-named imports (e.g.
135    // single-file unit-test fixtures).  Real-world risk of a "definitions
136    // populated, imports table externally truncated" half-state is mitigated
137    // by the fact that the backfill itself runs inside a single transaction
138    // (so a crash mid-flight rolls back the entire write).
139    let already_populated: i64 = conn.query_row(
140        "SELECT EXISTS(SELECT 1 FROM symbol_definitions LIMIT 1)",
141        [],
142        |row| row.get(0),
143    )?;
144    if already_populated != 0 {
145        return Ok(());
146    }
147
148    let files_ir_total: i64 =
149        conn.query_row("SELECT COUNT(*) FROM files_ir", [], |row| row.get(0))?;
150    if files_ir_total == 0 {
151        return Ok(());
152    }
153
154    // Materialise the (branch_id, file_path, ir_data) triples first so the
155    // prepared SELECT is dropped before we BEGIN the write transaction.
156    // SQLite tolerates nested statements on the same connection, but keeping
157    // read/write phases separate avoids depending on that.
158    struct StaleRow {
159        branch_id: String,
160        file_path: String,
161        ir_data: Vec<u8>,
162    }
163    let rows: Vec<StaleRow> = {
164        let mut stmt = conn.prepare(
165            "SELECT branch_id, file_path, ir_data FROM files_ir
166             WHERE ir_schema_version = ?1",
167        )?;
168        let iter = stmt.query_map(params![i64::from(IR_SCHEMA_VERSION)], |row| {
169            Ok(StaleRow {
170                branch_id: row.get(0)?,
171                file_path: row.get(1)?,
172                ir_data: row.get(2)?,
173            })
174        })?;
175        iter.collect::<Result<Vec<_>, _>>()?
176    };
177
178    let tx = conn
179        .unchecked_transaction()
180        .map_err(|e| StorageError::QueryError(format!("begin V13 backfill tx: {e}")))?;
181
182    let mut indexed = 0_u64;
183    let mut skipped = 0_u64;
184
185    {
186        let mut delete_defs = tx.prepare_cached(
187            "DELETE FROM symbol_definitions WHERE branch_id = ?1 AND file_path = ?2",
188        )?;
189        let mut delete_imps = tx.prepare_cached(
190            "DELETE FROM symbol_imports WHERE branch_id = ?1 AND importer_file = ?2",
191        )?;
192        let mut insert_def = tx.prepare_cached(
193            "INSERT INTO symbol_definitions
194                (branch_id, symbol_name, file_path, line, end_line, kind, is_public, snippet)
195             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
196        )?;
197        let mut insert_imp = tx.prepare_cached(
198            "INSERT INTO symbol_imports (branch_id, imported_name, importer_file)
199             VALUES (?1, ?2, ?3)",
200        )?;
201
202        for row in rows {
203            let project_file = match deserialize_ir(&row.ir_data) {
204                Ok(pf) => pf,
205                Err(e) => {
206                    tracing::warn!(
207                        "V13 backfill: skipping {}:{} — IR deserialize failed: {e}",
208                        row.branch_id,
209                        row.file_path,
210                    );
211                    skipped += 1;
212                    continue;
213                }
214            };
215
216            delete_defs.execute(params![row.branch_id, row.file_path])?;
217            delete_imps.execute(params![row.branch_id, row.file_path])?;
218
219            for def in extract_definitions(&project_file) {
220                insert_def.execute(params![
221                    row.branch_id,
222                    def.symbol_name,
223                    def.file_path,
224                    def.line,
225                    def.end_line,
226                    def.kind.as_str(),
227                    i64::from(def.is_public),
228                    def.snippet,
229                ])?;
230            }
231            for imp in extract_imports(&project_file) {
232                insert_imp
233                    .execute(params![row.branch_id, imp.imported_name, imp.importer_file,])?;
234            }
235            indexed += 1;
236        }
237    }
238
239    tx.commit()
240        .map_err(|e| StorageError::QueryError(format!("commit V13 backfill tx: {e}")))?;
241
242    if skipped > 0 {
243        tracing::info!(
244            "V13 backfill: indexed {indexed} files, skipped {skipped} stale files \
245             (run `seshat scan` to re-index them)",
246        );
247    } else {
248        tracing::info!("V13 backfill: indexed {indexed} files");
249    }
250    Ok(())
251}
252
253/// Detect and delete `files_ir` rows whose serialized blobs were written by a
254/// different (older or future) [`IR_SCHEMA_VERSION`], so that a subsequent scan
255/// can re-parse from scratch instead of hard-failing on deserialize.
256///
257/// `files_ir` is a pure parse cache — every row can be reconstructed by re-parsing
258/// the source file. The derived symbol-index tables (`symbol_definitions`,
259/// `symbol_imports`) are also cleared for any branch that had stale rows, since
260/// they were built from those now-deleted blobs and the next scan will repopulate
261/// them.
262///
263/// User-curated data is intentionally NOT touched: `decisions`, `nodes`, `edges`,
264/// `branches`, `branch_metadata`, `repo_metadata`, `submodules`, `code_embeddings`,
265/// `package_metadata`. Only the IR cache and its derived indexes are reset.
266///
267/// All deletes run inside a single transaction so a crash mid-wipe leaves the DB
268/// untouched.
269///
270/// Returns a [`StaleIrWipeReport`] describing how much was cleared. Callers
271/// should treat an empty report as "no-op" and skip user-facing logging.
272pub fn wipe_stale_ir_cache(db: &Database) -> Result<StaleIrWipeReport, StorageError> {
273    let conn = db.conn.lock().map_err(|e| {
274        StorageError::QueryError(format!("acquire connection lock for IR-cache wipe: {e}"))
275    })?;
276    wipe_stale_ir_cache_on(&conn)
277}
278
279/// Same as [`wipe_stale_ir_cache`] but operates on a borrowed [`Connection`].
280///
281/// Exposed for internal callers that already hold the connection lock (avoids
282/// re-entrant locking on the [`Arc<Mutex<Connection>>`]).
283fn wipe_stale_ir_cache_on(conn: &Connection) -> Result<StaleIrWipeReport, StorageError> {
284    // Collect distinct cached versions and the set of branches with stale rows
285    // in one pass — used both for diagnostics and to scope the symbol-index wipe.
286    struct StaleSummary {
287        cached_versions: Vec<u8>,
288        affected_branches: Vec<String>,
289        total: u64,
290    }
291    let summary: StaleSummary = {
292        // `ir_schema_version != ?1` does NOT match NULL rows in SQLite (NULL
293        // comparisons return UNKNOWN). The column is `NOT NULL DEFAULT 0` per
294        // migration V7, but an externally-modified DB or a NULL leaking past
295        // the constraint would otherwise escape the wipe and crash the scan
296        // later. `IS NOT ?1` treats NULL as unequal, catching that case too.
297        let mut stmt = conn.prepare(
298            "SELECT COALESCE(ir_schema_version, 0), branch_id, COUNT(*) FROM files_ir
299             WHERE ir_schema_version IS NOT ?1
300             GROUP BY ir_schema_version, branch_id",
301        )?;
302        let rows = stmt.query_map(params![i64::from(IR_SCHEMA_VERSION)], |row| {
303            Ok((
304                row.get::<_, i64>(0)?,
305                row.get::<_, String>(1)?,
306                row.get::<_, i64>(2)?,
307            ))
308        })?;
309
310        let mut versions: std::collections::BTreeSet<u8> = std::collections::BTreeSet::new();
311        let mut branches: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
312        let mut total: u64 = 0;
313        for row in rows {
314            let (version, branch, count) = row?;
315            // Clamp out-of-range versions (shouldn't happen — column is u8-shaped
316            // but stored as INTEGER) to `u8::MAX` so they still surface in logs
317            // as "unknown stale version".
318            let v: u8 = u8::try_from(version).unwrap_or(u8::MAX);
319            versions.insert(v);
320            branches.insert(branch);
321            total = total.saturating_add(u64::try_from(count).unwrap_or(0));
322        }
323        StaleSummary {
324            cached_versions: versions.into_iter().collect(),
325            affected_branches: branches.into_iter().collect(),
326            total,
327        }
328    };
329
330    if summary.total == 0 {
331        return Ok(StaleIrWipeReport::default());
332    }
333
334    // `unchecked_transaction` (not `Connection::transaction`) — the latter
335    // takes `&mut Connection` but we only hold `&Connection` here (the
336    // caller's MutexGuard yields shared access). Safety is fine: we have
337    // exclusive access via the mutex for the duration of this call.
338    let tx = conn
339        .unchecked_transaction()
340        .map_err(|e| StorageError::QueryError(format!("begin IR-cache wipe tx: {e}")))?;
341
342    // Mirror the NULL-aware predicate from the summary SELECT.
343    let stale_count = tx.execute(
344        "DELETE FROM files_ir WHERE ir_schema_version IS NOT ?1",
345        params![i64::from(IR_SCHEMA_VERSION)],
346    )? as u64;
347
348    // Wipe derived symbol-index rows for every affected branch. We do this
349    // per-branch rather than globally so that other branches whose IR is still
350    // current keep their symbol-index intact (cheaper than a full backfill on
351    // the next open).
352    let mut defs_cleared: u64 = 0;
353    let mut imps_cleared: u64 = 0;
354    {
355        let mut del_defs =
356            tx.prepare_cached("DELETE FROM symbol_definitions WHERE branch_id = ?1")?;
357        let mut del_imps = tx.prepare_cached("DELETE FROM symbol_imports WHERE branch_id = ?1")?;
358        for branch in &summary.affected_branches {
359            defs_cleared = defs_cleared.saturating_add(del_defs.execute(params![branch])? as u64);
360            imps_cleared = imps_cleared.saturating_add(del_imps.execute(params![branch])? as u64);
361        }
362    }
363
364    tx.commit()
365        .map_err(|e| StorageError::QueryError(format!("commit IR-cache wipe tx: {e}")))?;
366
367    Ok(StaleIrWipeReport {
368        stale_count,
369        cached_versions: summary.cached_versions,
370        symbol_definitions_cleared: defs_cleared,
371        symbol_imports_cleared: imps_cleared,
372    })
373}
374
375#[cfg(test)]
376mod tests {
377    use super::*;
378    use std::fs;
379    use std::path::PathBuf;
380
381    /// Helper: create a temporary directory that is cleaned up on drop.
382    struct TempDir(PathBuf);
383
384    impl TempDir {
385        fn new(name: &str) -> Self {
386            let dir =
387                std::env::temp_dir().join(format!("seshat_test_{name}_{}", std::process::id()));
388            fs::create_dir_all(&dir).unwrap();
389            Self(dir)
390        }
391
392        fn path(&self) -> &Path {
393            &self.0
394        }
395    }
396
397    impl Drop for TempDir {
398        fn drop(&mut self) {
399            let _ = fs::remove_dir_all(&self.0);
400        }
401    }
402
403    #[test]
404    fn migration_applies_on_fresh_in_memory_db() {
405        let db = Database::open(":memory:").expect("should open in-memory DB");
406        let conn = db.connection().lock().unwrap();
407
408        // Verify all five tables exist.
409        let tables: Vec<String> = conn
410            .prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
411            .unwrap()
412            .query_map([], |row| row.get(0))
413            .unwrap()
414            .filter_map(|r| r.ok())
415            .collect();
416
417        assert!(tables.contains(&"nodes".to_string()), "missing nodes table");
418        assert!(tables.contains(&"edges".to_string()), "missing edges table");
419        assert!(
420            tables.contains(&"files_ir".to_string()),
421            "missing files_ir table"
422        );
423        assert!(
424            tables.contains(&"metadata".to_string()),
425            "missing metadata table"
426        );
427        assert!(
428            tables.contains(&"package_metadata".to_string()),
429            "missing package_metadata table"
430        );
431        assert!(
432            tables.contains(&"code_embeddings".to_string()),
433            "missing code_embeddings table"
434        );
435        assert!(
436            tables.contains(&"symbol_definitions".to_string()),
437            "missing symbol_definitions table"
438        );
439        assert!(
440            tables.contains(&"symbol_imports".to_string()),
441            "missing symbol_imports table"
442        );
443        assert!(
444            tables.contains(&"branch_metadata".to_string()),
445            "missing branch_metadata table"
446        );
447
448        // Verify indexes exist.
449        let indexes: Vec<String> = conn
450            .prepare("SELECT name FROM sqlite_master WHERE type='index' AND name LIKE 'idx_%' ORDER BY name")
451            .unwrap()
452            .query_map([], |row| row.get(0))
453            .unwrap()
454            .filter_map(|r| r.ok())
455            .collect();
456
457        assert!(
458            indexes.contains(&"idx_nodes_branch_id".to_string()),
459            "missing idx_nodes_branch_id"
460        );
461        assert!(
462            indexes.contains(&"idx_nodes_nature".to_string()),
463            "missing idx_nodes_nature"
464        );
465        assert!(
466            indexes.contains(&"idx_edges_source_id".to_string()),
467            "missing idx_edges_source_id"
468        );
469        assert!(
470            indexes.contains(&"idx_edges_target_id".to_string()),
471            "missing idx_edges_target_id"
472        );
473        assert!(
474            indexes.contains(&"idx_files_ir_branch_path".to_string()),
475            "missing idx_files_ir_branch_path"
476        );
477        assert!(
478            indexes.contains(&"idx_package_metadata_registry".to_string()),
479            "missing idx_package_metadata_registry"
480        );
481        assert!(
482            indexes.contains(&"idx_package_metadata_fetched_at".to_string()),
483            "missing idx_package_metadata_fetched_at"
484        );
485        assert!(
486            indexes.contains(&"idx_symbol_definitions_branch_name".to_string()),
487            "missing idx_symbol_definitions_branch_name"
488        );
489        assert!(
490            indexes.contains(&"idx_symbol_imports_branch_name".to_string()),
491            "missing idx_symbol_imports_branch_name"
492        );
493    }
494
495    // ── V14 branch_metadata migration tests ──────────────────────────────
496
497    #[test]
498    fn v14_migration_is_idempotent_on_reopen() {
499        // Opening the same on-disk DB twice must not re-fail the V14 step.
500        // Refinery already skips already-applied migrations, but the SQL
501        // itself also uses CREATE TABLE/INDEX IF NOT EXISTS so this also
502        // exercises the "apply twice on a fresh DB" path implicitly.
503        let tmp = TempDir::new("v14_idempotent");
504        let db_path = tmp.path().join("test.db");
505
506        let _db1 = Database::open(&db_path).expect("first open should apply V14");
507        // Drop and re-open — second open re-runs migrations::runner() which
508        // must see V14 already applied and become a no-op.
509        let db2 = Database::open(&db_path).expect("second open should not re-error on V14");
510
511        let conn = db2.connection().lock().unwrap();
512        let count: i64 = conn
513            .query_row(
514                "SELECT count(*) FROM sqlite_master WHERE type='table' AND name='branch_metadata'",
515                [],
516                |row| row.get(0),
517            )
518            .unwrap();
519        assert_eq!(count, 1, "branch_metadata table must exist after reopen");
520    }
521
522    #[test]
523    fn v14_does_not_disturb_repo_metadata() {
524        // repo_metadata is the table V14 is migrating *away* from. Make sure
525        // V14 leaves any existing rows in it alone — workspace_crates may
526        // still be sitting there from a pre-V14 scan, and the migration
527        // must not destroy that data (the read-site cut-over is handled
528        // separately, in load_internal_names).
529        let db = Database::open(":memory:").expect("open db");
530        let conn = db.connection().lock().unwrap();
531
532        // Seed a repo_metadata row that pre-dates V14.
533        conn.execute(
534            "INSERT INTO repo_metadata (key, value) VALUES (?1, ?2)",
535            params!["workspace_crates", "[\"legacy\"]"],
536        )
537        .expect("seed repo_metadata");
538
539        let value: String = conn
540            .query_row(
541                "SELECT value FROM repo_metadata WHERE key = ?1",
542                params!["workspace_crates"],
543                |row| row.get(0),
544            )
545            .expect("repo_metadata row still readable");
546        assert_eq!(value, "[\"legacy\"]");
547    }
548
549    #[test]
550    fn v14_branch_metadata_cascades_on_branch_delete() {
551        // FK with ON DELETE CASCADE: deleting a branches row must remove its
552        // branch_metadata rows automatically. This is the contract that
553        // BranchRepository::delete_branch (and snapshot teardown) relies on.
554        let db = Database::open(":memory:").expect("open db");
555        let conn = db.connection().lock().unwrap();
556
557        conn.execute(
558            "INSERT INTO branches (branch_id) VALUES (?1)",
559            params!["feat-x"],
560        )
561        .expect("insert parent branch");
562        conn.execute(
563            "INSERT INTO branch_metadata (branch_id, key, value) VALUES (?1, ?2, ?3)",
564            params!["feat-x", "workspace_crates", "[\"a\",\"b\"]"],
565        )
566        .expect("insert branch_metadata");
567
568        // Row is present before delete.
569        let before: i64 = conn
570            .query_row(
571                "SELECT count(*) FROM branch_metadata WHERE branch_id = ?1",
572                params!["feat-x"],
573                |row| row.get(0),
574            )
575            .unwrap();
576        assert_eq!(before, 1, "branch_metadata row must exist before cascade");
577
578        conn.execute(
579            "DELETE FROM branches WHERE branch_id = ?1",
580            params!["feat-x"],
581        )
582        .expect("delete parent branch");
583
584        // FK cascade should have removed the dependent row.
585        let after: i64 = conn
586            .query_row(
587                "SELECT count(*) FROM branch_metadata WHERE branch_id = ?1",
588                params!["feat-x"],
589                |row| row.get(0),
590            )
591            .unwrap();
592        assert_eq!(
593            after, 0,
594            "branch_metadata row must cascade-delete with parent branch"
595        );
596    }
597
598    #[test]
599    fn v14_branch_metadata_primary_key_upserts_on_conflict() {
600        // The composite PRIMARY KEY (branch_id, key) is what makes the
601        // SqliteBranchMetadataRepository::set UPSERT work; lock it here at
602        // the schema level so an accidental schema change in the future
603        // trips this test instead of silently breaking writers.
604        let db = Database::open(":memory:").expect("open db");
605        let conn = db.connection().lock().unwrap();
606
607        conn.execute(
608            "INSERT INTO branches (branch_id) VALUES (?1)",
609            params!["b1"],
610        )
611        .expect("insert branch");
612        conn.execute(
613            "INSERT INTO branch_metadata (branch_id, key, value) VALUES (?1, ?2, ?3)",
614            params!["b1", "k", "v1"],
615        )
616        .expect("insert v1");
617
618        // Same (branch_id, key) — must collide on the PK and be rejected
619        // without ON CONFLICT clause, proving the PK is enforced.
620        let result = conn.execute(
621            "INSERT INTO branch_metadata (branch_id, key, value) VALUES (?1, ?2, ?3)",
622            params!["b1", "k", "v2"],
623        );
624        assert!(
625            result.is_err(),
626            "duplicate (branch_id, key) must violate PRIMARY KEY"
627        );
628
629        // ON CONFLICT UPSERT clause must succeed.
630        conn.execute(
631            "INSERT INTO branch_metadata (branch_id, key, value) VALUES (?1, ?2, ?3) \
632             ON CONFLICT(branch_id, key) DO UPDATE SET value = excluded.value",
633            params!["b1", "k", "v2"],
634        )
635        .expect("upsert on conflict");
636
637        let value: String = conn
638            .query_row(
639                "SELECT value FROM branch_metadata WHERE branch_id = ?1 AND key = ?2",
640                params!["b1", "k"],
641                |row| row.get(0),
642            )
643            .unwrap();
644        assert_eq!(value, "v2", "upsert must overwrite value on conflict");
645    }
646
647    #[test]
648    fn open_sets_busy_timeout() {
649        let db = Database::open(":memory:").expect("should open");
650        let conn = db.connection().lock().unwrap();
651
652        // rusqlite::Connection has no `busy_timeout` getter, so probe it
653        // through PRAGMA. Value is in milliseconds.
654        let timeout: i64 = conn
655            .query_row("PRAGMA busy_timeout", [], |row| row.get(0))
656            .expect("query busy_timeout");
657
658        assert_eq!(
659            timeout,
660            i64::try_from(BUSY_TIMEOUT_MS).unwrap(),
661            "Database::open must configure busy_timeout to {BUSY_TIMEOUT_MS} ms; \
662             a value of 0 makes concurrent writers fail with SQLITE_BUSY immediately."
663        );
664    }
665
666    #[test]
667    fn concurrent_writer_waits_instead_of_failing_with_busy() {
668        // Two separate Database handles on the same on-disk file simulate
669        // two processes (e.g. `seshat scan` racing `seshat serve`). The first
670        // holds an exclusive write txn for ~200 ms; the second's write must
671        // succeed instead of returning SQLITE_BUSY.
672        let tmp = TempDir::new("busy_timeout");
673        let db_path = tmp.path().join("test.db");
674
675        let db1 = Database::open(&db_path).expect("open db1");
676        let db2 = Database::open(&db_path).expect("open db2");
677
678        let writer = std::thread::spawn(move || {
679            let conn = db1.connection().lock().unwrap();
680            // BEGIN IMMEDIATE acquires the RESERVED write lock straight away.
681            conn.execute("BEGIN IMMEDIATE", [])
682                .expect("begin immediate");
683            conn.execute(
684                "INSERT INTO metadata (key, value) VALUES (?1, ?2)",
685                rusqlite::params!["writer1", "value1"],
686            )
687            .expect("insert in writer1");
688            std::thread::sleep(std::time::Duration::from_millis(200));
689            conn.execute("COMMIT", []).expect("commit writer1");
690        });
691
692        // Give writer1 enough time to take the lock.
693        std::thread::sleep(std::time::Duration::from_millis(50));
694
695        let started_at = std::time::Instant::now();
696        let result = {
697            let conn = db2.connection().lock().unwrap();
698            conn.execute(
699                "INSERT INTO metadata (key, value) VALUES (?1, ?2)",
700                rusqlite::params!["writer2", "value2"],
701            )
702        };
703        let waited = started_at.elapsed();
704
705        writer.join().expect("writer1 thread");
706
707        assert!(
708            result.is_ok(),
709            "concurrent writer must succeed (waited busy_timeout, then proceeded), \
710             got: {result:?}"
711        );
712        assert!(
713            waited >= std::time::Duration::from_millis(50),
714            "concurrent writer must have waited for the held lock, but returned in {waited:?}"
715        );
716        assert!(
717            waited < std::time::Duration::from_millis(BUSY_TIMEOUT_MS),
718            "concurrent writer should not have hit the full busy_timeout ceiling \
719             (writer1 only held the lock for ~200 ms), but waited {waited:?}"
720        );
721    }
722
723    // ── V13 symbol-index backfill tests ──────────────────────────────────
724
725    /// Build a Rust IR `ProjectFile` with one public function, one type, one
726    /// export, and a mix of concrete + wildcard + namespace imports.
727    fn rust_fixture(path: &str) -> seshat_core::ProjectFile {
728        use seshat_core::{
729            Export, Function, Import, Language, LanguageIR, ProjectFile, RustIR, TypeDef,
730            TypeDefKind,
731        };
732
733        ProjectFile {
734            path: PathBuf::from(path),
735            language: Language::Rust,
736            content_hash: "h".to_owned(),
737            imports: vec![
738                Import {
739                    module: "foo".to_owned(),
740                    names: vec!["Bar".to_owned()],
741                    is_type_only: false,
742                    line: 1,
743                },
744                Import {
745                    module: "wild".to_owned(),
746                    names: vec!["*".to_owned()],
747                    is_type_only: false,
748                    line: 2,
749                },
750            ],
751            exports: vec![Export {
752                name: "exported".to_owned(),
753                is_default: false,
754                is_type_only: false,
755                line: 30,
756                end_line: 30,
757            }],
758            functions: vec![Function {
759                name: "do_thing".to_owned(),
760                is_public: true,
761                is_async: false,
762                line: 10,
763                end_line: 12,
764                parameters: vec!["x".to_owned()],
765                doc_comment: None,
766            }],
767            types: vec![TypeDef {
768                name: "Widget".to_owned(),
769                kind: TypeDefKind::Struct,
770                is_public: true,
771                line: 20,
772                end_line: 25,
773                doc_comment: None,
774            }],
775            dependencies_used: Vec::new(),
776            language_ir: LanguageIR::Rust(RustIR::default()),
777            file_doc: None,
778        }
779    }
780
781    fn python_fixture(path: &str) -> seshat_core::ProjectFile {
782        use seshat_core::{
783            Function, Import, Language, LanguageIR, ProjectFile, PythonIR, TypeDef, TypeDefKind,
784        };
785
786        ProjectFile {
787            path: PathBuf::from(path),
788            language: Language::Python,
789            content_hash: "h".to_owned(),
790            imports: vec![Import {
791                module: "os".to_owned(),
792                names: vec!["path".to_owned()],
793                is_type_only: false,
794                line: 1,
795            }],
796            exports: Vec::new(),
797            functions: vec![Function {
798                name: "helper".to_owned(),
799                is_public: false,
800                is_async: false,
801                line: 5,
802                end_line: 7,
803                parameters: vec![],
804                doc_comment: None,
805            }],
806            types: vec![TypeDef {
807                name: "MyClass".to_owned(),
808                kind: TypeDefKind::Class,
809                is_public: true,
810                line: 10,
811                end_line: 20,
812                doc_comment: None,
813            }],
814            dependencies_used: Vec::new(),
815            language_ir: LanguageIR::Python(PythonIR::default()),
816            file_doc: None,
817        }
818    }
819
820    fn ts_fixture(path: &str) -> seshat_core::ProjectFile {
821        use seshat_core::{
822            Export, Function, Import, Language, LanguageIR, ProjectFile, TypeDef, TypeDefKind,
823            TypeScriptIR,
824        };
825
826        ProjectFile {
827            path: PathBuf::from(path),
828            language: Language::TypeScript,
829            content_hash: "h".to_owned(),
830            imports: vec![
831                Import {
832                    module: "react".to_owned(),
833                    names: vec!["React".to_owned()],
834                    is_type_only: false,
835                    line: 1,
836                },
837                Import {
838                    module: "namespaced".to_owned(),
839                    names: vec!["* as alias".to_owned()],
840                    is_type_only: false,
841                    line: 2,
842                },
843            ],
844            exports: vec![Export {
845                name: "App".to_owned(),
846                is_default: true,
847                is_type_only: false,
848                line: 10,
849                end_line: 30,
850            }],
851            functions: vec![Function {
852                name: "App".to_owned(),
853                is_public: true,
854                is_async: false,
855                line: 10,
856                end_line: 30,
857                parameters: vec![],
858                doc_comment: None,
859            }],
860            types: vec![TypeDef {
861                name: "AppProps".to_owned(),
862                kind: TypeDefKind::Interface,
863                is_public: true,
864                line: 5,
865                end_line: 8,
866                doc_comment: None,
867            }],
868            dependencies_used: Vec::new(),
869            language_ir: LanguageIR::TypeScript(TypeScriptIR::default()),
870            file_doc: None,
871        }
872    }
873
874    fn js_fixture(path: &str) -> seshat_core::ProjectFile {
875        use seshat_core::{
876            Export, Function, Import, JavaScriptIR, Language, LanguageIR, ProjectFile, TypeDef,
877            TypeDefKind,
878        };
879
880        ProjectFile {
881            path: PathBuf::from(path),
882            language: Language::JavaScript,
883            content_hash: "h".to_owned(),
884            imports: vec![Import {
885                module: "lodash".to_owned(),
886                names: vec!["map".to_owned()],
887                is_type_only: false,
888                line: 1,
889            }],
890            exports: vec![Export {
891                name: "handler".to_owned(),
892                is_default: false,
893                is_type_only: false,
894                line: 12,
895                end_line: 25,
896            }],
897            functions: vec![Function {
898                name: "handler".to_owned(),
899                is_public: true,
900                is_async: true,
901                line: 12,
902                end_line: 25,
903                parameters: vec![],
904                doc_comment: None,
905            }],
906            types: vec![TypeDef {
907                name: "Handler".to_owned(),
908                kind: TypeDefKind::Class,
909                is_public: true,
910                line: 4,
911                end_line: 10,
912                doc_comment: None,
913            }],
914            dependencies_used: Vec::new(),
915            language_ir: LanguageIR::JavaScript(JavaScriptIR::default()),
916            file_doc: None,
917        }
918    }
919
920    /// Insert a `files_ir` row directly with serialized IR — bypasses the
921    /// repository so we can simulate "DB existed before V13 ran".
922    fn insert_files_ir_row(conn: &Connection, branch: &str, file: &seshat_core::ProjectFile) {
923        let ir_bytes = crate::ir_serialization::serialize_ir(file).expect("serialize");
924        conn.execute(
925            "INSERT INTO files_ir
926                (branch_id, file_path, language, content_hash, ir_data, ir_schema_version,
927                 last_commit_date, updated_at)
928             VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL, datetime('now'))",
929            params![
930                branch,
931                file.path.to_string_lossy().as_ref(),
932                file.language.as_str(),
933                file.content_hash,
934                ir_bytes,
935                i64::from(IR_SCHEMA_VERSION),
936            ],
937        )
938        .expect("insert files_ir row");
939    }
940
941    fn count_rows(conn: &Connection, sql: &str) -> i64 {
942        conn.query_row(sql, [], |row| row.get(0)).expect("count")
943    }
944
945    #[test]
946    fn backfill_noop_on_fresh_in_memory_db() {
947        // Empty DB: no files_ir rows → backfill must short-circuit and leave
948        // both symbol tables empty.
949        let db = Database::open(":memory:").expect("open");
950        let conn = db.connection().lock().unwrap();
951        assert_eq!(
952            count_rows(&conn, "SELECT COUNT(*) FROM symbol_definitions"),
953            0
954        );
955        assert_eq!(count_rows(&conn, "SELECT COUNT(*) FROM symbol_imports"), 0);
956    }
957
958    #[test]
959    fn backfill_populates_pre_v13_db_on_next_open() {
960        // Simulate an existing DB whose files_ir was populated before V13
961        // existed: open the (already-migrated) DB once, seed files_ir, wipe
962        // symbol_definitions, then `backfill_symbol_index` should refill it.
963        let tmp = TempDir::new("backfill_populate");
964        let db_path = tmp.path().join("test.db");
965
966        {
967            let db = Database::open(&db_path).expect("first open");
968            let conn = db.connection().lock().unwrap();
969            insert_files_ir_row(&conn, "main", &rust_fixture("src/lib.rs"));
970            insert_files_ir_row(&conn, "main", &python_fixture("pkg/mod.py"));
971            insert_files_ir_row(&conn, "main", &ts_fixture("src/app.tsx"));
972            insert_files_ir_row(&conn, "main", &js_fixture("src/handler.js"));
973            // Wipe the symbol tables so the second open's backfill gate
974            // ("symbol_definitions empty") fires.
975            conn.execute("DELETE FROM symbol_definitions", []).unwrap();
976            conn.execute("DELETE FROM symbol_imports", []).unwrap();
977        }
978
979        {
980            let db = Database::open(&db_path).expect("second open");
981            let conn = db.connection().lock().unwrap();
982            // Rust: fn + type + export = 3.  Python: fn + type = 2 (no export).
983            // TS:   fn + type + export = 3.  JS:   fn + type + export = 3.
984            // Total = 11 definitions.
985            assert_eq!(
986                count_rows(&conn, "SELECT COUNT(*) FROM symbol_definitions"),
987                11
988            );
989            // Imports: Rust → 1 ("Bar"), Python → 1 ("path"), TS → 1 ("React")
990            // (wildcards filtered), JS → 1 ("map").  Total = 4.
991            assert_eq!(count_rows(&conn, "SELECT COUNT(*) FROM symbol_imports"), 4);
992        }
993    }
994
995    #[test]
996    fn backfill_is_idempotent_running_twice() {
997        // Running the backfill on an already-populated DB should be a no-op —
998        // counts stay stable.
999        let tmp = TempDir::new("backfill_idempotent");
1000        let db_path = tmp.path().join("test.db");
1001
1002        {
1003            let db = Database::open(&db_path).expect("first open");
1004            let conn = db.connection().lock().unwrap();
1005            insert_files_ir_row(&conn, "main", &rust_fixture("src/lib.rs"));
1006            conn.execute("DELETE FROM symbol_definitions", []).unwrap();
1007            conn.execute("DELETE FROM symbol_imports", []).unwrap();
1008        }
1009
1010        let counts_after_first = {
1011            let db = Database::open(&db_path).expect("second open");
1012            let conn = db.connection().lock().unwrap();
1013            (
1014                count_rows(&conn, "SELECT COUNT(*) FROM symbol_definitions"),
1015                count_rows(&conn, "SELECT COUNT(*) FROM symbol_imports"),
1016            )
1017        };
1018
1019        // Third open — symbol_definitions is non-empty so the gate skips the
1020        // backfill; counts must not change.
1021        let counts_after_second = {
1022            let db = Database::open(&db_path).expect("third open");
1023            let conn = db.connection().lock().unwrap();
1024            (
1025                count_rows(&conn, "SELECT COUNT(*) FROM symbol_definitions"),
1026                count_rows(&conn, "SELECT COUNT(*) FROM symbol_imports"),
1027            )
1028        };
1029
1030        assert_eq!(counts_after_first, counts_after_second);
1031        assert_eq!(counts_after_first.0, 3);
1032        assert_eq!(counts_after_first.1, 1);
1033    }
1034
1035    #[test]
1036    fn backfill_excludes_defining_file_imports_for_wildcards() {
1037        // The IR `imports` contains a wildcard plus a concrete name — only
1038        // the concrete one should land in `symbol_imports`.
1039        let tmp = TempDir::new("backfill_wildcards");
1040        let db_path = tmp.path().join("test.db");
1041
1042        {
1043            let db = Database::open(&db_path).expect("open");
1044            let conn = db.connection().lock().unwrap();
1045            insert_files_ir_row(&conn, "main", &rust_fixture("src/lib.rs"));
1046            conn.execute("DELETE FROM symbol_definitions", []).unwrap();
1047            conn.execute("DELETE FROM symbol_imports", []).unwrap();
1048        }
1049
1050        let db = Database::open(&db_path).expect("open after seed");
1051        let conn = db.connection().lock().unwrap();
1052
1053        let imports: Vec<String> = conn
1054            .prepare("SELECT imported_name FROM symbol_imports ORDER BY imported_name")
1055            .unwrap()
1056            .query_map([], |row| row.get::<_, String>(0))
1057            .unwrap()
1058            .filter_map(Result::ok)
1059            .collect();
1060
1061        assert_eq!(imports, vec!["Bar".to_owned()]);
1062    }
1063
1064    // ── wipe_stale_ir_cache tests ─────────────────────────────────────────
1065
1066    /// Insert a `files_ir` row with the given (possibly out-of-version) IR
1067    /// schema version. Used to simulate a DB written by a prior binary.
1068    fn insert_files_ir_row_with_version(
1069        conn: &Connection,
1070        branch: &str,
1071        file_path: &str,
1072        ir_schema_version: i64,
1073    ) {
1074        // For non-current versions we use a placeholder blob — wipe must not
1075        // attempt to deserialize it.
1076        let blob: Vec<u8> = vec![0u8, 0u8, 0u8];
1077        conn.execute(
1078            "INSERT INTO files_ir
1079                (branch_id, file_path, language, content_hash, ir_data, ir_schema_version,
1080                 last_commit_date, updated_at)
1081             VALUES (?1, ?2, 'rust', 'h', ?3, ?4, NULL, datetime('now'))",
1082            params![branch, file_path, blob, ir_schema_version],
1083        )
1084        .expect("insert files_ir row with version");
1085    }
1086
1087    #[test]
1088    fn wipe_stale_ir_cache_noop_on_empty_db() {
1089        let db = Database::open(":memory:").expect("open");
1090        let report = wipe_stale_ir_cache(&db).expect("wipe");
1091        assert!(report.is_empty());
1092        assert_eq!(report.stale_count, 0);
1093        assert!(report.cached_versions.is_empty());
1094    }
1095
1096    #[test]
1097    fn wipe_stale_ir_cache_noop_when_all_rows_current() {
1098        let db = Database::open(":memory:").expect("open");
1099        {
1100            let conn = db.connection().lock().unwrap();
1101            insert_files_ir_row(&conn, "main", &rust_fixture("src/lib.rs"));
1102        }
1103        let report = wipe_stale_ir_cache(&db).expect("wipe");
1104        assert!(report.is_empty(), "current-version rows must not be wiped");
1105
1106        let conn = db.connection().lock().unwrap();
1107        assert_eq!(count_rows(&conn, "SELECT COUNT(*) FROM files_ir"), 1);
1108    }
1109
1110    #[test]
1111    fn wipe_stale_ir_cache_clears_v7_rows_and_reports_versions() {
1112        let db = Database::open(":memory:").expect("open");
1113        {
1114            let conn = db.connection().lock().unwrap();
1115            // Three v7 rows, two v6 rows, one current.
1116            insert_files_ir_row_with_version(&conn, "main", "a.rs", 7);
1117            insert_files_ir_row_with_version(&conn, "main", "b.rs", 7);
1118            insert_files_ir_row_with_version(&conn, "main", "c.rs", 7);
1119            insert_files_ir_row_with_version(&conn, "main", "d.rs", 6);
1120            insert_files_ir_row_with_version(&conn, "main", "e.rs", 6);
1121            insert_files_ir_row(&conn, "main", &rust_fixture("src/fresh.rs"));
1122        }
1123
1124        let report = wipe_stale_ir_cache(&db).expect("wipe");
1125        assert_eq!(report.stale_count, 5, "must wipe both v6 and v7 rows");
1126        assert_eq!(
1127            report.cached_versions,
1128            vec![6, 7],
1129            "must report distinct cached versions ascending"
1130        );
1131
1132        let conn = db.connection().lock().unwrap();
1133        // Only the current-version row remains.
1134        let remaining: i64 = conn
1135            .query_row("SELECT COUNT(*) FROM files_ir", [], |row| row.get(0))
1136            .unwrap();
1137        assert_eq!(remaining, 1);
1138        let kept_version: i64 = conn
1139            .query_row("SELECT ir_schema_version FROM files_ir", [], |row| {
1140                row.get(0)
1141            })
1142            .unwrap();
1143        assert_eq!(kept_version, i64::from(IR_SCHEMA_VERSION));
1144    }
1145
1146    #[test]
1147    fn wipe_stale_ir_cache_handles_default_zero_version() {
1148        // V7 migration backfilled existing rows with `ir_schema_version = 0`
1149        // for legacy DBs upgraded across that boundary. Make sure that case
1150        // is caught.
1151        let db = Database::open(":memory:").expect("open");
1152        {
1153            let conn = db.connection().lock().unwrap();
1154            insert_files_ir_row_with_version(&conn, "main", "legacy.rs", 0);
1155        }
1156
1157        let report = wipe_stale_ir_cache(&db).expect("wipe");
1158        assert_eq!(report.stale_count, 1);
1159        assert_eq!(report.cached_versions, vec![0]);
1160
1161        let conn = db.connection().lock().unwrap();
1162        let remaining: i64 = conn
1163            .query_row("SELECT COUNT(*) FROM files_ir", [], |row| row.get(0))
1164            .unwrap();
1165        assert_eq!(remaining, 0);
1166    }
1167
1168    #[test]
1169    fn wipe_stale_ir_cache_preserves_decisions_and_other_user_data() {
1170        let db = Database::open(":memory:").expect("open");
1171        {
1172            let conn = db.connection().lock().unwrap();
1173
1174            // Seed a stale IR row.
1175            insert_files_ir_row_with_version(&conn, "main", "stale.rs", 7);
1176
1177            // Seed user-curated decision (project-wide, NOT branch-scoped).
1178            conn.execute(
1179                "INSERT INTO decisions
1180                    (description_hash, description, state, nature, weight,
1181                     decided_on_branch, decided_at)
1182                 VALUES (?1, ?2, 'recorded', 'decision', 'strong', 'main', 1700000000)",
1183                params!["hash_user_1", "Important user decision"],
1184            )
1185            .expect("seed decision");
1186
1187            // Seed nodes / edges / branches / branch_metadata / repo_metadata /
1188            // package_metadata / code_embeddings rows — every table the wipe
1189            // must leave alone.
1190            conn.execute(
1191                "INSERT INTO branches (branch_id) VALUES (?1)",
1192                params!["main"],
1193            )
1194            .expect("seed branch");
1195            conn.execute(
1196                "INSERT INTO branch_metadata (branch_id, key, value) VALUES (?1, ?2, ?3)",
1197                params!["main", "workspace_crates", "[]"],
1198            )
1199            .expect("seed branch_metadata");
1200            conn.execute(
1201                "INSERT INTO nodes (branch_id, nature, weight, confidence, adoption_count, total_count, description)
1202                 VALUES ('main', 'convention', 'strong', 1.0, 1, 1, 'desc')",
1203                [],
1204            )
1205            .expect("seed node");
1206            conn.execute(
1207                "INSERT INTO metadata (key, value) VALUES (?1, ?2)",
1208                params!["project_name", "test"],
1209            )
1210            .expect("seed repo_metadata");
1211        }
1212
1213        let report = wipe_stale_ir_cache(&db).expect("wipe");
1214        assert_eq!(report.stale_count, 1);
1215
1216        let conn = db.connection().lock().unwrap();
1217        // files_ir was the only thing cleared.
1218        assert_eq!(count_rows(&conn, "SELECT COUNT(*) FROM files_ir"), 0);
1219
1220        // Everything else is intact — most importantly, decisions.
1221        assert_eq!(
1222            count_rows(&conn, "SELECT COUNT(*) FROM decisions"),
1223            1,
1224            "user-curated decisions must NOT be touched by an IR-cache wipe"
1225        );
1226        let decision_text: String = conn
1227            .query_row(
1228                "SELECT description FROM decisions WHERE description_hash = ?1",
1229                params!["hash_user_1"],
1230                |row| row.get(0),
1231            )
1232            .unwrap();
1233        assert_eq!(decision_text, "Important user decision");
1234
1235        assert_eq!(count_rows(&conn, "SELECT COUNT(*) FROM nodes"), 1);
1236        assert_eq!(count_rows(&conn, "SELECT COUNT(*) FROM branches"), 1);
1237        assert_eq!(count_rows(&conn, "SELECT COUNT(*) FROM branch_metadata"), 1);
1238        assert_eq!(count_rows(&conn, "SELECT COUNT(*) FROM metadata"), 1);
1239    }
1240
1241    #[test]
1242    fn wipe_stale_ir_cache_clears_symbol_index_for_affected_branches_only() {
1243        let db = Database::open(":memory:").expect("open");
1244        {
1245            let conn = db.connection().lock().unwrap();
1246            // Branch "stale": one stale IR row + some derived symbol-index rows.
1247            insert_files_ir_row_with_version(&conn, "stale", "a.rs", 7);
1248            conn.execute(
1249                "INSERT INTO symbol_definitions
1250                    (branch_id, symbol_name, file_path, line, end_line, kind, is_public, snippet)
1251                 VALUES ('stale','foo','a.rs',1,2,'function',1,'')",
1252                [],
1253            )
1254            .unwrap();
1255            conn.execute(
1256                "INSERT INTO symbol_imports (branch_id, imported_name, importer_file)
1257                 VALUES ('stale','Bar','a.rs')",
1258                [],
1259            )
1260            .unwrap();
1261
1262            // Branch "fresh": one current IR row + its derived symbol-index rows.
1263            // These must NOT be cleared.
1264            insert_files_ir_row(&conn, "fresh", &rust_fixture("src/lib.rs"));
1265            conn.execute(
1266                "INSERT INTO symbol_definitions
1267                    (branch_id, symbol_name, file_path, line, end_line, kind, is_public, snippet)
1268                 VALUES ('fresh','keep','lib.rs',1,2,'function',1,'')",
1269                [],
1270            )
1271            .unwrap();
1272            conn.execute(
1273                "INSERT INTO symbol_imports (branch_id, imported_name, importer_file)
1274                 VALUES ('fresh','Keep','lib.rs')",
1275                [],
1276            )
1277            .unwrap();
1278        }
1279
1280        let report = wipe_stale_ir_cache(&db).expect("wipe");
1281        assert_eq!(report.stale_count, 1);
1282        assert!(report.symbol_definitions_cleared >= 1);
1283        assert!(report.symbol_imports_cleared >= 1);
1284
1285        let conn = db.connection().lock().unwrap();
1286        // "stale" branch lost its derived symbol-index rows.
1287        let stale_defs: i64 = conn
1288            .query_row(
1289                "SELECT COUNT(*) FROM symbol_definitions WHERE branch_id = 'stale'",
1290                [],
1291                |row| row.get(0),
1292            )
1293            .unwrap();
1294        assert_eq!(stale_defs, 0);
1295        let stale_imps: i64 = conn
1296            .query_row(
1297                "SELECT COUNT(*) FROM symbol_imports WHERE branch_id = 'stale'",
1298                [],
1299                |row| row.get(0),
1300            )
1301            .unwrap();
1302        assert_eq!(stale_imps, 0);
1303
1304        // "fresh" branch's symbol-index is untouched (one row in each table —
1305        // the backfill on open may also have populated rows from the rust_fixture
1306        // IR blob, so we just assert "kept" is still there).
1307        let fresh_kept: i64 = conn
1308            .query_row(
1309                "SELECT COUNT(*) FROM symbol_definitions
1310                 WHERE branch_id = 'fresh' AND symbol_name = 'keep'",
1311                [],
1312                |row| row.get(0),
1313            )
1314            .unwrap();
1315        assert_eq!(fresh_kept, 1, "fresh branch's symbol-index must survive");
1316    }
1317
1318    #[test]
1319    fn wipe_stale_ir_cache_is_idempotent() {
1320        let db = Database::open(":memory:").expect("open");
1321        {
1322            let conn = db.connection().lock().unwrap();
1323            insert_files_ir_row_with_version(&conn, "main", "stale.rs", 7);
1324        }
1325        let first = wipe_stale_ir_cache(&db).expect("wipe 1");
1326        assert_eq!(first.stale_count, 1);
1327
1328        let second = wipe_stale_ir_cache(&db).expect("wipe 2");
1329        assert!(
1330            second.is_empty(),
1331            "second wipe on already-clean cache must be a no-op"
1332        );
1333    }
1334
1335    #[test]
1336    fn backfill_skips_stale_ir_rows() {
1337        // A row tagged with an older `ir_schema_version` cannot be
1338        // deserialized — backfill must skip it without aborting the whole
1339        // pass.
1340        let tmp = TempDir::new("backfill_stale");
1341        let db_path = tmp.path().join("test.db");
1342
1343        {
1344            let db = Database::open(&db_path).expect("open");
1345            let conn = db.connection().lock().unwrap();
1346            // Insert a fresh row + a row tagged as stale (older schema version)
1347            // with a placeholder blob.
1348            insert_files_ir_row(&conn, "main", &rust_fixture("src/fresh.rs"));
1349            conn.execute(
1350                "INSERT INTO files_ir
1351                    (branch_id, file_path, language, content_hash, ir_data, ir_schema_version,
1352                     last_commit_date, updated_at)
1353                 VALUES ('main','src/stale.rs','rust','h',?1, ?2, NULL, datetime('now'))",
1354                params![vec![0u8, 0u8, 0u8], i64::from(IR_SCHEMA_VERSION) - 1],
1355            )
1356            .unwrap();
1357            conn.execute("DELETE FROM symbol_definitions", []).unwrap();
1358            conn.execute("DELETE FROM symbol_imports", []).unwrap();
1359        }
1360
1361        let db = Database::open(&db_path).expect("reopen");
1362        let conn = db.connection().lock().unwrap();
1363        // Only fresh row should contribute its definitions (3) and imports (1).
1364        assert_eq!(
1365            count_rows(&conn, "SELECT COUNT(*) FROM symbol_definitions"),
1366            3
1367        );
1368        assert_eq!(count_rows(&conn, "SELECT COUNT(*) FROM symbol_imports"), 1);
1369    }
1370
1371    #[test]
1372    fn reopening_existing_db_is_idempotent() {
1373        let tmp = TempDir::new("reopen");
1374        let db_path = tmp.path().join("test.db");
1375
1376        // First open: creates DB and runs migrations.
1377        {
1378            let db = Database::open(&db_path).expect("first open should succeed");
1379            let conn = db.connection().lock().unwrap();
1380            conn.execute(
1381                "INSERT INTO metadata (key, value) VALUES (?1, ?2)",
1382                rusqlite::params!["test_key", "test_value"],
1383            )
1384            .expect("insert should work");
1385        }
1386
1387        // Second open: should not fail and data should persist.
1388        {
1389            let db = Database::open(&db_path).expect("second open should succeed");
1390            let conn = db.connection().lock().unwrap();
1391
1392            let value: String = conn
1393                .query_row(
1394                    "SELECT value FROM metadata WHERE key = ?1",
1395                    rusqlite::params!["test_key"],
1396                    |row| row.get(0),
1397                )
1398                .expect("data should persist across reopens");
1399
1400            assert_eq!(value, "test_value");
1401        }
1402    }
1403}