1use 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#[derive(Debug, Clone, Default)]
21pub struct StaleIrWipeReport {
22 pub stale_count: u64,
24 pub cached_versions: Vec<u8>,
26 pub symbol_definitions_cleared: u64,
28 pub symbol_imports_cleared: u64,
30}
31
32impl StaleIrWipeReport {
33 pub fn is_empty(&self) -> bool {
35 self.stale_count == 0
36 }
37}
38
39embed_migrations!("migrations");
41
42const BUSY_TIMEOUT_MS: u64 = 5_000;
44
45#[derive(Debug, Clone)]
53pub struct Database {
54 conn: Arc<Mutex<Connection>>,
55}
56
57impl Database {
58 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 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 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 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 migrations::runner()
98 .run(&mut conn)
99 .map_err(|e| StorageError::MigrationError(e.to_string()))?;
100
101 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 pub fn connection(&self) -> &Arc<Mutex<Connection>> {
115 &self.conn
116 }
117}
118
119fn backfill_symbol_index(conn: &Connection) -> Result<(), StorageError> {
133 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 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
253pub 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
279fn wipe_stale_ir_cache_on(conn: &Connection) -> Result<StaleIrWipeReport, StorageError> {
284 struct StaleSummary {
287 cached_versions: Vec<u8>,
288 affected_branches: Vec<String>,
289 total: u64,
290 }
291 let summary: StaleSummary = {
292 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 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 let tx = conn
339 .unchecked_transaction()
340 .map_err(|e| StorageError::QueryError(format!("begin IR-cache wipe tx: {e}")))?;
341
342 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 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 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 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 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 #[test]
498 fn v14_migration_is_idempotent_on_reopen() {
499 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 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 let db = Database::open(":memory:").expect("open db");
530 let conn = db.connection().lock().unwrap();
531
532 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 assert_eq!(
986 count_rows(&conn, "SELECT COUNT(*) FROM symbol_definitions"),
987 11
988 );
989 assert_eq!(count_rows(&conn, "SELECT COUNT(*) FROM symbol_imports"), 4);
992 }
993 }
994
995 #[test]
996 fn backfill_is_idempotent_running_twice() {
997 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 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 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 fn insert_files_ir_row_with_version(
1069 conn: &Connection,
1070 branch: &str,
1071 file_path: &str,
1072 ir_schema_version: i64,
1073 ) {
1074 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 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 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 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 insert_files_ir_row_with_version(&conn, "main", "stale.rs", 7);
1176
1177 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 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 assert_eq!(count_rows(&conn, "SELECT COUNT(*) FROM files_ir"), 0);
1219
1220 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 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 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 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 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 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_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 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 {
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 {
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}