1#![allow(clippy::type_complexity)]
4
5pub use rusqlite;
14
15pub mod history;
16pub mod snapshot;
17pub mod system;
19
20use rusqlite::{params, Connection, OptionalExtension};
21use scc_core::{
22 Entity, Evidence, Flow, Invariant, Provenance, Relationship, Repository, Severity, Snapshot,
23};
24use std::collections::{HashMap, HashSet};
25use std::io::Read;
26use std::path::{Path, PathBuf};
27use thiserror::Error;
28
29pub const SCHEMA_VERSION: u32 = 9;
31pub const FTS_ESCAPE: &str = "\"";
33const MIGRATIONS: &[&str] = &[
35 MIGRATION_1,
36 MIGRATION_2,
37 MIGRATION_3,
38 MIGRATION_4,
39 MIGRATION_5,
40 MIGRATION_6,
41 MIGRATION_7,
42 MIGRATION_8,
43 MIGRATION_9,
44];
45
46const MIGRATION_4: &str = r#"
52ALTER TABLE context_cache RENAME COLUMN revision TO epoch;
53"#;
54
55const MIGRATION_5: &str = r#"
59CREATE TABLE IF NOT EXISTS flow_graphs (
60 id TEXT PRIMARY KEY,
61 kind TEXT NOT NULL,
62 name TEXT NOT NULL,
63 trigger TEXT,
64 graph TEXT NOT NULL
65);
66"#;
67
68const MIGRATION_7: &str = r#"
74CREATE TABLE IF NOT EXISTS graph_revisions (
75 rev INTEGER PRIMARY KEY,
76 base_rev INTEGER NOT NULL DEFAULT 0,
77 created_at TEXT NOT NULL,
78 source_hash TEXT NOT NULL DEFAULT '',
79 extractor_version TEXT NOT NULL DEFAULT '',
80 entity_count INTEGER NOT NULL DEFAULT 0,
81 rel_count INTEGER NOT NULL DEFAULT 0,
82 file_count INTEGER NOT NULL DEFAULT 0
83);
84CREATE TABLE IF NOT EXISTS revision_members (
85 rev INTEGER NOT NULL,
86 kind TEXT NOT NULL,
87 id TEXT NOT NULL,
88 row_json TEXT NOT NULL DEFAULT '{}',
89 PRIMARY KEY (rev, kind, id)
90);
91CREATE INDEX IF NOT EXISTS idx_revision_members_rev ON revision_members(rev);
92CREATE TABLE IF NOT EXISTS context_snapshots (
93 id TEXT PRIMARY KEY,
94 created_at TEXT NOT NULL,
95 task TEXT NOT NULL DEFAULT '',
96 epoch TEXT NOT NULL DEFAULT '',
97 revision INTEGER NOT NULL DEFAULT 0,
98 artifact TEXT NOT NULL DEFAULT '',
99 entity_ids TEXT NOT NULL DEFAULT '[]',
100 budget INTEGER NOT NULL DEFAULT 0,
101 warnings TEXT NOT NULL DEFAULT '[]'
102);
103"#;
104
105const MIGRATION_8: &str = r#"
114ALTER TABLE graph_revisions ADD COLUMN semantic_config_hash TEXT NOT NULL DEFAULT '';
115ALTER TABLE graph_revisions ADD COLUMN graph_content_hash TEXT NOT NULL DEFAULT '';
116ALTER TABLE context_snapshots ADD COLUMN entity_fp TEXT NOT NULL DEFAULT '{}';
117ALTER TABLE context_snapshots ADD COLUMN rel_fp TEXT NOT NULL DEFAULT '{}';
118ALTER TABLE context_snapshots ADD COLUMN contract_fp TEXT NOT NULL DEFAULT '{}';
119ALTER TABLE context_snapshots ADD COLUMN state_fp TEXT NOT NULL DEFAULT '{}';
120ALTER TABLE context_snapshots ADD COLUMN flow_names TEXT NOT NULL DEFAULT '[]';
121ALTER TABLE context_snapshots ADD COLUMN artifact_hash TEXT NOT NULL DEFAULT '';
122"#;
123
124const MIGRATION_9: &str = r#"
129CREATE TABLE IF NOT EXISTS plugin_state (
130 plugin_id TEXT NOT NULL,
131 key TEXT NOT NULL,
132 value TEXT NOT NULL DEFAULT '{}',
133 updated_at TEXT NOT NULL DEFAULT '',
134 PRIMARY KEY (plugin_id, key)
135);
136CREATE INDEX IF NOT EXISTS idx_plugin_state_plugin ON plugin_state(plugin_id);
137"#;
138
139const MIGRATION_6: &str = r#"
145CREATE TABLE IF NOT EXISTS trace_signatures (
146 signature TEXT PRIMARY KEY,
147 count INTEGER NOT NULL DEFAULT 1,
148 latency_ms REAL NOT NULL DEFAULT 0,
149 errors INTEGER NOT NULL DEFAULT 0,
150 last_observed TEXT NOT NULL
151);
152"#;
153
154const MIGRATION_3: &str = r#"
157CREATE TABLE IF NOT EXISTS embeddings (
158 entity_id TEXT PRIMARY KEY,
159 vector BLOB NOT NULL,
160 model TEXT NOT NULL,
161 updated_at TEXT NOT NULL
162);
163"#;
164
165const MIGRATION_2: &str = r#"
168ALTER TABLE runtime_edges ADD COLUMN latency_ms REAL NOT NULL DEFAULT 0;
169ALTER TABLE runtime_edges ADD COLUMN errors INTEGER NOT NULL DEFAULT 0;
170"#;
171
172const MIGRATION_1: &str = r#"
174CREATE TABLE IF NOT EXISTS meta (
175 key TEXT PRIMARY KEY,
176 value TEXT NOT NULL
177);
178
179CREATE TABLE IF NOT EXISTS repositories (
180 id TEXT PRIMARY KEY,
181 name TEXT NOT NULL,
182 url TEXT,
183 root TEXT NOT NULL,
184 indexed_at TEXT
185);
186
187CREATE TABLE IF NOT EXISTS snapshots (
188 id INTEGER PRIMARY KEY AUTOINCREMENT,
189 revision TEXT NOT NULL,
190 branch TEXT,
191 indexed_at TEXT NOT NULL,
192 status TEXT NOT NULL DEFAULT 'active',
193 file_count INTEGER NOT NULL DEFAULT 0
194);
195
196CREATE TABLE IF NOT EXISTS files (
197 path TEXT PRIMARY KEY,
198 hash TEXT NOT NULL,
199 language TEXT NOT NULL DEFAULT 'unknown',
200 kind TEXT NOT NULL DEFAULT 'other',
201 size INTEGER NOT NULL DEFAULT 0,
202 indexed_at TEXT
203);
204
205CREATE TABLE IF NOT EXISTS symbols (
206 id INTEGER PRIMARY KEY AUTOINCREMENT,
207 file TEXT NOT NULL,
208 name TEXT NOT NULL,
209 symbol_kind TEXT NOT NULL,
210 signature TEXT,
211 start_line INTEGER NOT NULL,
212 end_line INTEGER NOT NULL,
213 exported INTEGER NOT NULL DEFAULT 0,
214 docstring TEXT
215);
216CREATE INDEX IF NOT EXISTS idx_symbols_file ON symbols(file);
217CREATE INDEX IF NOT EXISTS idx_symbols_name ON symbols(name);
218
219CREATE TABLE IF NOT EXISTS imports (
220 id INTEGER PRIMARY KEY AUTOINCREMENT,
221 file TEXT NOT NULL,
222 module TEXT NOT NULL,
223 names TEXT NOT NULL DEFAULT '[]',
224 line INTEGER NOT NULL DEFAULT 0,
225 type TEXT NOT NULL DEFAULT 'member'
226);
227CREATE INDEX IF NOT EXISTS idx_imports_file ON imports(file);
228
229CREATE TABLE IF NOT EXISTS entities (
230 id TEXT PRIMARY KEY,
231 kind TEXT NOT NULL,
232 name TEXT NOT NULL,
233 attributes TEXT NOT NULL DEFAULT '{}',
234 evidence TEXT NOT NULL DEFAULT '[]',
235 sources TEXT NOT NULL DEFAULT '[]'
236);
237CREATE INDEX IF NOT EXISTS idx_entities_kind ON entities(kind);
238
239CREATE TABLE IF NOT EXISTS relationships (
240 id TEXT PRIMARY KEY,
241 subject TEXT NOT NULL,
242 predicate TEXT NOT NULL,
243 object TEXT NOT NULL,
244 provenance TEXT NOT NULL,
245 confidence REAL NOT NULL,
246 evidence TEXT NOT NULL DEFAULT '[]',
247 verified_at TEXT NOT NULL DEFAULT '',
248 source_path TEXT NOT NULL DEFAULT ''
249);
250CREATE INDEX IF NOT EXISTS idx_rel_subject ON relationships(subject);
251CREATE INDEX IF NOT EXISTS idx_rel_object ON relationships(object);
252CREATE INDEX IF NOT EXISTS idx_rel_predicate ON relationships(predicate);
253CREATE INDEX IF NOT EXISTS idx_rel_source ON relationships(source_path);
254
255CREATE TABLE IF NOT EXISTS evidence (
256 id TEXT PRIMARY KEY,
257 type TEXT NOT NULL,
258 path TEXT,
259 symbol TEXT,
260 start_line INTEGER,
261 end_line INTEGER,
262 revision TEXT,
263 content_hash TEXT,
264 extractor TEXT,
265 extractor_version TEXT
266);
267CREATE INDEX IF NOT EXISTS idx_evidence_path ON evidence(path);
268
269CREATE TABLE IF NOT EXISTS components (
270 id TEXT PRIMARY KEY,
271 name TEXT NOT NULL,
272 kind TEXT NOT NULL,
273 responsibility TEXT NOT NULL DEFAULT '[]',
274 implementation TEXT NOT NULL DEFAULT '[]',
275 evidence TEXT NOT NULL DEFAULT '[]',
276 attributes TEXT NOT NULL DEFAULT '{}'
277);
278
279CREATE TABLE IF NOT EXISTS flows (
280 id TEXT PRIMARY KEY,
281 kind TEXT NOT NULL,
282 name TEXT NOT NULL,
283 trigger TEXT,
284 steps TEXT NOT NULL DEFAULT '[]',
285 attributes TEXT NOT NULL DEFAULT '{}'
286);
287
288CREATE TABLE IF NOT EXISTS invariants (
289 id TEXT PRIMARY KEY,
290 statement TEXT NOT NULL,
291 severity TEXT NOT NULL,
292 scope TEXT NOT NULL DEFAULT '[]',
293 enforced_by TEXT NOT NULL DEFAULT '[]',
294 provenance TEXT NOT NULL DEFAULT 'DECLARED',
295 evidence TEXT NOT NULL DEFAULT '[]'
296);
297
298CREATE TABLE IF NOT EXISTS tests (
299 id TEXT PRIMARY KEY,
300 name TEXT NOT NULL,
301 file TEXT NOT NULL,
302 kind TEXT NOT NULL DEFAULT 'unit',
303 symbol TEXT
304);
305CREATE INDEX IF NOT EXISTS idx_tests_file ON tests(file);
306
307CREATE TABLE IF NOT EXISTS context_cache (
308 key TEXT PRIMARY KEY,
309 pack TEXT NOT NULL,
310 revision TEXT NOT NULL,
311 created_at TEXT NOT NULL
312);
313
314CREATE TABLE IF NOT EXISTS intent_claims (
315 id INTEGER PRIMARY KEY AUTOINCREMENT,
316 source TEXT NOT NULL,
317 claim TEXT NOT NULL,
318 created_at TEXT NOT NULL
319);
320
321CREATE TABLE IF NOT EXISTS runtime_edges (
322 source TEXT NOT NULL,
323 target TEXT NOT NULL,
324 count INTEGER NOT NULL DEFAULT 1,
325 last_observed TEXT NOT NULL,
326 PRIMARY KEY (source, target)
327);
328
329CREATE TABLE IF NOT EXISTS drift_findings (
330 id INTEGER PRIMARY KEY AUTOINCREMENT,
331 kind TEXT NOT NULL,
332 severity TEXT NOT NULL,
333 message TEXT NOT NULL,
334 created_at TEXT NOT NULL,
335 resolved INTEGER NOT NULL DEFAULT 0
336);
337
338CREATE VIRTUAL TABLE IF NOT EXISTS symbols_fts USING fts5(
339 name, signature, symbol_kind UNINDEXED, file UNINDEXED
340);
341
342CREATE VIRTUAL TABLE IF NOT EXISTS entities_fts USING fts5(
343 id UNINDEXED, kind UNINDEXED, name, attributes
344);
345"#;
346
347#[derive(Debug, Error)]
348pub enum StoreError {
350 #[error("sqlite: {0}")]
351 Sqlite(#[from] rusqlite::Error),
352 #[error("json: {0}")]
353 Json(#[from] serde_json::Error),
354 #[error("repository not initialized: {0}")]
355 NotInitialized(String),
356 #[error("index cache is corrupt (refusing to fabricate an empty index): {0}")]
357 Corrupt(String),
358}
359
360pub type Result<T> = std::result::Result<T, StoreError>;
362
363#[derive(Debug, Clone, serde::Serialize)]
364pub struct RuntimeEdgeRow {
366 pub source: String,
367 pub target: String,
368 pub count: u64,
369 pub latency_ms: f64,
370 pub errors: u64,
371 pub last_observed: String,
372}
373
374#[derive(Debug, Clone, Copy, PartialEq, Eq)]
379pub enum ModelEpochKind {
381 Source,
383 Semantic,
385 Evidence,
387 Intent,
389 Runtime,
391 Derived,
393}
394
395impl ModelEpochKind {
397 pub fn meta_key(&self) -> &'static str {
399 match self {
400 ModelEpochKind::Source => "source_generation",
401 ModelEpochKind::Semantic => "semantic_generation",
402 ModelEpochKind::Evidence => "evidence_generation",
403 ModelEpochKind::Intent => "intent_generation",
404 ModelEpochKind::Runtime => "runtime_generation",
405 ModelEpochKind::Derived => "derived_generation",
406 }
407 }
408}
409
410#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
417pub struct ModelEpoch {
419 pub source: u64,
420 pub semantic: u64,
421 pub evidence: u64,
422 pub intent: u64,
423 pub runtime: u64,
424 pub derived: u64,
425}
426
427impl ModelEpoch {
429 pub fn zero() -> ModelEpoch {
431 ModelEpoch {
432 source: 0,
433 semantic: 0,
434 evidence: 0,
435 intent: 0,
436 runtime: 0,
437 derived: 0,
438 }
439 }
440
441 pub fn composite(&self, revision: &str) -> String {
445 let mut h = blake3::Hasher::new();
446 h.update(b"scc-model-epoch-v1");
447 h.update(self.source.to_le_bytes().as_slice());
448 h.update(self.semantic.to_le_bytes().as_slice());
449 h.update(self.evidence.to_le_bytes().as_slice());
450 h.update(self.intent.to_le_bytes().as_slice());
451 h.update(self.runtime.to_le_bytes().as_slice());
452 h.update(self.derived.to_le_bytes().as_slice());
453 h.update(revision.as_bytes());
454 format!("epoch:{}", &h.finalize().to_hex()[..24])
455 }
456}
457#[derive(Debug)]
460pub struct Store {
462 pub conn: Connection,
463 pub root: PathBuf,
464 pub repo_id: String,
466 pub repo_name: String,
467 batch_depth: std::sync::atomic::AtomicUsize,
468}
469
470const SQLITE_MAGIC: &[u8] = b"SQLite format 3\0";
471
472fn refuse_corrupt_existing_db(path: &Path) -> Result<()> {
477 let Ok(meta) = std::fs::metadata(path) else {
478 return Ok(());
479 };
480 if !meta.is_file() || meta.len() == 0 {
481 return Ok(());
482 }
483 if meta.len() < 100 {
484 return Err(StoreError::Corrupt("truncated sqlite header".into()));
485 }
486 let mut f = std::fs::File::open(path).map_err(|e| StoreError::Corrupt(e.to_string()))?;
487 let mut magic = [0u8; 16];
488 let n = f.read(&mut magic).map_err(|e| StoreError::Corrupt(e.to_string()))?;
489 if n < SQLITE_MAGIC.len() || magic.as_slice() != SQLITE_MAGIC {
490 return Err(StoreError::Corrupt(
491 "file is not a SQLite database".into(),
492 ));
493 }
494 Ok(())
495}
496
497fn probe_existing_schema(conn: &Connection) -> Result<()> {
502 match conn.query_row(
503 "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'entities' LIMIT 1",
504 [],
505 |_| Ok(()),
506 ) {
507 Ok(()) => Ok(()),
508 Err(rusqlite::Error::QueryReturnedNoRows) => Err(StoreError::Corrupt(
509 "existing database is missing SCC schema".into(),
510 )),
511 Err(e) => Err(StoreError::Corrupt(e.to_string())),
512 }
513}
514
515struct NestGuard<'c> {
517 conn: &'c Connection,
518 committed: bool,
519}
520
521impl NestGuard<'_> {
523 fn commit(mut self) -> Result<()> {
525 self.committed = true;
526 self.conn.execute_batch("RELEASE scc_nest")?;
527 Ok(())
528 }
529}
530
531impl Drop for NestGuard<'_> {
533 fn drop(&mut self) {
535 if !self.committed {
536 let _ = self
537 .conn
538 .execute_batch("ROLLBACK TO scc_nest; RELEASE scc_nest;");
539 }
540 }
541}
542
543impl Store {
545 pub fn open_recovering(path: &Path, root: &Path) -> Result<(Store, Option<PathBuf>)> {
558 match Self::open(path, root) {
559 Ok(store) => Ok((store, None)),
560 Err(e) if Self::is_corruption(&e) => {
561 let quarantine = Self::quarantine_db(path)?;
562 let store = Self::open(path, root)?;
563 Ok((store, Some(quarantine)))
564 }
565 Err(e) => Err(e),
566 }
567 }
568
569 pub fn quarantine_db(path: &Path) -> Result<PathBuf> {
574 let stamp = std::time::SystemTime::now()
575 .duration_since(std::time::UNIX_EPOCH)
576 .map(|d| d.as_secs())
577 .unwrap_or(0);
578 let quarantine = path.with_extension(format!("db.corrupt-{stamp}"));
579 let _ = std::fs::rename(path, &quarantine);
580 for suffix in ["-wal", "-shm", "-journal"] {
581 let sidecar = PathBuf::from(format!("{}{suffix}", path.display()));
582 if sidecar.is_file() {
583 let dest = PathBuf::from(format!("{}{suffix}", quarantine.display()));
584 let _ = std::fs::rename(&sidecar, &dest);
585 }
586 }
587 Ok(quarantine)
588 }
589
590 pub fn is_corruption(e: &StoreError) -> bool {
596 match e {
597 StoreError::Corrupt(_) => true,
598 StoreError::Sqlite(inner) => {
599 let m = inner.to_string();
600 m.contains("malformed")
601 || m.contains("not a database")
602 || m.contains("database disk image")
603 }
604 _ => false,
605 }
606 }
607
608 pub fn batch_begin(&self) -> Result<()> {
615 use std::sync::atomic::Ordering;
616 if self.batch_depth.fetch_add(1, Ordering::SeqCst) == 0 {
617 self.conn.execute_batch("BEGIN IMMEDIATE")?;
618 }
619 Ok(())
620 }
621
622 pub fn batch_end(&self) -> Result<()> {
624 use std::sync::atomic::Ordering;
625 if self.batch_depth.fetch_sub(1, Ordering::SeqCst) == 1 {
626 self.conn.execute_batch("COMMIT")?;
627 }
628 Ok(())
629 }
630
631 pub fn batch_abort(&self) {
633 use std::sync::atomic::Ordering;
634 if self.batch_depth.swap(0, Ordering::SeqCst) > 0 {
635 let _ = self.conn.execute_batch("ROLLBACK");
636 }
637 }
638
639 pub fn batch_write<T, E>(&self, f: impl FnOnce() -> std::result::Result<T, E>) -> std::result::Result<T, E>
643 where
644 E: From<StoreError>,
645 {
646 self.batch_begin()?;
647 match f() {
648 Ok(v) => self.batch_end().map_err(E::from).map(|_| v),
649 Err(e) => {
650 self.batch_abort();
651 Err(e)
652 }
653 }
654 }
655
656 fn nest(&self) -> Result<NestGuard<'_>> {
662 self.conn.execute_batch("SAVEPOINT scc_nest")?;
663 Ok(NestGuard { conn: &self.conn, committed: false })
664 }
665
666 pub fn open(path: &Path, root: &Path) -> Result<Store> {
668 let existed_nonempty = path.is_file()
669 && std::fs::metadata(path).map(|m| m.len() > 0).unwrap_or(false);
670 refuse_corrupt_existing_db(path)?;
671 let conn = match Connection::open(path) {
672 Ok(c) => c,
673 Err(e) if existed_nonempty => {
674 return Err(StoreError::Corrupt(e.to_string()));
675 }
676 Err(e) => return Err(e.into()),
677 };
678 if existed_nonempty {
679 probe_existing_schema(&conn)?;
680 }
681 conn.pragma_update(None, "journal_mode", "WAL")?;
682 conn.pragma_update(None, "busy_timeout", 5000)?;
683 conn.pragma_update(None, "synchronous", "FULL")?;
689 conn.pragma_update(None, "foreign_keys", "ON")?;
690 apply_migrations(&conn)?;
691
692 let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
693 let name = root
694 .file_name()
695 .map(|s| s.to_string_lossy().to_string())
696 .unwrap_or_else(|| "repository".to_string());
697 let explicit = std::env::var("SCC_REPO_ID")
701 .ok()
702 .filter(|s| !s.trim().is_empty());
703 let mut repo_id = scc_core::sanitize_key(&name);
704 let mut id_source = "basename";
705 if let Some(e) = explicit {
706 repo_id = scc_core::sanitize_key(e.trim());
707 id_source = "explicit";
708 } else if existed_nonempty {
709 if let Some(persisted) = Self::existing_repo_id(&conn)? {
710 repo_id = persisted;
711 id_source = "persistent";
712 }
713 }
714
715 let mut store = Store {
716 conn,
717 root,
718 repo_id,
719 repo_name: name,
720 batch_depth: std::sync::atomic::AtomicUsize::new(0),
721 };
722 store.ensure_repository()?;
723 if id_source == "persistent" {
724 store.refresh_repository_root()?;
725 }
726 store.meta_set("repo_id_source", id_source)?;
727 Ok(store)
728 }
729
730 fn ensure_repository(&mut self) -> Result<()> {
732 let existing: Option<String> = self
733 .conn
734 .query_row(
735 "SELECT id FROM repositories WHERE id = ?1",
736 params![self.repo_id],
737 |r| r.get(0),
738 )
739 .optional()?;
740 if existing.is_none() {
741 self.conn.execute(
742 "INSERT INTO repositories (id, name, root, indexed_at) VALUES (?1, ?2, ?3, ?4)",
743 params![
744 self.repo_id,
745 self.repo_name,
746 self.root.to_string_lossy(),
747 scc_core::now_rfc3339()
748 ],
749 )?;
750 }
751 Ok(())
752 }
753 fn existing_repo_id(conn: &Connection) -> Result<Option<String>> {
757 let id: Option<String> = conn
758 .query_row(
759 "SELECT id FROM repositories ORDER BY rowid LIMIT 1",
760 [],
761 |r| r.get(0),
762 )
763 .optional()?;
764 Ok(id)
765 }
766
767 fn refresh_repository_root(&self) -> Result<()> {
772 self.conn.execute(
773 "UPDATE repositories SET root = ?1, name = ?2 WHERE id = ?3",
774 params![
775 self.root.to_string_lossy(),
776 self.repo_name,
777 self.repo_id
778 ],
779 )?;
780 Ok(())
781 }
782
783 pub fn adopt_stable_identity(
790 &mut self,
791 explicit: Option<&str>,
792 remote_url: Option<&str>,
793 ) -> Result<bool> {
794 if self.meta_get("repo_id_source")?.as_deref() == Some("explicit") {
795 return Ok(false);
796 }
797 let n: u64 = self.conn.query_row(
798 "SELECT COUNT(*) FROM entities",
799 [],
800 |r| r.get(0),
801 )?;
802 if n > 0 {
803 return Ok(false);
804 }
805 let basename = self
806 .root
807 .file_name()
808 .map(|s| s.to_string_lossy().to_string())
809 .unwrap_or_else(|| "repository".to_string());
810 let id = scc_core::identity::stable_repo_id(explicit, remote_url, &basename);
811 if id == self.repo_id {
812 return Ok(false);
813 }
814 self.conn.execute(
815 "DELETE FROM repositories WHERE id = ?1",
816 params![self.repo_id],
817 )?;
818 self.repo_id = id;
819 self.ensure_repository()?;
820 self.meta_set("repo_id_source", "remote")?;
821 Ok(true)
822 }
823
824 pub fn repository(&self) -> Repository {
826 Repository {
827 id: self.repo_id.clone(),
828 name: self.repo_name.clone(),
829 url: self
830 .meta_get("remote_url")
831 .ok()
832 .flatten()
833 .filter(|s| !s.is_empty()),
834 }
835 }
836
837 pub fn plugin_state_put(&self, plugin_id: &str, key: &str, value: &str) -> Result<()> {
845 self.conn.execute(
846 "INSERT INTO plugin_state (plugin_id, key, value, updated_at) VALUES (?1, ?2, ?3, ?4)
847 ON CONFLICT(plugin_id, key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at",
848 params![plugin_id, key, value, scc_core::now_rfc3339()],
849 )?;
850 Ok(())
851 }
852
853 pub fn plugin_state_get(&self, plugin_id: &str, key: &str) -> Result<Option<String>> {
855 let v = self
856 .conn
857 .query_row(
858 "SELECT value FROM plugin_state WHERE plugin_id = ?1 AND key = ?2",
859 params![plugin_id, key],
860 |r| r.get(0),
861 )
862 .optional()?;
863 Ok(v)
864 }
865
866 pub fn plugin_state_delete(&self, plugin_id: &str, key: &str) -> Result<()> {
868 self.conn.execute(
869 "DELETE FROM plugin_state WHERE plugin_id = ?1 AND key = ?2",
870 params![plugin_id, key],
871 )?;
872 Ok(())
873 }
874
875 pub fn plugin_state_scan(&self, plugin_id: &str, prefix: &str, limit: usize) -> Result<Vec<(String, String)>> {
877 let esc = prefix.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_");
878 let like = format!("{esc}%");
879 let mut stmt = self.conn.prepare(
880 "SELECT key, value FROM plugin_state WHERE plugin_id = ?1 AND key LIKE ?2 ESCAPE '\\' ORDER BY key LIMIT ?3",
881 )?;
882 let rows = stmt.query_map(params![plugin_id, like, limit.max(1) as i64], |r| {
883 Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
884 })?;
885 rows.collect::<std::result::Result<Vec<_>, _>>().map_err(|e| e.into())
886 }
887
888 pub fn meta_set(&self, key: &str, value: &str) -> Result<()> {
894 self.conn.execute(
895 "INSERT INTO meta (key, value) VALUES (?1, ?2)
896 ON CONFLICT(key) DO UPDATE SET value = excluded.value",
897 params![key, value],
898 )?;
899 Ok(())
900 }
901
902 pub fn meta_get(&self, key: &str) -> Result<Option<String>> {
904 let v = self
905 .conn
906 .query_row("SELECT value FROM meta WHERE key = ?1", params![key], |r| {
907 r.get(0)
908 })
909 .optional()?;
910 Ok(v)
911 }
912
913 pub fn bump_epoch(&self, kind: ModelEpochKind) -> Result<()> {
918 let key = kind.meta_key();
919 let next: u64 = self
920 .conn
921 .query_row("SELECT value FROM meta WHERE key = ?1", params![key], |r| {
922 r.get::<_, String>(0)
923 })
924 .optional()?
925 .and_then(|s| s.parse::<u64>().ok())
926 .unwrap_or(0)
927 + 1;
928 self.meta_set(key, &next.to_string())
929 }
930
931 pub fn model_epoch(&self) -> Result<ModelEpoch> {
936 let g = |k: &str| -> Result<u64> {
937 Ok(self
938 .meta_get(k)?
939 .and_then(|s| s.parse::<u64>().ok())
940 .unwrap_or(0))
941 };
942 Ok(ModelEpoch {
943 source: g(ModelEpochKind::Source.meta_key())?,
944 semantic: g(ModelEpochKind::Semantic.meta_key())?,
945 evidence: g(ModelEpochKind::Evidence.meta_key())?,
946 intent: g(ModelEpochKind::Intent.meta_key())?,
947 runtime: g(ModelEpochKind::Runtime.meta_key())?,
948 derived: g(ModelEpochKind::Derived.meta_key())?,
949 })
950 }
951
952 pub fn begin_snapshot(&self, revision: &str, branch: Option<&str>) -> Result<i64> {
958 self.conn.execute(
959 "INSERT INTO snapshots (revision, branch, indexed_at, status) VALUES (?1, ?2, ?3, 'active')",
960 params![revision, branch, scc_core::now_rfc3339()],
961 )?;
962 Ok(self.conn.last_insert_rowid())
963 }
964
965 pub fn finish_snapshot(&self, id: i64, file_count: usize) -> Result<()> {
967 self.conn.execute(
968 "UPDATE snapshots SET file_count = ?2, status = 'complete' WHERE id = ?1",
969 params![id, file_count as i64],
970 )?;
971 self.bump_epoch(ModelEpochKind::Source)?;
973 Ok(())
974 }
975
976 pub fn latest_snapshot(&self) -> Result<Option<Snapshot>> {
978 let row = self
979 .conn
980 .query_row(
981 "SELECT revision, branch, indexed_at FROM snapshots
982 WHERE status = 'complete' ORDER BY id DESC LIMIT 1",
983 [],
984 |r| {
985 Ok((
986 r.get::<_, String>(0)?,
987 r.get::<_, Option<String>>(1)?,
988 r.get::<_, String>(2)?,
989 ))
990 },
991 )
992 .optional()?;
993 Ok(row.map(|(revision, branch, indexed_at)| Snapshot {
994 revision,
995 branch,
996 indexed_at,
997 }))
998 }
999
1000 pub fn snapshot_status(&self) -> Result<Option<(Snapshot, i64)>> {
1002 let row = self
1003 .conn
1004 .query_row(
1005 "SELECT revision, branch, indexed_at, (SELECT COUNT(*) FROM files) FROM snapshots
1006 WHERE status = 'complete' ORDER BY id DESC LIMIT 1",
1007 [],
1008 |r| {
1009 Ok((
1010 r.get::<_, String>(0)?,
1011 r.get::<_, Option<String>>(1)?,
1012 r.get::<_, String>(2)?,
1013 r.get::<_, i64>(3)?,
1014 ))
1015 },
1016 )
1017 .optional()?;
1018 Ok(row.map(|(revision, branch, indexed_at, files)| {
1019 (
1020 Snapshot {
1021 revision,
1022 branch,
1023 indexed_at,
1024 },
1025 files,
1026 )
1027 }))
1028 }
1029
1030 pub fn upsert_file(&self, path: &str, hash: &str, language: &str, kind: &str, size: u64) -> Result<()> {
1036 self.conn.execute(
1037 "INSERT INTO files (path, hash, language, kind, size, indexed_at)
1038 VALUES (?1, ?2, ?3, ?4, ?5, ?6)
1039 ON CONFLICT(path) DO UPDATE SET hash = excluded.hash, language = excluded.language,
1040 kind = excluded.kind, size = excluded.size, indexed_at = excluded.indexed_at",
1041 params![path, hash, language, kind, size as i64, scc_core::now_rfc3339()],
1042 )?;
1043 Ok(())
1044 }
1045
1046 pub fn file(&self, path: &str) -> Result<Option<(String, String, String, u64)>> {
1048 let row = self
1049 .conn
1050 .query_row(
1051 "SELECT hash, language, kind, size FROM files WHERE path = ?1",
1052 params![path],
1053 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get::<_, i64>(3)? as u64)),
1054 )
1055 .optional()?;
1056 Ok(row)
1057 }
1058
1059 pub fn all_files(&self) -> Result<Vec<(String, String, String, String, u64)>> {
1061 let mut stmt = self
1062 .conn
1063 .prepare("SELECT path, hash, language, kind, size FROM files")?;
1064 let rows = stmt.query_map([], |r| {
1065 Ok((
1066 r.get::<_, String>(0)?,
1067 r.get::<_, String>(1)?,
1068 r.get::<_, String>(2)?,
1069 r.get::<_, String>(3)?,
1070 r.get::<_, i64>(4)? as u64,
1071 ))
1072 })?;
1073 let mut out = Vec::new();
1074 for row in rows {
1075 out.push(row?);
1076 }
1077 Ok(out)
1078 }
1079
1080 pub fn delete_file(&self, path: &str) -> Result<()> {
1082 self.conn.execute("DELETE FROM files WHERE path = ?1", params![path])?;
1083 Ok(())
1084 }
1085
1086pub fn purge_path(&self, path: &str) -> Result<()> {
1101 let _sp = self.nest()?;
1102 let affected_concepts: Vec<String> = {
1105 let mut stmt = self.conn.prepare(
1106 "SELECT DISTINCT r.object FROM relationships r
1107 JOIN entities e ON e.id = r.subject
1108 WHERE r.predicate = ?1 AND e.kind = ?2 AND e.sources LIKE ?3",
1109 )?;
1110 let rows = stmt.query_map(
1111 params![
1112 scc_core::predicates::OCCURS,
1113 scc_core::kinds::OCCURRENCE,
1114 format!("%\"{path}\"%")
1115 ],
1116 |r| r.get::<_, String>(0),
1117 )?;
1118 let mut v = Vec::new();
1119 for r in rows {
1120 v.push(r?);
1121 }
1122 v.sort();
1123 v.dedup();
1124 v
1125 };
1126 let occ_ids: Vec<String> = {
1128 let mut stmt = self.conn.prepare(
1129 "SELECT id FROM entities WHERE kind = ?1 AND sources LIKE ?2",
1130 )?;
1131 let rows = stmt.query_map(
1132 params![scc_core::kinds::OCCURRENCE, format!("%\"{path}\"%")],
1133 |r| r.get::<_, String>(0),
1134 )?;
1135 let mut v = Vec::new();
1136 for r in rows {
1137 v.push(r?);
1138 }
1139 v
1140 };
1141 let ev_ids: Vec<String> = {
1143 let mut stmt = self.conn.prepare("SELECT id FROM evidence WHERE path = ?1")?;
1144 let rows = stmt.query_map(params![path], |r| r.get::<_, String>(0))?;
1145 let mut v = Vec::new();
1146 for r in rows {
1147 v.push(r?);
1148 }
1149 v
1150 };
1151 let sym_names: Vec<String> = {
1153 let mut stmt = self.conn.prepare("SELECT name FROM symbols WHERE file = ?1")?;
1154 let rows = stmt.query_map(params![path], |r| r.get::<_, String>(0))?;
1155 let mut v = Vec::new();
1156 for r in rows {
1157 v.push(r?);
1158 }
1159 v
1160 };
1161 self.conn.execute("DELETE FROM symbols WHERE file = ?1", params![path])?;
1162 self.conn.execute("DELETE FROM imports WHERE file = ?1", params![path])?;
1163 self.conn.execute("DELETE FROM evidence WHERE path = ?1", params![path])?;
1164 let mut orphaned_evidence: Vec<String> = Vec::new();
1167 for name in sym_names {
1168 let sid = scc_core::symbol_id(&self.repo_id, path, &name);
1169 let mut stmt = self.conn.prepare(
1170 "SELECT id, evidence FROM relationships WHERE subject = ?1 OR object = ?1",
1171 )?;
1172 let rows = stmt.query_map(params![sid], |r| {
1173 Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
1174 })?;
1175 let mut to_delete: Vec<(String, String)> = Vec::new();
1176 for r in rows {
1177 to_delete.push(r?);
1178 }
1179 drop(stmt);
1180 for (rid, ev_json) in to_delete {
1181 if let Ok(ev_ids) = serde_json::from_str::<Vec<String>>(&ev_json) {
1182 orphaned_evidence.extend(ev_ids);
1183 }
1184 self.conn.execute("DELETE FROM relationships WHERE id = ?1", params![rid])?;
1185 }
1186 }
1187 let _ = orphaned_evidence;
1191 self.conn.execute(
1192 "DELETE FROM entities WHERE id LIKE ?1",
1193 params![format!("%/symbol/{path}/%")],
1194 )?;
1195 self.conn.execute(
1201 "DELETE FROM entities WHERE sources LIKE ?1 AND kind NOT IN (?2, ?3)",
1202 params![
1203 format!("%\"{path}\"%"),
1204 scc_core::kinds::SCHEMA,
1205 scc_core::kinds::REACTIVE
1206 ],
1207 )?;
1208 self.conn.execute(
1209 "DELETE FROM relationships WHERE source_path = ?1",
1210 params![path],
1211 )?;
1212 for ev in ev_ids {
1214 self.conn.execute(
1215 "DELETE FROM relationships WHERE evidence LIKE ?1",
1216 params![format!("%\"{ev}\"%")],
1217 )?;
1218 }
1219 self.conn.execute(
1220 "DELETE FROM tests WHERE file = ?1",
1221 params![path],
1222 )?;
1223 for oid in &occ_ids {
1226 self.conn.execute(
1227 "DELETE FROM relationships WHERE subject = ?1 OR object = ?1",
1228 params![oid],
1229 )?;
1230 self.conn.execute("DELETE FROM entities_fts WHERE id = ?1", params![oid])?;
1231 }
1232 self.conn.execute(
1233 "DELETE FROM entities WHERE kind = ?1 AND sources LIKE ?2",
1234 params![scc_core::kinds::OCCURRENCE, format!("%\"{path}\"%")],
1235 )?;
1236 for concept in &affected_concepts {
1241 let remaining: Vec<String> = {
1242 let mut stmt = self.conn.prepare(
1243 "SELECT DISTINCT json_extract(e.attributes, '$.path') FROM entities e
1244 JOIN relationships r ON r.subject = e.id
1245 WHERE r.predicate = ?1 AND r.object = ?2 AND e.kind = ?3
1246 AND json_extract(e.attributes, '$.path') IS NOT NULL
1247 ORDER BY 1",
1248 )?;
1249 let rows = stmt.query_map(
1250 params![
1251 scc_core::predicates::OCCURS,
1252 concept,
1253 scc_core::kinds::OCCURRENCE
1254 ],
1255 |r| r.get::<_, String>(0),
1256 )?;
1257 let mut v = Vec::new();
1258 for r in rows {
1259 v.push(r?);
1260 }
1261 v
1262 };
1263 if remaining.is_empty() {
1264 self.conn.execute(
1265 "DELETE FROM relationships WHERE subject = ?1 OR object = ?1",
1266 params![concept],
1267 )?;
1268 self.conn.execute("DELETE FROM entities_fts WHERE id = ?1", params![concept])?;
1269 self.conn.execute("DELETE FROM entities WHERE id = ?1", params![concept])?;
1270 } else {
1271 self.conn.execute(
1272 "UPDATE entities SET sources = ?1 WHERE id = ?2",
1273 params![serde_json::to_string(&remaining)?, concept],
1274 )?;
1275 }
1276 }
1277 _sp.commit()?;
1278 Ok(())
1279 }
1280
1281 pub fn paths_depending_on(&self, path: &str) -> Result<Vec<String>> {
1286 let file_id = scc_core::entity_id(&self.repo_id, scc_core::kinds::FILE, path);
1287 let mut stmt = self.conn.prepare(
1288 "SELECT DISTINCT source_path FROM relationships
1289 WHERE source_path != ?1 AND source_path != ''
1290 AND predicate IN (?4, ?5)
1291 AND (
1292 object = ?2
1293 OR object IN (
1294 SELECT id FROM entities
1295 WHERE kind = ?3 AND json_extract(attributes, '$.file') = ?1
1296 )
1297 )
1298 ORDER BY source_path",
1299 )?;
1300 let rows = stmt.query_map(
1301 params![
1302 path,
1303 file_id,
1304 scc_core::kinds::SYMBOL,
1305 scc_core::predicates::IMPORTS,
1306 scc_core::predicates::CALLS
1307 ],
1308 |r| r.get::<_, String>(0),
1309 )?;
1310 let mut out = Vec::new();
1311 for r in rows {
1312 let p = r?;
1313 if !p.is_empty() {
1314 out.push(p);
1315 }
1316 }
1317 Ok(out)
1318 }
1319
1320 pub fn purge_all(&self) -> Result<()> {
1323 let _sp = self.nest()?;
1324 for table in [
1325 "symbols",
1326 "entities",
1327 "relationships",
1328 "evidence",
1329 "components",
1330 "flows",
1331 "invariants",
1332 "tests",
1333 "context_cache",
1334 "drift_findings",
1335 ] {
1336 self.conn.execute(&format!("DELETE FROM {table}"), [])?;
1337 }
1338 self.conn.execute("DELETE FROM files", [])?;
1339 self.conn.execute("DELETE FROM snapshots", [])?;
1340 _sp.commit()?;
1341 Ok(())
1342 }
1343
1344 #[allow(clippy::too_many_arguments)]
1349 pub fn insert_symbol(
1351 &self,
1352 file: &str,
1353 name: &str,
1354 kind: &str,
1355 signature: Option<&str>,
1356 start_line: u32,
1357 end_line: u32,
1358 exported: bool,
1359 docstring: Option<&str>,
1360 ) -> Result<i64> {
1361 self.conn.execute(
1362 "INSERT INTO symbols (file, name, symbol_kind, signature, start_line, end_line, exported, docstring)
1363 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
1364 params![
1365 file,
1366 name,
1367 kind,
1368 signature,
1369 start_line as i64,
1370 end_line as i64,
1371 exported as i64,
1372 docstring
1373 ],
1374 )?;
1375 let id = self.conn.last_insert_rowid();
1376 self.conn.execute(
1377 "INSERT INTO symbols_fts (name, signature, symbol_kind, file) VALUES (?1, ?2, ?3, ?4)",
1378 params![name, signature.unwrap_or(""), kind, file],
1379 )?;
1380 Ok(id)
1381 }
1382
1383 pub fn symbols_in_file(&self, file: &str) -> Result<Vec<(i64, String, String, Option<String>, u32, u32, bool, Option<String>)>> {
1385 let mut stmt = self
1386 .conn
1387 .prepare("SELECT id, name, symbol_kind, signature, start_line, end_line, exported, docstring FROM symbols WHERE file = ?1 ORDER BY start_line")?;
1388 let rows = stmt.query_map(params![file], |r| {
1389 Ok((
1390 r.get::<_, i64>(0)?,
1391 r.get::<_, String>(1)?,
1392 r.get::<_, String>(2)?,
1393 r.get::<_, Option<String>>(3)?,
1394 r.get::<_, i64>(4)? as u32,
1395 r.get::<_, i64>(5)? as u32,
1396 r.get::<_, i64>(6)? != 0,
1397 r.get::<_, Option<String>>(7)?,
1398 ))
1399 })?;
1400 let mut out = Vec::new();
1401 for r in rows {
1402 out.push(r?);
1403 }
1404 Ok(out)
1405 }
1406
1407 pub fn symbols_named(&self, name: &str) -> Result<Vec<(String, String, String)>> {
1409 let mut stmt = self
1410 .conn
1411 .prepare("SELECT file, symbol_kind, signature FROM symbols WHERE name = ?1")?;
1412 let rows = stmt.query_map(params![name], |r| {
1413 Ok((r.get(0)?, r.get(1)?, r.get(2)?))
1414 })?;
1415 let mut out = Vec::new();
1416 for r in rows {
1417 out.push(r?);
1418 }
1419 Ok(out)
1420 }
1421
1422 pub fn insert_imports(&self, file: &str, imports: &[(String, Vec<(String, String)>, u32, String)]) -> Result<()> {
1428 let _sp = self.nest()?;
1429 self.conn.execute("DELETE FROM imports WHERE file = ?1", params![file])?;
1430 for (module, names, line, typ) in imports {
1431 self.conn.execute(
1432 "INSERT INTO imports (file, module, names, line, type) VALUES (?1, ?2, ?3, ?4, ?5)",
1433 params![file, module, serde_json::to_string(names)?, *line as i64, typ],
1434 )?;
1435 }
1436 _sp.commit()?;
1437 Ok(())
1438 }
1439
1440 pub fn imports_in_file(&self, file: &str) -> Result<Vec<(String, Vec<(String, String)>, u32, String)>> {
1442 let mut stmt = self
1443 .conn
1444 .prepare("SELECT module, names, line, type FROM imports WHERE file = ?1 ORDER BY line")?;
1445 let rows = stmt.query_map(params![file], |r| {
1446 Ok((
1447 r.get::<_, String>(0)?,
1448 r.get::<_, String>(1)?,
1449 r.get::<_, i64>(2)? as u32,
1450 r.get::<_, String>(3)?,
1451 ))
1452 })?;
1453 let mut out = Vec::new();
1454 for r in rows {
1455 let (module, names, line, typ) = r?;
1456 out.push((
1457 module,
1458 serde_json::from_str(&names).unwrap_or_default(),
1459 line,
1460 typ,
1461 ));
1462 }
1463 Ok(out)
1464 }
1465
1466 pub fn all_imports(&self) -> Result<Vec<(String, String, Vec<(String, String)>, u32, String)>> {
1468 let mut stmt = self
1469 .conn
1470 .prepare("SELECT file, module, names, line, type FROM imports ORDER BY file, line")?;
1471 let rows = stmt.query_map([], |r| {
1472 Ok((
1473 r.get::<_, String>(0)?,
1474 r.get::<_, String>(1)?,
1475 r.get::<_, String>(2)?,
1476 r.get::<_, i64>(3)? as u32,
1477 r.get::<_, String>(4)?,
1478 ))
1479 })?;
1480 let mut out = Vec::new();
1481 for r in rows {
1482 let (file, module, names, line, typ) = r?;
1483 out.push((
1484 file,
1485 module,
1486 serde_json::from_str(&names).unwrap_or_default(),
1487 line,
1488 typ,
1489 ));
1490 }
1491 Ok(out)
1492 }
1493
1494 pub fn insert_entity(&self, entity: &Entity, sources: &[String]) -> Result<()> {
1500 self.conn.execute(
1501 "INSERT OR REPLACE INTO entities (id, kind, name, attributes, evidence, sources)
1502 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
1503 params![
1504 entity.id,
1505 entity.kind,
1506 entity.name,
1507 serde_json::to_string(&entity.attributes)?,
1508 serde_json::to_string(&entity.evidence)?,
1509 serde_json::to_string(sources)?,
1510 ],
1511 )?;
1512 self.conn.execute(
1513 "INSERT OR REPLACE INTO entities_fts (id, kind, name, attributes) VALUES (?1, ?2, ?3, ?4)",
1514 params![
1515 entity.id,
1516 entity.kind,
1517 entity.name,
1518 serde_json::to_string(&entity.attributes)?
1519 ],
1520 )?;
1521 Ok(())
1522 }
1523
1524 pub fn get_entity(&self, id: &str) -> Result<Option<Entity>> {
1526 let row = self
1527 .conn
1528 .query_row(
1529 "SELECT id, kind, name, attributes, evidence FROM entities WHERE id = ?1",
1530 params![id],
1531 |r| {
1532 Ok((
1533 r.get::<_, String>(0)?,
1534 r.get::<_, String>(1)?,
1535 r.get::<_, String>(2)?,
1536 r.get::<_, String>(3)?,
1537 r.get::<_, String>(4)?,
1538 ))
1539 },
1540 )
1541 .optional()?;
1542 Ok(row.map(|(id, kind, name, attributes, evidence)| Entity {
1543 id,
1544 kind,
1545 name,
1546 attributes: serde_json::from_str(&attributes).unwrap_or_default(),
1547 evidence: serde_json::from_str(&evidence).unwrap_or_default(),
1548 }))
1549 }
1550
1551 pub fn entity_sources(&self, id: &str) -> Result<Vec<String>> {
1556 let row = self
1557 .conn
1558 .query_row(
1559 "SELECT sources FROM entities WHERE id = ?1",
1560 params![id],
1561 |r| r.get::<_, String>(0),
1562 )
1563 .optional()?;
1564 Ok(row
1565 .map(|s| serde_json::from_str(&s).unwrap_or_default())
1566 .unwrap_or_default())
1567 }
1568
1569 pub fn concept_occurrences(&self, concept_id: &str) -> Result<Vec<Entity>> {
1574 let mut stmt = self.conn.prepare(
1575 "SELECT e.id, e.kind, e.name, e.attributes, e.evidence FROM entities e
1576 JOIN relationships r ON r.subject = e.id
1577 WHERE r.predicate = ?1 AND r.object = ?2 AND e.kind = ?3
1578 ORDER BY e.id",
1579 )?;
1580 let rows = stmt.query_map(
1581 params![
1582 scc_core::predicates::OCCURS,
1583 concept_id,
1584 scc_core::kinds::OCCURRENCE
1585 ],
1586 |r| {
1587 Ok((
1588 r.get::<_, String>(0)?,
1589 r.get::<_, String>(1)?,
1590 r.get::<_, String>(2)?,
1591 r.get::<_, String>(3)?,
1592 r.get::<_, String>(4)?,
1593 ))
1594 },
1595 )?;
1596 let mut out = Vec::new();
1597 for r in rows {
1598 let (id, kind, name, attributes, evidence) = r?;
1599 out.push(Entity {
1600 id,
1601 kind,
1602 name,
1603 attributes: serde_json::from_str(&attributes).unwrap_or_default(),
1604 evidence: serde_json::from_str(&evidence).unwrap_or_default(),
1605 });
1606 }
1607 Ok(out)
1608 }
1609
1610 pub fn entities_by_kind(&self, kind: &str) -> Result<Vec<Entity>> {
1612 let mut stmt = self
1613 .conn
1614 .prepare("SELECT id, kind, name, attributes, evidence FROM entities WHERE kind = ?1 ORDER BY name")?;
1615 let rows = stmt.query_map(params![kind], |r| {
1616 Ok((
1617 r.get::<_, String>(0)?,
1618 r.get::<_, String>(1)?,
1619 r.get::<_, String>(2)?,
1620 r.get::<_, String>(3)?,
1621 r.get::<_, String>(4)?,
1622 ))
1623 })?;
1624 let mut out = Vec::new();
1625 for r in rows {
1626 let (id, kind, name, attributes, evidence) = r?;
1627 out.push(Entity {
1628 id,
1629 kind,
1630 name,
1631 attributes: serde_json::from_str(&attributes).unwrap_or_default(),
1632 evidence: serde_json::from_str(&evidence).unwrap_or_default(),
1633 });
1634 }
1635 Ok(out)
1636 }
1637
1638 pub fn all_entities(&self) -> Result<Vec<Entity>> {
1640 self.all_entities_impl()
1641 }
1642
1643 fn all_entities_impl(&self) -> Result<Vec<Entity>> {
1645 let mut stmt = self
1646 .conn
1647 .prepare("SELECT id, kind, name, attributes, evidence FROM entities ORDER BY kind, name")?;
1648 let rows = stmt.query_map([], |r| {
1649 Ok((
1650 r.get::<_, String>(0)?,
1651 r.get::<_, String>(1)?,
1652 r.get::<_, String>(2)?,
1653 r.get::<_, String>(3)?,
1654 r.get::<_, String>(4)?,
1655 ))
1656 })?;
1657 let mut out = Vec::new();
1658 for r in rows {
1659 let (id, kind, name, attributes, evidence) = r?;
1660 out.push(Entity {
1661 id,
1662 kind,
1663 name,
1664 attributes: serde_json::from_str(&attributes).unwrap_or_default(),
1665 evidence: serde_json::from_str(&evidence).unwrap_or_default(),
1666 });
1667 }
1668 Ok(out)
1669 }
1670
1671 pub fn delete_entity(&self, id: &str) -> Result<()> {
1673 self.conn.execute("DELETE FROM entities WHERE id = ?1", params![id])?;
1674 self.conn
1675 .execute("DELETE FROM entities_fts WHERE id = ?1", params![id])?;
1676 Ok(())
1677 }
1678
1679 pub fn delete_entities(&self, ids: &[String]) -> Result<()> {
1681 for id in ids {
1682 self.delete_entity(id)?;
1683 }
1684 Ok(())
1685 }
1686
1687 pub fn insert_relationship(&self, rel: &Relationship, source_path: &str) -> Result<()> {
1693 self.conn.execute(
1694 "INSERT OR REPLACE INTO relationships (id, subject, predicate, object, provenance, confidence, evidence, verified_at, source_path)
1695 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
1696 params![
1697 rel.id,
1698 rel.subject,
1699 rel.predicate,
1700 rel.object,
1701 rel.provenance.as_str(),
1702 rel.confidence,
1703 serde_json::to_string(&rel.evidence)?,
1704 rel.verified_at,
1705 source_path,
1706 ],
1707 )?;
1708 Ok(())
1709 }
1710
1711 pub fn relationships_for(&self, subject: &str) -> Result<Vec<Relationship>> {
1713 self.query_relationships("SELECT * FROM relationships WHERE subject = ?1 ORDER BY id", params![subject])
1714 }
1715
1716 pub fn relationships_to(&self, object: &str) -> Result<Vec<Relationship>> {
1718 self.query_relationships("SELECT * FROM relationships WHERE object = ?1 ORDER BY id", params![object])
1719 }
1720
1721 pub fn relationships_between(&self, subject: &str, predicate: &str, object: &str) -> Result<Vec<Relationship>> {
1723 self.query_relationships(
1724 "SELECT * FROM relationships WHERE subject = ?1 AND predicate = ?2 AND object = ?3",
1725 params![subject, predicate, object],
1726 )
1727 }
1728
1729 pub fn all_relationships(&self) -> Result<Vec<Relationship>> {
1731 self.query_relationships("SELECT * FROM relationships ORDER BY id", [])
1732 }
1733
1734 pub fn relationship_ids_with_source(&self, path: &str, predicate: &str) -> Result<Vec<String>> {
1737 let mut stmt = self.conn.prepare(
1738 "SELECT id FROM relationships WHERE source_path = ?1 AND predicate = ?2",
1739 )?;
1740 let rows = stmt.query_map(params![path, predicate], |r| r.get::<_, String>(0))?;
1741 let mut out = Vec::new();
1742 for r in rows {
1743 out.push(r?);
1744 }
1745 Ok(out)
1746 }
1747
1748 pub fn count_relationships(&self) -> Result<u64> {
1750 Ok(self
1751 .conn
1752 .query_row("SELECT COUNT(*) FROM relationships", [], |r| r.get(0))?)
1753 }
1754
1755 pub fn delete_relationship(&self, id: &str) -> Result<()> {
1757 self.conn
1758 .execute("DELETE FROM relationships WHERE id = ?1", params![id])?;
1759 Ok(())
1760 }
1761
1762 fn query_relationships(
1764 &self,
1765 sql: &str,
1766 p: impl rusqlite::Params,
1768 ) -> Result<Vec<Relationship>> {
1769 let mut stmt = self.conn.prepare(sql)?;
1770 let rows = stmt.query_map(p, |r| {
1771 Ok((
1772 r.get::<_, String>(0)?,
1773 r.get::<_, String>(1)?,
1774 r.get::<_, String>(2)?,
1775 r.get::<_, String>(3)?,
1776 r.get::<_, String>(4)?,
1777 r.get::<_, f64>(5)?,
1778 r.get::<_, String>(6)?,
1779 r.get::<_, String>(7)?,
1780 ))
1781 })?;
1782 let mut out = Vec::new();
1783 for row in rows {
1784 let (id, subject, predicate, object, provenance, confidence, evidence, verified_at) =
1785 row?;
1786 out.push(Relationship {
1787 id,
1788 subject,
1789 predicate,
1790 object,
1791 provenance: parse_provenance(&provenance),
1792 confidence,
1793 evidence: serde_json::from_str(&evidence).unwrap_or_default(),
1794 verified_at,
1795 });
1796 }
1797 Ok(out)
1798 }
1799
1800 pub fn insert_evidence(&self, ev: &Evidence) -> Result<()> {
1806 self.conn.execute(
1807 "INSERT OR REPLACE INTO evidence (id, type, path, symbol, start_line, end_line, revision, content_hash, extractor, extractor_version)
1808 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
1809 params![
1810 ev.id,
1811 evidence_type_str(&ev.r#type),
1812 ev.path,
1813 ev.symbol,
1814 ev.start_line.map(|l| l as i64),
1815 ev.end_line.map(|l| l as i64),
1816 ev.revision,
1817 ev.content_hash,
1818 ev.extractor,
1819 ev.extractor_version,
1820 ],
1821 )?;
1822 Ok(())
1823 }
1824
1825 pub fn get_evidence(&self, id: &str) -> Result<Option<Evidence>> {
1827 let row = self
1828 .conn
1829 .query_row(
1830 "SELECT id, type, path, symbol, start_line, end_line, revision, content_hash, extractor, extractor_version FROM evidence WHERE id = ?1",
1831 params![id],
1832 |r| {
1833 Ok((
1834 r.get::<_, String>(0)?,
1835 r.get::<_, String>(1)?,
1836 r.get::<_, Option<String>>(2)?,
1837 r.get::<_, Option<String>>(3)?,
1838 r.get::<_, Option<i64>>(4)?,
1839 r.get::<_, Option<i64>>(5)?,
1840 r.get::<_, Option<String>>(6)?,
1841 r.get::<_, Option<String>>(7)?,
1842 r.get::<_, Option<String>>(8)?,
1843 r.get::<_, Option<String>>(9)?,
1844 ))
1845 },
1846 )
1847 .optional()?;
1848 Ok(row.map(|(id, typ, path, symbol, sl, el, rev, hash, ext, extv)| Evidence {
1849 id,
1850 r#type: parse_evidence_type(&typ),
1851 path,
1852 symbol,
1853 start_line: sl.map(|v| v as u32),
1854 end_line: el.map(|v| v as u32),
1855 revision: rev,
1856 content_hash: hash,
1857 extractor: ext,
1858 extractor_version: extv,
1859 }))
1860 }
1861
1862 pub fn all_evidence(&self) -> Result<Vec<Evidence>> {
1864 let mut stmt = self
1865 .conn
1866 .prepare("SELECT id, type, path, symbol, start_line, end_line, revision, content_hash, extractor, extractor_version FROM evidence ORDER BY id")?;
1867 let rows = stmt.query_map([], |r| {
1868 Ok((
1869 r.get::<_, String>(0)?,
1870 r.get::<_, String>(1)?,
1871 r.get::<_, Option<String>>(2)?,
1872 r.get::<_, Option<String>>(3)?,
1873 r.get::<_, Option<i64>>(4)?,
1874 r.get::<_, Option<i64>>(5)?,
1875 r.get::<_, Option<String>>(6)?,
1876 r.get::<_, Option<String>>(7)?,
1877 r.get::<_, Option<String>>(8)?,
1878 r.get::<_, Option<String>>(9)?,
1879 ))
1880 })?;
1881 let mut out = Vec::new();
1882 for row in rows {
1883 let (id, typ, path, symbol, sl, el, rev, hash, ext, extv) = row?;
1884 out.push(Evidence {
1885 id,
1886 r#type: parse_evidence_type(&typ),
1887 path,
1888 symbol,
1889 start_line: sl.map(|v| v as u32),
1890 end_line: el.map(|v| v as u32),
1891 revision: rev,
1892 content_hash: hash,
1893 extractor: ext,
1894 extractor_version: extv,
1895 });
1896 }
1897 Ok(out)
1898 }
1899
1900 pub fn evidence_for_path(&self, path: &str) -> Result<Vec<Evidence>> {
1902 let mut stmt = self
1903 .conn
1904 .prepare("SELECT id, type, path, symbol, start_line, end_line, revision, content_hash, extractor, extractor_version FROM evidence WHERE path = ?1 ORDER BY id")?;
1905 let rows = stmt.query_map(params![path], |r| {
1906 Ok((
1907 r.get::<_, String>(0)?,
1908 r.get::<_, String>(1)?,
1909 r.get::<_, Option<String>>(2)?,
1910 r.get::<_, Option<String>>(3)?,
1911 r.get::<_, Option<i64>>(4)?,
1912 r.get::<_, Option<i64>>(5)?,
1913 r.get::<_, Option<String>>(6)?,
1914 r.get::<_, Option<String>>(7)?,
1915 r.get::<_, Option<String>>(8)?,
1916 r.get::<_, Option<String>>(9)?,
1917 ))
1918 })?;
1919 let mut out = Vec::new();
1920 for row in rows {
1921 let (id, typ, path, symbol, sl, el, rev, hash, ext, extv) = row?;
1922 out.push(Evidence {
1923 id,
1924 r#type: parse_evidence_type(&typ),
1925 path,
1926 symbol,
1927 start_line: sl.map(|v| v as u32),
1928 end_line: el.map(|v| v as u32),
1929 revision: rev,
1930 content_hash: hash,
1931 extractor: ext,
1932 extractor_version: extv,
1933 });
1934 }
1935 Ok(out)
1936 }
1937
1938 pub fn replace_components(&self, components: &[Entity]) -> Result<()> {
1944 let _sp = self.nest()?;
1945 self.conn.execute("DELETE FROM components", [])?;
1946 let keep: HashSet<&str> = components.iter().map(|c| c.id.as_str()).collect();
1951 let stale: Vec<String> = {
1952 let mut stmt = self.conn.prepare("SELECT id FROM entities WHERE kind = ?1")?;
1953 let rows = stmt.query_map(params![scc_core::kinds::COMPONENT], |r| {
1954 r.get::<_, String>(0)
1955 })?;
1956 let mut v = Vec::new();
1957 for r in rows {
1958 let id = r?;
1959 if !keep.contains(id.as_str()) {
1960 v.push(id);
1961 }
1962 }
1963 v
1964 };
1965 for id in &stale {
1966 self.conn.execute("DELETE FROM entities WHERE id = ?1", params![id])?;
1967 self.conn.execute("DELETE FROM entities_fts WHERE id = ?1", params![id])?;
1968 }
1969 for c in components {
1970 self.conn.execute(
1971 "INSERT INTO components (id, name, kind, responsibility, implementation, evidence, attributes)
1972 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
1973 params![
1974 c.id,
1975 c.name,
1976 c.kind,
1977 serde_json::to_string(&c.attributes.get("responsibility").cloned().unwrap_or(serde_json::json!([])))?,
1978 serde_json::to_string(&c.attributes.get("implementation").cloned().unwrap_or(serde_json::json!([])))?,
1979 serde_json::to_string(&c.evidence)?,
1980 serde_json::to_string(&c.attributes)?,
1981 ],
1982 )?;
1983 self.conn.execute(
1984 "INSERT OR REPLACE INTO entities (id, kind, name, attributes, evidence, sources) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
1985 params![c.id, c.kind, c.name, serde_json::to_string(&c.attributes)?, serde_json::to_string(&c.evidence)?, "[]"],
1986 )?;
1987 self.conn.execute(
1988 "INSERT OR REPLACE INTO entities_fts (id, kind, name, attributes) VALUES (?1, ?2, ?3, ?4)",
1989 params![c.id, c.kind, c.name, serde_json::to_string(&c.attributes)?],
1990 )?;
1991 }
1992 _sp.commit()?;
1993 Ok(())
1994 }
1995
1996 pub fn components(&self) -> Result<Vec<Entity>> {
1998 let mut stmt = self
1999 .conn
2000 .prepare("SELECT id, name, kind, responsibility, implementation, evidence, attributes FROM components ORDER BY name")?;
2001 let rows = stmt.query_map([], |r| {
2002 Ok((
2003 r.get::<_, String>(0)?,
2004 r.get::<_, String>(1)?,
2005 r.get::<_, String>(2)?,
2006 r.get::<_, String>(3)?,
2007 r.get::<_, String>(4)?,
2008 r.get::<_, String>(5)?,
2009 r.get::<_, String>(6)?,
2010 ))
2011 })?;
2012 let mut out = Vec::new();
2013 for row in rows {
2014 let (id, name, kind, resp, implm, ev, attrs) = row?;
2015 let mut attributes: std::collections::BTreeMap<String, serde_json::Value> =
2016 serde_json::from_str(&attrs).unwrap_or_default();
2017 if let Ok(r) = serde_json::from_str::<Vec<serde_json::Value>>(&resp) {
2018 attributes.insert("responsibility".into(), serde_json::Value::Array(r));
2019 }
2020 if let Ok(r) = serde_json::from_str::<Vec<serde_json::Value>>(&implm) {
2021 attributes.insert("implementation".into(), serde_json::Value::Array(r));
2022 }
2023 out.push(Entity {
2024 id,
2025 kind,
2026 name,
2027 attributes,
2028 evidence: serde_json::from_str(&ev).unwrap_or_default(),
2029 });
2030 }
2031 Ok(out)
2032 }
2033
2034 pub fn replace_flow_graphs(&self, graphs: &[scc_core::FlowGraph]) -> Result<()> {
2040 let _sp = self.nest()?;
2041 self.conn.execute("DELETE FROM flow_graphs", [])?;
2042 for g in graphs {
2043 let kind = scc_core::flow_kind_str(&g.kind);
2044 let trigger = g.trigger.clone().unwrap_or_default();
2045 self.conn.execute(
2046 "INSERT OR REPLACE INTO flow_graphs (id, kind, name, trigger, graph) VALUES (?1, ?2, ?3, ?4, ?5)",
2047 params![g.id, kind, g.name, trigger, serde_json::to_string(g)?],
2048 )?;
2049 }
2050 _sp.commit()?;
2051 Ok(())
2052 }
2053
2054 pub fn flow_graphs(&self) -> Result<Vec<scc_core::FlowGraph>> {
2056 let mut stmt = self
2057 .conn
2058 .prepare("SELECT graph FROM flow_graphs ORDER BY id")?;
2059 let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
2060 let mut out = Vec::new();
2061 for r in rows {
2062 let json = r?;
2063 if let Ok(g) = serde_json::from_str(&json) {
2064 out.push(g);
2065 }
2066 }
2067 Ok(out)
2068 }
2069
2070 pub fn replace_flows(&self, flows: &[Flow]) -> Result<()> {
2072 let _sp = self.nest()?;
2073 self.conn.execute("DELETE FROM flows", [])?;
2074 for f in flows {
2075 self.conn.execute(
2076 "INSERT INTO flows (id, kind, name, trigger, steps, attributes) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
2077 params![
2078 f.id,
2079 flow_kind_str(&f.kind),
2080 f.name,
2081 f.trigger,
2082 serde_json::to_string(&f.steps)?,
2083 serde_json::to_string(&f.attributes)?
2084 ],
2085 )?;
2086 }
2087 _sp.commit()?;
2088 Ok(())
2089 }
2090
2091 pub fn flows(&self) -> Result<Vec<Flow>> {
2093 let mut stmt = self
2094 .conn
2095 .prepare("SELECT id, kind, name, trigger, steps, attributes FROM flows ORDER BY kind, name")?;
2096 let rows = stmt.query_map([], |r| {
2097 Ok((
2098 r.get::<_, String>(0)?,
2099 r.get::<_, String>(1)?,
2100 r.get::<_, String>(2)?,
2101 r.get::<_, Option<String>>(3)?,
2102 r.get::<_, String>(4)?,
2103 r.get::<_, String>(5)?,
2104 ))
2105 })?;
2106 let mut out = Vec::new();
2107 for row in rows {
2108 let (id, kind, name, trigger, steps, attrs) = row?;
2109 out.push(Flow {
2110 id,
2111 kind: parse_flow_kind(&kind),
2112 name,
2113 trigger,
2114 steps: serde_json::from_str(&steps).unwrap_or_default(),
2115 attributes: serde_json::from_str(&attrs).unwrap_or_default(),
2116 });
2117 }
2118 Ok(out)
2119 }
2120
2121 pub fn flow(&self, id: &str) -> Result<Option<Flow>> {
2123 let row = self
2124 .conn
2125 .query_row(
2126 "SELECT id, kind, name, trigger, steps, attributes FROM flows WHERE id = ?1",
2127 params![id],
2128 |r| {
2129 Ok((
2130 r.get::<_, String>(0)?,
2131 r.get::<_, String>(1)?,
2132 r.get::<_, String>(2)?,
2133 r.get::<_, Option<String>>(3)?,
2134 r.get::<_, String>(4)?,
2135 r.get::<_, String>(5)?,
2136 ))
2137 },
2138 )
2139 .optional()?;
2140 Ok(row.map(|(id, kind, name, trigger, steps, attrs)| Flow {
2141 id,
2142 kind: parse_flow_kind(&kind),
2143 name,
2144 trigger,
2145 steps: serde_json::from_str(&steps).unwrap_or_default(),
2146 attributes: serde_json::from_str(&attrs).unwrap_or_default(),
2147 }))
2148 }
2149
2150 pub fn replace_invariants(&self, invariants: &[Invariant]) -> Result<()> {
2152 let _sp = self.nest()?;
2153 self.conn.execute("DELETE FROM invariants", [])?;
2154 for inv in invariants {
2155 self.conn.execute(
2156 "INSERT INTO invariants (id, statement, severity, scope, enforced_by, provenance, evidence)
2157 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
2158 params![
2159 inv.id,
2160 inv.statement,
2161 severity_str(&inv.severity),
2162 serde_json::to_string(&inv.scope)?,
2163 serde_json::to_string(&inv.enforced_by)?,
2164 inv.provenance.map(|p| p.as_str().to_string()).unwrap_or_else(|| "DECLARED".into()),
2165 serde_json::to_string(&inv.evidence)?,
2166 ],
2167 )?;
2168 }
2169 _sp.commit()?;
2170 Ok(())
2171 }
2172
2173 pub fn invariants(&self) -> Result<Vec<Invariant>> {
2175 let mut stmt = self
2176 .conn
2177 .prepare("SELECT id, statement, severity, scope, enforced_by, provenance, evidence FROM invariants ORDER BY id")?;
2178 let rows = stmt.query_map([], |r| {
2179 Ok((
2180 r.get::<_, String>(0)?,
2181 r.get::<_, String>(1)?,
2182 r.get::<_, String>(2)?,
2183 r.get::<_, String>(3)?,
2184 r.get::<_, String>(4)?,
2185 r.get::<_, String>(5)?,
2186 r.get::<_, String>(6)?,
2187 ))
2188 })?;
2189 let mut out = Vec::new();
2190 for row in rows {
2191 let (id, statement, severity, scope, enforced_by, provenance, ev) = row?;
2192 out.push(Invariant {
2193 id,
2194 statement,
2195 severity: parse_severity(&severity),
2196 scope: serde_json::from_str(&scope).unwrap_or_default(),
2197 enforced_by: serde_json::from_str(&enforced_by).unwrap_or_default(),
2198 provenance: Some(parse_provenance(&provenance)),
2199 evidence: serde_json::from_str(&ev).unwrap_or_default(),
2200 });
2201 }
2202 Ok(out)
2203 }
2204
2205 pub fn insert_test(&self, id: &str, name: &str, file: &str, kind: &str, symbol: Option<&str>) -> Result<()> {
2207 self.conn.execute(
2208 "INSERT OR REPLACE INTO tests (id, name, file, kind, symbol) VALUES (?1, ?2, ?3, ?4, ?5)",
2209 params![id, name, file, kind, symbol],
2210 )?;
2211 Ok(())
2212 }
2213
2214 pub fn tests(&self) -> Result<Vec<(String, String, String, String, Option<String>)>> {
2216 let mut stmt = self
2217 .conn
2218 .prepare("SELECT id, name, file, kind, symbol FROM tests ORDER BY file, name")?;
2219 let rows = stmt.query_map([], |r| {
2220 Ok((
2221 r.get::<_, String>(0)?,
2222 r.get::<_, String>(1)?,
2223 r.get::<_, String>(2)?,
2224 r.get::<_, String>(3)?,
2225 r.get::<_, Option<String>>(4)?,
2226 ))
2227 })?;
2228 let mut out = Vec::new();
2229 for r in rows {
2230 out.push(r?);
2231 }
2232 Ok(out)
2233 }
2234
2235 pub fn sweep_orphan_evidence(&self) -> Result<u64> {
2242 let mut referenced: HashSet<String> = HashSet::new();
2246
2247 for (table, column) in [
2249 ("entities", "evidence"),
2250 ("relationships", "evidence"),
2251 ("components", "evidence"),
2252 ("invariants", "evidence"),
2253 ] {
2254 let sql = format!("SELECT {column} FROM {table}");
2255 let mut stmt = self.conn.prepare(&sql)?;
2256 let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
2257 for r in rows {
2258 if let Ok(ids) = serde_json::from_str::<Vec<String>>(&r?) {
2259 referenced.extend(ids);
2260 }
2261 }
2262 drop(stmt);
2263 }
2264
2265 {
2267 let mut stmt = self.conn.prepare("SELECT steps FROM flows")?;
2268 let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
2269 for r in rows {
2270 if let Ok(steps) = serde_json::from_str::<Vec<scc_core::FlowStep>>(&r?) {
2271 for step in steps {
2272 referenced.extend(step.evidence);
2273 }
2274 }
2275 }
2276 drop(stmt);
2277 }
2278
2279 {
2281 let mut stmt = self.conn.prepare("SELECT graph FROM flow_graphs")?;
2282 let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
2283 for r in rows {
2284 if let Ok(g) = serde_json::from_str::<scc_core::FlowGraph>(&r?) {
2285 for node in &g.nodes {
2286 referenced.extend(node.evidence.iter().cloned());
2287 }
2288 for edge in &g.edges {
2289 referenced.extend(edge.evidence.iter().cloned());
2290 }
2291 }
2292 }
2293 drop(stmt);
2294 }
2295
2296 let mut stmt = self.conn.prepare("SELECT id FROM evidence")?;
2297 let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
2298 let mut orphans: Vec<String> = Vec::new();
2299 for r in rows {
2300 let id = r?;
2301 if !referenced.contains(&id) {
2302 orphans.push(id);
2303 }
2304 }
2305 drop(stmt);
2306
2307 let count = orphans.len() as u64;
2308 for chunk in orphans.chunks(500) {
2310 let placeholders = vec!["?"; chunk.len()].join(",");
2311 let sql = format!("DELETE FROM evidence WHERE id IN ({placeholders})");
2312 self.conn
2313 .execute(&sql, rusqlite::params_from_iter(chunk.iter()))?;
2314 }
2315 Ok(count)
2316 }
2317
2318 pub fn cache_epoch(&self) -> Result<String> {
2327 let revision = self
2328 .latest_snapshot()?
2329 .map(|s| s.revision)
2330 .unwrap_or_else(|| "not-indexed".to_string());
2331 Ok(self.model_epoch()?.composite(&revision))
2332 }
2333
2334 pub fn cache_get(&self, key: &str, epoch: &str) -> Result<Option<String>> {
2336 let row = self
2337 .conn
2338 .query_row(
2339 "SELECT pack FROM context_cache WHERE key = ?1 AND epoch = ?2",
2340 params![key, epoch],
2341 |r| r.get(0),
2342 )
2343 .optional()?;
2344 Ok(row)
2345 }
2346
2347 pub fn cache_put(&self, key: &str, pack: &str, epoch: &str) -> Result<()> {
2349 self.conn.execute(
2350 "INSERT OR REPLACE INTO context_cache (key, pack, epoch, created_at) VALUES (?1, ?2, ?3, ?4)",
2351 params![key, pack, epoch, scc_core::now_rfc3339()],
2352 )?;
2353 Ok(())
2354 }
2355
2356 pub fn cache_clear(&self) -> Result<()> {
2358 self.conn.execute("DELETE FROM context_cache", [])?;
2359 Ok(())
2360 }
2361
2362 pub fn replace_intent_claims(&self, claims: &[(String, serde_json::Value)]) -> Result<()> {
2368 let _sp = self.nest()?;
2369 self.conn.execute("DELETE FROM intent_claims", [])?;
2370 for (source, claim) in claims {
2371 self.conn.execute(
2372 "INSERT INTO intent_claims (source, claim, created_at) VALUES (?1, ?2, ?3)",
2373 params![source, serde_json::to_string(claim)?, scc_core::now_rfc3339()],
2374 )?;
2375 }
2376 _sp.commit()?;
2377 self.bump_epoch(ModelEpochKind::Intent)?;
2379 Ok(())
2380 }
2381
2382 pub fn intent_claims(&self) -> Result<Vec<(String, serde_json::Value)>> {
2384 let mut stmt = self
2385 .conn
2386 .prepare("SELECT source, claim FROM intent_claims ORDER BY id")?;
2387 let rows = stmt.query_map([], |r| {
2388 Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
2389 })?;
2390 let mut out = Vec::new();
2391 for r in rows {
2392 let (source, claim) = r?;
2393 if let Ok(v) = serde_json::from_str(&claim) {
2394 out.push((source, v));
2395 }
2396 }
2397 Ok(out)
2398 }
2399
2400 pub fn runtime_edge_rows(&self) -> Result<Vec<RuntimeEdgeRow>> {
2406 let mut stmt = self.conn.prepare(
2407 "SELECT source, target, count, latency_ms, errors, last_observed
2408 FROM runtime_edges ORDER BY source, target",
2409 )?;
2410 let rows = stmt.query_map([], |r| {
2411 Ok(RuntimeEdgeRow {
2412 source: r.get(0)?,
2413 target: r.get(1)?,
2414 count: r.get::<_, i64>(2)? as u64,
2415 latency_ms: r.get(3)?,
2416 errors: r.get::<_, i64>(4)? as u64,
2417 last_observed: r.get(5)?,
2418 })
2419 })?;
2420 let mut out = Vec::new();
2421 for r in rows {
2422 out.push(r?);
2423 }
2424 Ok(out)
2425 }
2426
2427 pub fn upsert_trace_signature(&self, signature: &str, latency_ms: f64, errors: u64) -> Result<()> {
2438 self.conn.execute(
2439 "INSERT INTO trace_signatures (signature, count, latency_ms, errors, last_observed)
2440 VALUES (?1, 1, ?2, ?3, ?4)
2441 ON CONFLICT(signature) DO UPDATE SET
2442 count = trace_signatures.count + 1,
2443 latency_ms = (trace_signatures.latency_ms * trace_signatures.count + excluded.latency_ms)
2444 / (trace_signatures.count + 1),
2445 errors = trace_signatures.errors + excluded.errors,
2446 last_observed = excluded.last_observed",
2447 params![signature, latency_ms, errors as i64, scc_core::now_rfc3339()],
2448 )?;
2449 Ok(())
2450 }
2451
2452 pub fn trace_signatures(&self) -> Result<Vec<(String, u64, f64, u64, String)>> {
2455 let mut stmt = self.conn.prepare(
2456 "SELECT signature, count, latency_ms, errors, last_observed
2457 FROM trace_signatures ORDER BY count DESC, signature",
2458 )?;
2459 let rows = stmt.query_map([], |r| {
2460 Ok((
2461 r.get::<_, String>(0)?,
2462 r.get::<_, i64>(1)? as u64,
2463 r.get::<_, f64>(2)?,
2464 r.get::<_, i64>(3)? as u64,
2465 r.get::<_, String>(4)?,
2466 ))
2467 })?;
2468 let mut out = Vec::new();
2469 for r in rows {
2470 out.push(r?);
2471 }
2472 Ok(out)
2473 }
2474
2475 pub fn put_embedding(&self, entity_id: &str, vector: &[f32], model: &str) -> Result<()> {
2481 let bytes: Vec<u8> = vector
2482 .iter()
2483 .flat_map(|f| f.to_le_bytes())
2484 .collect();
2485 self.conn.execute(
2486 "INSERT OR REPLACE INTO embeddings (entity_id, vector, model, updated_at)
2487 VALUES (?1, ?2, ?3, ?4)",
2488 params![entity_id, bytes, model, scc_core::now_rfc3339()],
2489 )?;
2490 Ok(())
2491 }
2492
2493 pub fn get_embedding(&self, entity_id: &str) -> Result<Option<(Vec<f32>, String)>> {
2495 let row = self
2496 .conn
2497 .query_row(
2498 "SELECT vector, model FROM embeddings WHERE entity_id = ?1",
2499 params![entity_id],
2500 |r| Ok((r.get::<_, Vec<u8>>(0)?, r.get::<_, String>(1)?)),
2501 )
2502 .optional()?;
2503 Ok(row.map(|(bytes, model)| {
2504 let v = bytes
2505 .as_chunks::<4>().0.iter()
2506 .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
2507 .collect();
2508 (v, model)
2509 }))
2510 }
2511
2512 pub fn embedding_count(&self) -> Result<u64> {
2514 Ok(self
2515 .conn
2516 .query_row("SELECT COUNT(*) FROM embeddings", [], |r| r.get(0))?)
2517 }
2518
2519 pub fn add_drift_finding(&self, kind: &str, severity: &str, message: &str) -> Result<i64> {
2521 self.conn.execute(
2522 "INSERT INTO drift_findings (kind, severity, message, created_at, resolved) VALUES (?1, ?2, ?3, ?4, 0)",
2523 params![kind, severity, message, scc_core::now_rfc3339()],
2524 )?;
2525 Ok(self.conn.last_insert_rowid())
2526 }
2527
2528 pub fn drift_findings(&self, unresolved_only: bool) -> Result<Vec<(i64, String, String, String, String)>> {
2530 let sql = if unresolved_only {
2531 "SELECT id, kind, severity, message, created_at FROM drift_findings WHERE resolved = 0 ORDER BY id"
2532 } else {
2533 "SELECT id, kind, severity, message, created_at FROM drift_findings ORDER BY id"
2534 };
2535 let mut stmt = self.conn.prepare(sql)?;
2536 let rows = stmt.query_map([], |r| {
2537 Ok((
2538 r.get::<_, i64>(0)?,
2539 r.get::<_, String>(1)?,
2540 r.get::<_, String>(2)?,
2541 r.get::<_, String>(3)?,
2542 r.get::<_, String>(4)?,
2543 ))
2544 })?;
2545 let mut out = Vec::new();
2546 for r in rows {
2547 out.push(r?);
2548 }
2549 Ok(out)
2550 }
2551
2552 pub fn clear_drift_findings(&self) -> Result<()> {
2554 self.conn.execute("DELETE FROM drift_findings", [])?;
2555 Ok(())
2556 }
2557
2558 pub fn search_entities(&self, query: &str, limit: usize) -> Result<Vec<Entity>> {
2565 let q = fts_query(query);
2566 let mut stmt = self.conn.prepare(
2567 "SELECT e.id, e.kind, e.name, e.attributes, e.evidence
2568 FROM entities_fts f JOIN entities e ON e.id = f.id
2569 WHERE entities_fts MATCH ?1 ORDER BY bm25(entities_fts) LIMIT ?2",
2570 )?;
2571 let rows = stmt.query_map(params![q, limit as i64], |r| {
2572 Ok((
2573 r.get::<_, String>(0)?,
2574 r.get::<_, String>(1)?,
2575 r.get::<_, String>(2)?,
2576 r.get::<_, String>(3)?,
2577 r.get::<_, String>(4)?,
2578 ))
2579 })?;
2580 let mut out = Vec::new();
2581 for r in rows {
2582 let (id, kind, name, attributes, evidence) = r?;
2583 out.push(Entity {
2584 id,
2585 kind,
2586 name,
2587 attributes: serde_json::from_str(&attributes).unwrap_or_default(),
2588 evidence: serde_json::from_str(&evidence).unwrap_or_default(),
2589 });
2590 }
2591 Ok(out)
2592 }
2593
2594 pub fn search_symbols(&self, query: &str, limit: usize) -> Result<Vec<(String, String, String, String, u32)>> {
2597 let q = fts_query(query);
2598 let mut stmt = self.conn.prepare(
2599 "SELECT f.name, f.signature, f.symbol_kind, f.file,
2600 COALESCE((SELECT s.start_line FROM symbols s
2601 WHERE s.file = f.file AND s.name = f.name LIMIT 1), 0)
2602 FROM symbols_fts f
2603 WHERE symbols_fts MATCH ?1 ORDER BY bm25(symbols_fts) LIMIT ?2",
2604 )?;
2605 let rows = stmt.query_map(params![q, limit as i64], |r| {
2606 Ok((
2607 r.get::<_, String>(0)?,
2608 r.get::<_, Option<String>>(1)?.unwrap_or_default(),
2609 r.get::<_, String>(2)?,
2610 r.get::<_, String>(3)?,
2611 r.get::<_, i64>(4)? as u32,
2612 ))
2613 })?;
2614 let mut out = Vec::new();
2615 for r in rows {
2616 out.push(r?);
2617 }
2618 Ok(out)
2619 }
2620
2621 pub fn search_entities_like(&self, term: &str, limit: usize) -> Result<Vec<Entity>> {
2627 let pat = format!("%{}%", term.to_ascii_lowercase());
2628 let mut stmt = self.conn.prepare(
2629 "SELECT id, kind, name, attributes, evidence FROM entities
2630 WHERE lower(name) LIKE ?1 OR lower(attributes) LIKE ?1
2631 ORDER BY CASE WHEN lower(name) LIKE ?1 THEN 0 ELSE 1 END, length(name)
2632 LIMIT ?2",
2633 )?;
2634 let rows = stmt.query_map(params![pat, limit as i64], |r| {
2635 Ok((
2636 r.get::<_, String>(0)?,
2637 r.get::<_, String>(1)?,
2638 r.get::<_, String>(2)?,
2639 r.get::<_, String>(3)?,
2640 r.get::<_, String>(4)?,
2641 ))
2642 })?;
2643 let mut out = Vec::new();
2644 for r in rows {
2645 let (id, kind, name, attributes, evidence) = r?;
2646 out.push(Entity {
2647 id,
2648 kind,
2649 name,
2650 attributes: serde_json::from_str(&attributes).unwrap_or_default(),
2651 evidence: serde_json::from_str(&evidence).unwrap_or_default(),
2652 });
2653 }
2654 Ok(out)
2655 }
2656
2657 pub fn search_symbols_like(&self, term: &str, limit: usize) -> Result<Vec<(String, String, String, String, u32)>> {
2660 let pat = format!("%{}%", term.to_ascii_lowercase());
2661 let mut stmt = self.conn.prepare(
2662 "SELECT name, signature, symbol_kind, file, start_line FROM symbols
2663 WHERE lower(name) LIKE ?1 OR lower(signature) LIKE ?1 OR lower(docstring) LIKE ?1
2664 ORDER BY CASE WHEN lower(name) LIKE ?1 THEN 0 ELSE 1 END, length(name)
2665 LIMIT ?2",
2666 )?;
2667 let rows = stmt.query_map(params![pat, limit as i64], |r| {
2668 Ok((
2669 r.get::<_, String>(0)?,
2670 r.get::<_, Option<String>>(1)?.unwrap_or_default(),
2671 r.get::<_, String>(2)?,
2672 r.get::<_, String>(3)?,
2673 r.get::<_, i64>(4)? as u32,
2674 ))
2675 })?;
2676 let mut out = Vec::new();
2677 for r in rows {
2678 out.push(r?);
2679 }
2680 Ok(out)
2681 }
2682
2683 pub fn stats(&self) -> Result<HashMap<String, u64>> {
2689 let mut m = HashMap::new();
2690 for (name, sql) in [
2691 ("files", "SELECT COUNT(*) FROM files"),
2692 ("symbols", "SELECT COUNT(*) FROM symbols"),
2693 ("entities", "SELECT COUNT(*) FROM entities"),
2694 ("relationships", "SELECT COUNT(*) FROM relationships"),
2695 ("evidence", "SELECT COUNT(*) FROM evidence"),
2696 ("components", "SELECT COUNT(*) FROM components"),
2697 ("flows", "SELECT COUNT(*) FROM flows"),
2698 ("invariants", "SELECT COUNT(*) FROM invariants"),
2699 ("tests", "SELECT COUNT(*) FROM tests"),
2700 ] {
2701 let n: i64 = self.conn.query_row(sql, [], |r| r.get(0))?;
2702 m.insert(name.to_string(), n as u64);
2703 }
2704 Ok(m)
2705 }
2706}
2707
2708fn apply_migrations(conn: &Connection) -> Result<()> {
2714 let version: i64 = conn.query_row("PRAGMA user_version", [], |r| r.get(0))?;
2715 let current = SCHEMA_VERSION as i64;
2716 if version > current {
2717 return Err(StoreError::Sqlite(rusqlite::Error::InvalidParameterName(
2718 format!(
2719 "database schema v{version} is newer than supported v{SCHEMA_VERSION}"
2720 )
2721 .into_boxed_str()
2722 .to_string(),
2723 )));
2724 }
2725 for (i, m) in MIGRATIONS.iter().enumerate() {
2727 let target = (i + 1) as i64;
2728 if version < target {
2729 conn.execute_batch(m)?;
2730 }
2731 }
2732 if version < current {
2733 conn.pragma_update(None, "user_version", current)?;
2734 }
2735 Ok(())
2736}
2737
2738pub fn parse_provenance(s: &str) -> Provenance {
2740 match s {
2741 "EXTRACTED" => Provenance::Extracted,
2742 "RESOLVED" => Provenance::Resolved,
2743 "OBSERVED" => Provenance::Observed,
2744 "DECLARED" => Provenance::Declared,
2745 "INFERRED" => Provenance::Inferred,
2746 "STALE" => Provenance::Stale,
2747 _ => Provenance::Inferred,
2748 }
2749}
2750
2751pub fn evidence_type_str(t: &scc_core::EvidenceType) -> &'static str {
2753 match t {
2754 scc_core::EvidenceType::Source => "source",
2755 scc_core::EvidenceType::Config => "config",
2756 scc_core::EvidenceType::Runtime => "runtime",
2757 scc_core::EvidenceType::Test => "test",
2758 scc_core::EvidenceType::Intent => "intent",
2759 scc_core::EvidenceType::History => "history",
2760 }
2761}
2762
2763pub fn parse_evidence_type(s: &str) -> scc_core::EvidenceType {
2765 match s {
2766 "source" => scc_core::EvidenceType::Source,
2767 "config" => scc_core::EvidenceType::Config,
2768 "runtime" => scc_core::EvidenceType::Runtime,
2769 "test" => scc_core::EvidenceType::Test,
2770 "intent" => scc_core::EvidenceType::Intent,
2771 "history" => scc_core::EvidenceType::History,
2772 _ => scc_core::EvidenceType::Source,
2773 }
2774}
2775
2776pub fn flow_kind_str(k: &scc_core::FlowKind) -> &'static str {
2778 match k {
2779 scc_core::FlowKind::Architecture => "architecture",
2780 scc_core::FlowKind::Workflow => "workflow",
2781 scc_core::FlowKind::Sequence => "sequence",
2782 scc_core::FlowKind::Dataflow => "dataflow",
2783 scc_core::FlowKind::Lifecycle => "lifecycle",
2784 }
2785}
2786
2787pub fn parse_flow_kind(s: &str) -> scc_core::FlowKind {
2789 match s {
2790 "architecture" => scc_core::FlowKind::Architecture,
2791 "workflow" => scc_core::FlowKind::Workflow,
2792 "sequence" => scc_core::FlowKind::Sequence,
2793 "dataflow" => scc_core::FlowKind::Dataflow,
2794 "lifecycle" => scc_core::FlowKind::Lifecycle,
2795 _ => scc_core::FlowKind::Sequence,
2796 }
2797}
2798
2799pub fn severity_str(s: &Severity) -> &'static str {
2801 match s {
2802 Severity::Info => "info",
2803 Severity::Low => "low",
2804 Severity::Medium => "medium",
2805 Severity::High => "high",
2806 Severity::Critical => "critical",
2807 }
2808}
2809
2810pub fn parse_severity(s: &str) -> Severity {
2812 match s {
2813 "info" => Severity::Info,
2814 "low" => Severity::Low,
2815 "medium" => Severity::Medium,
2816 "high" => Severity::High,
2817 "critical" => Severity::Critical,
2818 _ => Severity::Medium,
2819 }
2820}
2821
2822fn fts_query(text: &str) -> String {
2826 let tokens: Vec<String> = text
2827 .split(|c: char| !c.is_alphanumeric() && c != '_' && c != '-' && c != '.')
2828 .filter(|t| !t.is_empty())
2829 .map(|t| {
2830 let t = t.trim_matches('"');
2831 format!("\"{t}\"*")
2832 })
2833 .collect();
2834 if tokens.is_empty() {
2835 return "\"\"".to_string();
2836 }
2837 tokens.join(" ")
2838}
2839
2840#[cfg(test)]
2841mod tests {
2843 use super::*;
2844 use tempfile::TempDir;
2845
2846 pub(crate) fn tmp_store() -> (Store, TempDir) {
2848 let dir = TempDir::new().unwrap();
2849 let root = dir.path().join("repo");
2850 std::fs::create_dir_all(&root).unwrap();
2851 let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
2852 (store, dir)
2853 }
2854
2855 #[test]
2856 fn recovering_open_quarantines_malformed_db() {
2858 let dir = TempDir::new().unwrap();
2859 let root = dir.path().join("repo");
2860 std::fs::create_dir_all(&root).unwrap();
2861 let db = dir.path().join("scc.db");
2862 let mut bytes = b"SQLite format 3\0".to_vec();
2865 bytes.resize(4096, 0xFF);
2866 std::fs::write(&db, &bytes).unwrap();
2867 let (store, quarantined) = Store::open_recovering(&db, &root).unwrap();
2868 let q = quarantined.expect("corrupt db must be quarantined");
2869 assert!(q.is_file(), "quarantine evidence preserved");
2870 let v: i64 = store
2872 .conn
2873 .query_row("PRAGMA user_version", [], |r| r.get(0))
2874 .unwrap();
2875 assert!(v >= 0);
2876 let (_, q2) = Store::open_recovering(&db, &root).unwrap();
2878 assert!(q2.is_none(), "healthy db must not quarantine");
2879 }
2880
2881 #[test]
2882 fn persistent_identity_survives_checkout_move() {
2884 let dir = TempDir::new().unwrap();
2885 let root_a = dir.path().join("aaa");
2886 std::fs::create_dir_all(&root_a).unwrap();
2887 let db = dir.path().join("scc.db");
2888 let s1 = Store::open(&db, &root_a).unwrap();
2889 assert_eq!(s1.repo_id, "aaa");
2890 let root_b = dir.path().join("bbb");
2892 std::fs::create_dir_all(&root_b).unwrap();
2893 let s2 = Store::open(&db, &root_b).unwrap();
2894 assert_eq!(s2.repo_id, "aaa", "move must not fork identity");
2895 assert_eq!(
2896 s2.meta_get("repo_id_source").unwrap().as_deref(),
2897 Some("persistent")
2898 );
2899 let row: String = s2.conn.query_row(
2901 "SELECT root FROM repositories WHERE id = 'aaa'",
2902 [],
2903 |r| r.get(0),
2904 ).unwrap();
2905 assert!(row.ends_with("bbb"), "{row}");
2906 }
2907
2908 #[test]
2909 fn explicit_and_remote_identity_adopt_on_fresh_db() {
2911 let (mut s, _d) = tmp_store();
2912 assert_eq!(s.repo_id, "repo");
2913 assert!(s.adopt_stable_identity(Some("MyRepo"), None).unwrap());
2914 assert_eq!(s.repo_id, "myrepo");
2915 assert!(!s.adopt_stable_identity(Some("MyRepo"), None).unwrap());
2916 let (mut t, _e) = tmp_store();
2917 assert!(t.adopt_stable_identity(None, Some("git@github.com:acme/billing.git")).unwrap());
2918 assert_eq!(t.repo_id, "github.com/acme/billing");
2919 assert_eq!(t.meta_get("repo_id_source").unwrap().as_deref(), Some("remote"));
2921 t.insert_entity(&Entity::new("x", "symbol", "f"), &["a.py".into()]).unwrap();
2923 assert!(!t.adopt_stable_identity(Some("other"), None).unwrap());
2924 assert_eq!(t.repo_id, "github.com/acme/billing");
2925 }
2926
2927
2928 #[test]
2929 fn migrations_apply_and_reopen() {
2931 let dir = TempDir::new().unwrap();
2932 let root = dir.path().join("repo");
2933 std::fs::create_dir_all(&root).unwrap();
2934 let db = dir.path().join("scc.db");
2935 {
2936 let s = Store::open(&db, &root).unwrap();
2937 s.insert_entity(&Entity::new("repo://t/component/a", "component", "A"), &["x.py".into()]).unwrap();
2938 }
2939 let s = Store::open(&db, &root).unwrap();
2941 assert!(s.get_entity("repo://t/component/a").unwrap().is_some());
2942 }
2943
2944 #[test]
2945 fn truncated_or_garbage_db_refuses_to_open() {
2947 let dir = TempDir::new().unwrap();
2948 let root = dir.path().join("repo");
2949 std::fs::create_dir_all(&root).unwrap();
2950 let db = dir.path().join("scc.db");
2951
2952 std::fs::write(&db, b"not a sqlite database at all").unwrap();
2953 let err = match Store::open(&db, &root) {
2954 Err(e) => e,
2955 Ok(_) => panic!("garbage db must not open"),
2956 };
2957 assert!(
2958 matches!(err, StoreError::Corrupt(_)),
2959 "garbage must be Corrupt, got {err}"
2960 );
2961 assert!(
2962 err.to_string().contains("corrupt") || err.to_string().contains("not a SQLite"),
2963 "{err}"
2964 );
2965
2966 {
2967 let s = Store::open(&dir.path().join("fresh.db"), &root).unwrap();
2968 s.meta_set("k", "v").unwrap();
2969 }
2970 let good = std::fs::read(dir.path().join("fresh.db")).unwrap();
2971 assert!(good.len() > 32, "expected a real sqlite file");
2972 std::fs::write(&db, &good[..32]).unwrap();
2973 let err = match Store::open(&db, &root) {
2974 Err(e) => e,
2975 Ok(_) => panic!("truncated sqlite must not open"),
2976 };
2977 assert!(
2978 matches!(err, StoreError::Corrupt(_)),
2979 "truncated sqlite must be Corrupt, got {err}"
2980 );
2981
2982 let empty = dir.path().join("empty.db");
2983 std::fs::write(&empty, b"").unwrap();
2984 Store::open(&empty, &root).expect("empty file is a fresh index");
2985 }
2986
2987 #[test]
2988 fn model_epoch_bumps_and_composites() {
2990 let (s, _d) = tmp_store();
2991 assert_eq!(s.model_epoch().unwrap(), ModelEpoch::zero());
2992 let e0 = s.cache_epoch().unwrap();
2993
2994 let id = s.begin_snapshot("abc", None).unwrap();
2996 s.finish_snapshot(id, 3).unwrap();
2997 let e1 = s.model_epoch().unwrap();
2998 assert_eq!(e1.source, 1);
2999 assert_ne!(s.cache_epoch().unwrap(), e0);
3000
3001 s.bump_epoch(ModelEpochKind::Semantic).unwrap();
3003 let e2 = s.model_epoch().unwrap();
3004 assert_eq!(e2.semantic, 1);
3005 assert_eq!(e2.source, 1);
3006
3007 let c1 = e1.composite("abc");
3010 let c2 = e2.composite("abc");
3011 assert_ne!(c1, c2);
3012 assert_ne!(c1, e1.composite("def"));
3013 assert_eq!(c1, e1.composite("abc"));
3015 }
3016
3017 #[test]
3018 fn cache_is_keyed_on_epoch() {
3020 let (s, _d) = tmp_store();
3021 let e0 = s.cache_epoch().unwrap();
3022 s.cache_put("task:1", "pack-A", &e0).unwrap();
3023 assert_eq!(s.cache_get("task:1", &e0).unwrap().as_deref(), Some("pack-A"));
3024
3025 s.bump_epoch(ModelEpochKind::Runtime).unwrap();
3027 let e1 = s.cache_epoch().unwrap();
3028 assert_ne!(e0, e1);
3029 assert_eq!(s.cache_get("task:1", &e1).unwrap(), None);
3030 assert_eq!(s.cache_get("task:1", &e0).unwrap().as_deref(), Some("pack-A"));
3032 }
3033
3034 #[test]
3035 fn intent_replacement_bumps_intent_epoch() {
3037 let (s, _d) = tmp_store();
3038 let before = s.model_epoch().unwrap().intent;
3039 s.replace_intent_claims(&[("component".into(), serde_json::json!({"name": "a"}))])
3040 .unwrap();
3041 let after = s.model_epoch().unwrap();
3042 assert_eq!(after.intent, before + 1);
3043 }
3044
3045 #[test]
3046 fn trace_signature_upsert_roundtrip_and_epoch_neutral() {
3048 let dir = TempDir::new().unwrap();
3049 let root = dir.path().join("repo");
3050 std::fs::create_dir_all(&root).unwrap();
3051 let db = dir.path().join("scc.db");
3052 {
3053 let s = Store::open(&db, &root).unwrap();
3054 assert!(s.trace_signatures().unwrap().is_empty());
3055
3056 let epoch_before = s.model_epoch().unwrap();
3059 s.upsert_trace_signature("root -> api -> db", 4.5, 1).unwrap();
3060 s.upsert_trace_signature("root -> api -> db", 5.5, 0).unwrap();
3061 s.upsert_trace_signature("root -> web -> api", 2.0, 0).unwrap();
3062 assert_eq!(s.model_epoch().unwrap(), epoch_before);
3063
3064 let sigs = s.trace_signatures().unwrap();
3065 assert_eq!(sigs.len(), 2);
3066 assert_eq!(sigs[0].0, "root -> api -> db");
3069 assert_eq!(sigs[0].1, 2);
3070 assert!((sigs[0].2 - 5.0).abs() < 1e-9);
3071 assert_eq!(sigs[0].3, 1);
3072 assert!(!sigs[0].4.is_empty());
3073 assert_eq!(sigs[1].0, "root -> web -> api");
3075 assert_eq!(sigs[1].1, 1);
3076 }
3077 let s = Store::open(&db, &root).unwrap();
3079 let sigs = s.trace_signatures().unwrap();
3080 assert_eq!(sigs.len(), 2);
3081 assert_eq!(sigs[0].0, "root -> api -> db");
3082 assert_eq!(sigs[0].1, 2);
3083 }
3084
3085 #[test]
3086 fn entity_roundtrip_and_fts() {
3088 let (s, _d) = tmp_store();
3089 let mut e = Entity::new("repo://r/component/transcript", "component", "transcript-normalizer");
3090 e.attr("responsibility", serde_json::json!(["normalize transcripts"]));
3091 s.insert_entity(&e, &["src/normalize.py".into()]).unwrap();
3092 let got = s.get_entity("repo://r/component/transcript").unwrap().unwrap();
3093 assert_eq!(got.name, "transcript-normalizer");
3094 let hits = s.search_entities("normalize", 10).unwrap();
3095 assert_eq!(hits.len(), 1);
3096 assert_eq!(hits[0].id, "repo://r/component/transcript");
3097 }
3098
3099 #[test]
3100 fn relationship_roundtrip() {
3102 let (s, _d) = tmp_store();
3103 let rel = Relationship::new(
3104 "rel:1",
3105 "repo://r/component/a",
3106 "calls",
3107 "repo://r/component/b",
3108 Provenance::Resolved,
3109 )
3110 .with_evidence(vec!["evidence:1".into()]);
3111 s.insert_relationship(&rel, "src/a.py").unwrap();
3112 let got = s.relationships_for("repo://r/component/a").unwrap();
3113 assert_eq!(got.len(), 1);
3114 assert_eq!(got[0].predicate, "calls");
3115 assert_eq!(got[0].provenance, Provenance::Resolved);
3116 }
3117
3118 #[test]
3119 fn sweep_orphan_evidence_removes_only_unreferenced() {
3121 let (s, _d) = tmp_store();
3122 s.insert_evidence(&Evidence::source("evidence:ent", "src/a.py")).unwrap();
3124 s.insert_evidence(&Evidence::source("evidence:rel", "src/b.py")).unwrap();
3125 s.insert_evidence(&Evidence::source("evidence:orphan", "src/c.py")).unwrap();
3126
3127 let mut e = Entity::new("repo://r/component/keep", "component", "keep");
3128 e.evidence = vec!["evidence:ent".into()];
3129 s.insert_entity(&e, &["src/a.py".into()]).unwrap();
3130 let rel = Relationship::new(
3131 "rel:1",
3132 "repo://r/component/keep",
3133 "calls",
3134 "repo://r/component/other",
3135 Provenance::Resolved,
3136 )
3137 .with_evidence(vec!["evidence:rel".into()]);
3138 s.insert_relationship(&rel, "src/b.py").unwrap();
3139
3140 assert_eq!(s.sweep_orphan_evidence().unwrap(), 1);
3142 assert!(s.get_evidence("evidence:ent").unwrap().is_some());
3143 assert!(s.get_evidence("evidence:rel").unwrap().is_some());
3144 assert!(s.get_evidence("evidence:orphan").unwrap().is_none());
3145
3146 let e2 = Entity::new("repo://r/component/keep", "component", "keep");
3148 s.insert_entity(&e2, &["src/a.py".into()]).unwrap();
3149 assert_eq!(s.sweep_orphan_evidence().unwrap(), 1);
3150 assert!(s.get_evidence("evidence:ent").unwrap().is_none());
3151 assert!(s.get_evidence("evidence:rel").unwrap().is_some());
3152 }
3153
3154 #[test]
3155 fn sweep_orphan_evidence_keeps_flow_and_component_references() {
3157 let (s, _d) = tmp_store();
3158 s.insert_evidence(&Evidence::source("evidence:flow", "src/a.py")).unwrap();
3161 s.insert_evidence(&Evidence::source("evidence:comp", "src/b.py")).unwrap();
3162 s.insert_evidence(&Evidence::source("evidence:gone", "src/c.py")).unwrap();
3163
3164 let mut comp = Entity::new("repo://r/component/keep", "component", "keep");
3166 comp.evidence = vec!["evidence:comp".into()];
3167 s.replace_components(&[comp]).unwrap();
3168
3169 let flow = scc_core::Flow {
3171 id: "flow:1".into(),
3172 kind: scc_core::FlowKind::Workflow,
3173 name: "wf".into(),
3174 trigger: None,
3175 steps: vec![scc_core::FlowStep {
3176 id: "step:1".into(),
3177 order: 0,
3178 actor: "repo://r/component/keep".into(),
3179 operation: "run".into(),
3180 condition: None,
3181 r#async: None,
3182 timeout_ms: None,
3183 retry_policy: None,
3184 failure_outcome: None,
3185 provenance: None,
3186 evidence: vec!["evidence:flow".into()],
3187 }],
3188 attributes: Default::default(),
3189 };
3190 s.replace_flows(&[flow]).unwrap();
3191
3192 assert_eq!(s.sweep_orphan_evidence().unwrap(), 1);
3193 assert!(s.get_evidence("evidence:flow").unwrap().is_some());
3194 assert!(s.get_evidence("evidence:comp").unwrap().is_some());
3195 assert!(s.get_evidence("evidence:gone").unwrap().is_none());
3196 }
3197
3198 #[test]
3199 fn purge_path_cascades() {
3201 let (s, _d) = tmp_store();
3202 s.insert_symbol("a.py", "foo", "function", None, 1, 5, true, None).unwrap();
3203 s.insert_evidence(&Evidence::source("evidence:1", "a.py")).unwrap();
3204 let rel = Relationship::new("rel:1", "repo://r/symbol/a.py/foo", "calls", "repo://r/symbol/b.py/bar", Provenance::Extracted)
3205 .with_evidence(vec!["evidence:1".into()]);
3206 s.insert_relationship(&rel, "a.py").unwrap();
3207 s.purge_path("a.py").unwrap();
3208 assert_eq!(s.symbols_in_file("a.py").unwrap().len(), 0);
3209 assert_eq!(s.all_relationships().unwrap().len(), 0);
3210 assert!(s.get_evidence("evidence:1").unwrap().is_none());
3211 }
3212
3213 #[test]
3214 fn paths_depending_on_finds_importers_and_callers() {
3216 let (s, _d) = tmp_store();
3217 let repo = s.repo_id.clone();
3218 let file_b = scc_core::entity_id(&repo, scc_core::kinds::FILE, "b.py");
3219 let file_a = scc_core::entity_id(&repo, scc_core::kinds::FILE, "a.py");
3220 s.insert_entity(
3221 &Entity::new(file_b.clone(), scc_core::kinds::FILE, "b.py"),
3222 &["b.py".into()],
3223 )
3224 .unwrap();
3225 s.insert_entity(
3226 &Entity::new(file_a.clone(), scc_core::kinds::FILE, "a.py"),
3227 &["a.py".into()],
3228 )
3229 .unwrap();
3230 let mut meth = Entity::new(
3231 scc_core::symbol_id(&repo, "b.py", "Order.process"),
3232 scc_core::kinds::SYMBOL,
3233 "Order.process",
3234 );
3235 meth.attr("file", serde_json::json!("b.py"));
3236 s.insert_entity(&meth, &["b.py".into()]).unwrap();
3237 s.insert_relationship(
3238 &Relationship::new(
3239 "rel:imp",
3240 file_a.clone(),
3241 scc_core::predicates::IMPORTS,
3242 file_b,
3243 Provenance::Extracted,
3244 ),
3245 "a.py",
3246 )
3247 .unwrap();
3248 s.insert_relationship(
3249 &Relationship::new(
3250 "rel:call",
3251 scc_core::symbol_id(&repo, "a.py", "handle"),
3252 scc_core::predicates::CALLS,
3253 scc_core::symbol_id(&repo, "b.py", "Order.process"),
3254 Provenance::Extracted,
3255 ),
3256 "a.py",
3257 )
3258 .unwrap();
3259 let deps = s.paths_depending_on("b.py").unwrap();
3260 assert_eq!(deps, vec!["a.py".to_string()]);
3261 assert!(s.paths_depending_on("a.py").unwrap().is_empty());
3262 s.insert_relationship(
3263 &Relationship::new(
3264 "rel:tested",
3265 scc_core::symbol_id(&repo, "c.py", "test_it"),
3266 scc_core::predicates::TESTED_BY,
3267 scc_core::symbol_id(&repo, "b.py", "Order.process"),
3268 Provenance::Extracted,
3269 ),
3270 "c.py",
3271 )
3272 .unwrap();
3273 let deps = s.paths_depending_on("b.py").unwrap();
3274 assert_eq!(
3275 deps,
3276 vec!["a.py".to_string()],
3277 "TESTED_BY must not cascade: {deps:?}"
3278 );
3279 }
3280
3281 #[test]
3282 fn replace_components_drops_vanished_component_entities() {
3284 let (s, _d) = tmp_store();
3285 let merged = Entity::new(
3286 "repo://r/component/root-services",
3287 scc_core::kinds::COMPONENT,
3288 "root+services",
3289 );
3290 let root = Entity::new(
3291 "repo://r/component/root",
3292 scc_core::kinds::COMPONENT,
3293 "root",
3294 );
3295 s.replace_components(&[merged, root.clone()]).unwrap();
3296 assert_eq!(s.entities_by_kind(scc_core::kinds::COMPONENT).unwrap().len(), 2);
3297 s.replace_components(&[root]).unwrap();
3298 let left = s.entities_by_kind(scc_core::kinds::COMPONENT).unwrap();
3299 assert_eq!(left.len(), 1, "{left:?}");
3300 assert_eq!(left[0].name, "root");
3301 assert!(s.get_entity("repo://r/component/root-services").unwrap().is_none());
3302 }
3303
3304#[test]
3311fn purge_path_recomputes_shared_concept_provenance() {
3313 let (s, _d) = tmp_store();
3314 let repo = &s.repo_id;
3315 let expr = "z.object({ name: z.string() })";
3316 let concept = scc_core::entity_id(repo, scc_core::kinds::SCHEMA, expr);
3317 s.insert_entity(
3319 &Entity::new(concept.clone(), scc_core::kinds::SCHEMA, expr),
3320 &["a.ts".into(), "b.ts".into()],
3321 )
3322 .unwrap();
3323 for (path, owner) in [("a.ts", "makeA"), ("b.ts", "makeB")] {
3324 let occ = scc_core::occurrence_id(repo, expr, path, owner, 3);
3325 s.insert_entity(
3326 Entity::new(occ.clone(), scc_core::kinds::OCCURRENCE, format!("{expr}@{path}@{owner}@3"))
3327 .attr("concept", serde_json::json!(concept))
3328 .attr("path", serde_json::json!(path))
3329 .attr("owner", serde_json::json!(owner))
3330 .attr("line", serde_json::json!(3)),
3331 &[path.to_string()],
3332 )
3333 .unwrap();
3334 s.insert_relationship(
3335 &Relationship::new(
3336 format!("rel:occ:{path}"),
3337 occ.clone(),
3338 scc_core::predicates::OCCURS,
3339 concept.clone(),
3340 Provenance::Extracted,
3341 ),
3342 path,
3343 )
3344 .unwrap();
3345 }
3346 s.purge_path("a.ts").unwrap();
3348 let concept_ent = s
3349 .get_entity(&concept)
3350 .unwrap()
3351 .expect("concept survives a single-file purge");
3352 assert_eq!(concept_ent.kind, scc_core::kinds::SCHEMA);
3353 assert_eq!(s.entity_sources(&concept).unwrap(), vec!["b.ts"]);
3354 let occs = s.concept_occurrences(&concept).unwrap();
3355 assert_eq!(occs.len(), 1, "derived count drops to 1: {occs:?}");
3356 assert_eq!(
3357 occs[0].attributes.get("path").and_then(|v| v.as_str()),
3358 Some("b.ts")
3359 );
3360 s.purge_path("b.ts").unwrap();
3362 assert!(s.get_entity(&concept).unwrap().is_none(), "concept gone");
3363 assert!(s.all_relationships().unwrap().is_empty(), "no dangling edges");
3364 s.insert_entity(
3366 &Entity::new("repo://r/store/db", "store", "db"),
3367 &["a.ts".into()],
3368 )
3369 .unwrap();
3370 s.purge_path("a.ts").unwrap();
3371 assert!(s.get_entity("repo://r/store/db").unwrap().is_none());
3372 }
3373
3374 #[test]
3375 fn cache_revision_scoped() {
3377 let (s, _d) = tmp_store();
3378 s.cache_put("k", "pack-v1", "rev1").unwrap();
3379 assert_eq!(s.cache_get("k", "rev1").unwrap(), Some("pack-v1".into()));
3380 assert_eq!(s.cache_get("k", "rev2").unwrap(), None);
3381 }
3382
3383 #[test]
3384 fn fts_escapes_punctuation() {
3386 let (s, _d) = tmp_store();
3387 let mut e = Entity::new("repo://r/route/api", "route", "GET /api/v1/items");
3388 e.attr("path", serde_json::json!("/api/v1/items"));
3389 s.insert_entity(&e, &["app.py".into()]).unwrap();
3390 let hits = s.search_entities("GET /api/v1/items", 10).unwrap();
3391 assert_eq!(hits.len(), 1);
3392 }
3393}
3394
3395#[cfg(test)]
3396mod like_tests {
3398 use super::*;
3399 use crate::tests::tmp_store;
3400
3401 #[test]
3402 fn entities_like_matches_attributes() {
3404 let (s, _d) = tmp_store();
3405 let mut e = Entity::new("repo://r/symbol/a.py/f", "symbol", "transcribe");
3406 e.attr("docstring", serde_json::json!("External ASR client with retry and fallback."));
3407 s.insert_entity(&e, &["a.py".into()]).unwrap();
3408 let hits = s.search_entities_like("retry", 6).unwrap();
3409 assert_eq!(hits.len(), 1, "must match docstring attributes");
3410 let hits2 = s.search_symbols_like("retry", 6).unwrap();
3411 assert_eq!(hits2.len(), 0);
3412 }
3413
3414 #[test]
3415 fn symbols_like_matches_docstring() {
3417 let (s, _d) = tmp_store();
3418 s.insert_symbol("a.py", "transcribe", "function", None, 1, 2, true,
3419 Some("External ASR client with retry and fallback.")).unwrap();
3420 let hits = s.search_symbols_like("retry", 6).unwrap();
3421 assert_eq!(hits.len(), 1, "must match symbol docstrings");
3422 assert_eq!(hits[0].0, "transcribe");
3423 }
3424}