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 = 10;
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 MIGRATION_10,
45];
46
47const MIGRATION_4: &str = r#"
53ALTER TABLE context_cache RENAME COLUMN revision TO epoch;
54"#;
55
56const MIGRATION_5: &str = r#"
60CREATE TABLE IF NOT EXISTS flow_graphs (
61 id TEXT PRIMARY KEY,
62 kind TEXT NOT NULL,
63 name TEXT NOT NULL,
64 trigger TEXT,
65 graph TEXT NOT NULL
66);
67"#;
68
69const MIGRATION_7: &str = r#"
75CREATE TABLE IF NOT EXISTS graph_revisions (
76 rev INTEGER PRIMARY KEY,
77 base_rev INTEGER NOT NULL DEFAULT 0,
78 created_at TEXT NOT NULL,
79 source_hash TEXT NOT NULL DEFAULT '',
80 extractor_version TEXT NOT NULL DEFAULT '',
81 entity_count INTEGER NOT NULL DEFAULT 0,
82 rel_count INTEGER NOT NULL DEFAULT 0,
83 file_count INTEGER NOT NULL DEFAULT 0
84);
85CREATE TABLE IF NOT EXISTS revision_members (
86 rev INTEGER NOT NULL,
87 kind TEXT NOT NULL,
88 id TEXT NOT NULL,
89 row_json TEXT NOT NULL DEFAULT '{}',
90 PRIMARY KEY (rev, kind, id)
91);
92CREATE INDEX IF NOT EXISTS idx_revision_members_rev ON revision_members(rev);
93CREATE TABLE IF NOT EXISTS context_snapshots (
94 id TEXT PRIMARY KEY,
95 created_at TEXT NOT NULL,
96 task TEXT NOT NULL DEFAULT '',
97 epoch TEXT NOT NULL DEFAULT '',
98 revision INTEGER NOT NULL DEFAULT 0,
99 artifact TEXT NOT NULL DEFAULT '',
100 entity_ids TEXT NOT NULL DEFAULT '[]',
101 budget INTEGER NOT NULL DEFAULT 0,
102 warnings TEXT NOT NULL DEFAULT '[]'
103);
104"#;
105
106const MIGRATION_8: &str = r#"
115ALTER TABLE graph_revisions ADD COLUMN semantic_config_hash TEXT NOT NULL DEFAULT '';
116ALTER TABLE graph_revisions ADD COLUMN graph_content_hash TEXT NOT NULL DEFAULT '';
117ALTER TABLE context_snapshots ADD COLUMN entity_fp TEXT NOT NULL DEFAULT '{}';
118ALTER TABLE context_snapshots ADD COLUMN rel_fp TEXT NOT NULL DEFAULT '{}';
119ALTER TABLE context_snapshots ADD COLUMN contract_fp TEXT NOT NULL DEFAULT '{}';
120ALTER TABLE context_snapshots ADD COLUMN state_fp TEXT NOT NULL DEFAULT '{}';
121ALTER TABLE context_snapshots ADD COLUMN flow_names TEXT NOT NULL DEFAULT '[]';
122ALTER TABLE context_snapshots ADD COLUMN artifact_hash TEXT NOT NULL DEFAULT '';
123"#;
124
125const MIGRATION_9: &str = r#"
130CREATE TABLE IF NOT EXISTS plugin_state (
131 plugin_id TEXT NOT NULL,
132 key TEXT NOT NULL,
133 value TEXT NOT NULL DEFAULT '{}',
134 updated_at TEXT NOT NULL DEFAULT '',
135 PRIMARY KEY (plugin_id, key)
136);
137CREATE INDEX IF NOT EXISTS idx_plugin_state_plugin ON plugin_state(plugin_id);
138"#;
139
140const MIGRATION_10: &str = r#"
147CREATE TABLE IF NOT EXISTS sidecar_facts (
148 plugin_id TEXT NOT NULL,
149 graph TEXT NOT NULL DEFAULT 'default',
150 key TEXT NOT NULL,
151 value TEXT NOT NULL DEFAULT '{}',
152 updated_at TEXT NOT NULL DEFAULT '',
153 PRIMARY KEY (plugin_id, graph, key)
154);
155CREATE INDEX IF NOT EXISTS idx_sidecar_plugin_graph ON sidecar_facts(plugin_id, graph);
156"#;
157
158const MIGRATION_6: &str = r#"
164CREATE TABLE IF NOT EXISTS trace_signatures (
165 signature TEXT PRIMARY KEY,
166 count INTEGER NOT NULL DEFAULT 1,
167 latency_ms REAL NOT NULL DEFAULT 0,
168 errors INTEGER NOT NULL DEFAULT 0,
169 last_observed TEXT NOT NULL
170);
171"#;
172
173const MIGRATION_3: &str = r#"
176CREATE TABLE IF NOT EXISTS embeddings (
177 entity_id TEXT PRIMARY KEY,
178 vector BLOB NOT NULL,
179 model TEXT NOT NULL,
180 updated_at TEXT NOT NULL
181);
182"#;
183
184const MIGRATION_2: &str = r#"
187ALTER TABLE runtime_edges ADD COLUMN latency_ms REAL NOT NULL DEFAULT 0;
188ALTER TABLE runtime_edges ADD COLUMN errors INTEGER NOT NULL DEFAULT 0;
189"#;
190
191const MIGRATION_1: &str = r#"
193CREATE TABLE IF NOT EXISTS meta (
194 key TEXT PRIMARY KEY,
195 value TEXT NOT NULL
196);
197
198CREATE TABLE IF NOT EXISTS repositories (
199 id TEXT PRIMARY KEY,
200 name TEXT NOT NULL,
201 url TEXT,
202 root TEXT NOT NULL,
203 indexed_at TEXT
204);
205
206CREATE TABLE IF NOT EXISTS snapshots (
207 id INTEGER PRIMARY KEY AUTOINCREMENT,
208 revision TEXT NOT NULL,
209 branch TEXT,
210 indexed_at TEXT NOT NULL,
211 status TEXT NOT NULL DEFAULT 'active',
212 file_count INTEGER NOT NULL DEFAULT 0
213);
214
215CREATE TABLE IF NOT EXISTS files (
216 path TEXT PRIMARY KEY,
217 hash TEXT NOT NULL,
218 language TEXT NOT NULL DEFAULT 'unknown',
219 kind TEXT NOT NULL DEFAULT 'other',
220 size INTEGER NOT NULL DEFAULT 0,
221 indexed_at TEXT
222);
223
224CREATE TABLE IF NOT EXISTS symbols (
225 id INTEGER PRIMARY KEY AUTOINCREMENT,
226 file TEXT NOT NULL,
227 name TEXT NOT NULL,
228 symbol_kind TEXT NOT NULL,
229 signature TEXT,
230 start_line INTEGER NOT NULL,
231 end_line INTEGER NOT NULL,
232 exported INTEGER NOT NULL DEFAULT 0,
233 docstring TEXT
234);
235CREATE INDEX IF NOT EXISTS idx_symbols_file ON symbols(file);
236CREATE INDEX IF NOT EXISTS idx_symbols_name ON symbols(name);
237
238CREATE TABLE IF NOT EXISTS imports (
239 id INTEGER PRIMARY KEY AUTOINCREMENT,
240 file TEXT NOT NULL,
241 module TEXT NOT NULL,
242 names TEXT NOT NULL DEFAULT '[]',
243 line INTEGER NOT NULL DEFAULT 0,
244 type TEXT NOT NULL DEFAULT 'member'
245);
246CREATE INDEX IF NOT EXISTS idx_imports_file ON imports(file);
247
248CREATE TABLE IF NOT EXISTS entities (
249 id TEXT PRIMARY KEY,
250 kind TEXT NOT NULL,
251 name TEXT NOT NULL,
252 attributes TEXT NOT NULL DEFAULT '{}',
253 evidence TEXT NOT NULL DEFAULT '[]',
254 sources TEXT NOT NULL DEFAULT '[]'
255);
256CREATE INDEX IF NOT EXISTS idx_entities_kind ON entities(kind);
257
258CREATE TABLE IF NOT EXISTS relationships (
259 id TEXT PRIMARY KEY,
260 subject TEXT NOT NULL,
261 predicate TEXT NOT NULL,
262 object TEXT NOT NULL,
263 provenance TEXT NOT NULL,
264 confidence REAL NOT NULL,
265 evidence TEXT NOT NULL DEFAULT '[]',
266 verified_at TEXT NOT NULL DEFAULT '',
267 source_path TEXT NOT NULL DEFAULT ''
268);
269CREATE INDEX IF NOT EXISTS idx_rel_subject ON relationships(subject);
270CREATE INDEX IF NOT EXISTS idx_rel_object ON relationships(object);
271CREATE INDEX IF NOT EXISTS idx_rel_predicate ON relationships(predicate);
272CREATE INDEX IF NOT EXISTS idx_rel_source ON relationships(source_path);
273
274CREATE TABLE IF NOT EXISTS evidence (
275 id TEXT PRIMARY KEY,
276 type TEXT NOT NULL,
277 path TEXT,
278 symbol TEXT,
279 start_line INTEGER,
280 end_line INTEGER,
281 revision TEXT,
282 content_hash TEXT,
283 extractor TEXT,
284 extractor_version TEXT
285);
286CREATE INDEX IF NOT EXISTS idx_evidence_path ON evidence(path);
287
288CREATE TABLE IF NOT EXISTS components (
289 id TEXT PRIMARY KEY,
290 name TEXT NOT NULL,
291 kind TEXT NOT NULL,
292 responsibility TEXT NOT NULL DEFAULT '[]',
293 implementation TEXT NOT NULL DEFAULT '[]',
294 evidence TEXT NOT NULL DEFAULT '[]',
295 attributes TEXT NOT NULL DEFAULT '{}'
296);
297
298CREATE TABLE IF NOT EXISTS flows (
299 id TEXT PRIMARY KEY,
300 kind TEXT NOT NULL,
301 name TEXT NOT NULL,
302 trigger TEXT,
303 steps TEXT NOT NULL DEFAULT '[]',
304 attributes TEXT NOT NULL DEFAULT '{}'
305);
306
307CREATE TABLE IF NOT EXISTS invariants (
308 id TEXT PRIMARY KEY,
309 statement TEXT NOT NULL,
310 severity TEXT NOT NULL,
311 scope TEXT NOT NULL DEFAULT '[]',
312 enforced_by TEXT NOT NULL DEFAULT '[]',
313 provenance TEXT NOT NULL DEFAULT 'DECLARED',
314 evidence TEXT NOT NULL DEFAULT '[]'
315);
316
317CREATE TABLE IF NOT EXISTS tests (
318 id TEXT PRIMARY KEY,
319 name TEXT NOT NULL,
320 file TEXT NOT NULL,
321 kind TEXT NOT NULL DEFAULT 'unit',
322 symbol TEXT
323);
324CREATE INDEX IF NOT EXISTS idx_tests_file ON tests(file);
325
326CREATE TABLE IF NOT EXISTS context_cache (
327 key TEXT PRIMARY KEY,
328 pack TEXT NOT NULL,
329 revision TEXT NOT NULL,
330 created_at TEXT NOT NULL
331);
332
333CREATE TABLE IF NOT EXISTS intent_claims (
334 id INTEGER PRIMARY KEY AUTOINCREMENT,
335 source TEXT NOT NULL,
336 claim TEXT NOT NULL,
337 created_at TEXT NOT NULL
338);
339
340CREATE TABLE IF NOT EXISTS runtime_edges (
341 source TEXT NOT NULL,
342 target TEXT NOT NULL,
343 count INTEGER NOT NULL DEFAULT 1,
344 last_observed TEXT NOT NULL,
345 PRIMARY KEY (source, target)
346);
347
348CREATE TABLE IF NOT EXISTS drift_findings (
349 id INTEGER PRIMARY KEY AUTOINCREMENT,
350 kind TEXT NOT NULL,
351 severity TEXT NOT NULL,
352 message TEXT NOT NULL,
353 created_at TEXT NOT NULL,
354 resolved INTEGER NOT NULL DEFAULT 0
355);
356
357CREATE VIRTUAL TABLE IF NOT EXISTS symbols_fts USING fts5(
358 name, signature, symbol_kind UNINDEXED, file UNINDEXED
359);
360
361CREATE VIRTUAL TABLE IF NOT EXISTS entities_fts USING fts5(
362 id UNINDEXED, kind UNINDEXED, name, attributes
363);
364"#;
365
366#[derive(Debug, Error)]
367pub enum StoreError {
369 #[error("sqlite: {0}")]
370 Sqlite(#[from] rusqlite::Error),
371 #[error("json: {0}")]
372 Json(#[from] serde_json::Error),
373 #[error("repository not initialized: {0}")]
374 NotInitialized(String),
375 #[error("index cache is corrupt (refusing to fabricate an empty index): {0}")]
376 Corrupt(String),
377}
378
379pub type Result<T> = std::result::Result<T, StoreError>;
381
382#[derive(Debug, Clone, serde::Serialize)]
383pub struct RuntimeEdgeRow {
385 pub source: String,
386 pub target: String,
387 pub count: u64,
388 pub latency_ms: f64,
389 pub errors: u64,
390 pub last_observed: String,
391}
392
393#[derive(Debug, Clone, Copy, PartialEq, Eq)]
398pub enum ModelEpochKind {
400 Source,
402 Semantic,
404 Evidence,
406 Intent,
408 Runtime,
410 Derived,
412}
413
414impl ModelEpochKind {
416 pub fn meta_key(&self) -> &'static str {
418 match self {
419 ModelEpochKind::Source => "source_generation",
420 ModelEpochKind::Semantic => "semantic_generation",
421 ModelEpochKind::Evidence => "evidence_generation",
422 ModelEpochKind::Intent => "intent_generation",
423 ModelEpochKind::Runtime => "runtime_generation",
424 ModelEpochKind::Derived => "derived_generation",
425 }
426 }
427}
428
429#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
436pub struct ModelEpoch {
438 pub source: u64,
439 pub semantic: u64,
440 pub evidence: u64,
441 pub intent: u64,
442 pub runtime: u64,
443 pub derived: u64,
444}
445
446impl ModelEpoch {
448 pub fn zero() -> ModelEpoch {
450 ModelEpoch {
451 source: 0,
452 semantic: 0,
453 evidence: 0,
454 intent: 0,
455 runtime: 0,
456 derived: 0,
457 }
458 }
459
460 pub fn composite(&self, revision: &str) -> String {
464 let mut h = blake3::Hasher::new();
465 h.update(b"scc-model-epoch-v1");
466 h.update(self.source.to_le_bytes().as_slice());
467 h.update(self.semantic.to_le_bytes().as_slice());
468 h.update(self.evidence.to_le_bytes().as_slice());
469 h.update(self.intent.to_le_bytes().as_slice());
470 h.update(self.runtime.to_le_bytes().as_slice());
471 h.update(self.derived.to_le_bytes().as_slice());
472 h.update(revision.as_bytes());
473 format!("epoch:{}", &h.finalize().to_hex()[..24])
474 }
475}
476#[derive(Debug)]
479pub struct Store {
481 pub conn: Connection,
482 pub root: PathBuf,
483 pub repo_id: String,
485 pub repo_name: String,
486 batch_depth: std::sync::atomic::AtomicUsize,
487}
488
489const SQLITE_MAGIC: &[u8] = b"SQLite format 3\0";
490
491fn refuse_corrupt_existing_db(path: &Path) -> Result<()> {
496 let Ok(meta) = std::fs::metadata(path) else {
497 return Ok(());
498 };
499 if !meta.is_file() || meta.len() == 0 {
500 return Ok(());
501 }
502 if meta.len() < 100 {
503 return Err(StoreError::Corrupt("truncated sqlite header".into()));
504 }
505 let mut f = std::fs::File::open(path).map_err(|e| StoreError::Corrupt(e.to_string()))?;
506 let mut magic = [0u8; 16];
507 let n = f.read(&mut magic).map_err(|e| StoreError::Corrupt(e.to_string()))?;
508 if n < SQLITE_MAGIC.len() || magic.as_slice() != SQLITE_MAGIC {
509 return Err(StoreError::Corrupt(
510 "file is not a SQLite database".into(),
511 ));
512 }
513 Ok(())
514}
515
516fn probe_existing_schema(conn: &Connection) -> Result<()> {
521 match conn.query_row(
522 "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'entities' LIMIT 1",
523 [],
524 |_| Ok(()),
525 ) {
526 Ok(()) => Ok(()),
527 Err(rusqlite::Error::QueryReturnedNoRows) => Err(StoreError::Corrupt(
528 "existing database is missing SCC schema".into(),
529 )),
530 Err(e) => Err(StoreError::Corrupt(e.to_string())),
531 }
532}
533
534struct NestGuard<'c> {
536 conn: &'c Connection,
537 committed: bool,
538}
539
540impl NestGuard<'_> {
542 fn commit(mut self) -> Result<()> {
544 self.committed = true;
545 self.conn.execute_batch("RELEASE scc_nest")?;
546 Ok(())
547 }
548}
549
550impl Drop for NestGuard<'_> {
552 fn drop(&mut self) {
554 if !self.committed {
555 let _ = self
556 .conn
557 .execute_batch("ROLLBACK TO scc_nest; RELEASE scc_nest;");
558 }
559 }
560}
561
562impl Store {
564 pub fn open_recovering(path: &Path, root: &Path) -> Result<(Store, Option<PathBuf>)> {
577 match Self::open(path, root) {
578 Ok(store) => Ok((store, None)),
579 Err(e) if Self::is_corruption(&e) => {
580 let quarantine = Self::quarantine_db(path)?;
581 let store = Self::open(path, root)?;
582 Ok((store, Some(quarantine)))
583 }
584 Err(e) => Err(e),
585 }
586 }
587
588 pub fn quarantine_db(path: &Path) -> Result<PathBuf> {
593 let stamp = std::time::SystemTime::now()
594 .duration_since(std::time::UNIX_EPOCH)
595 .map(|d| d.as_secs())
596 .unwrap_or(0);
597 let quarantine = path.with_extension(format!("db.corrupt-{stamp}"));
598 let _ = std::fs::rename(path, &quarantine);
599 for suffix in ["-wal", "-shm", "-journal"] {
600 let sidecar = PathBuf::from(format!("{}{suffix}", path.display()));
601 if sidecar.is_file() {
602 let dest = PathBuf::from(format!("{}{suffix}", quarantine.display()));
603 let _ = std::fs::rename(&sidecar, &dest);
604 }
605 }
606 Ok(quarantine)
607 }
608
609 pub fn is_corruption(e: &StoreError) -> bool {
615 match e {
616 StoreError::Corrupt(_) => true,
617 StoreError::Sqlite(inner) => {
618 let m = inner.to_string();
619 m.contains("malformed")
620 || m.contains("not a database")
621 || m.contains("database disk image")
622 }
623 _ => false,
624 }
625 }
626
627 pub fn batch_begin(&self) -> Result<()> {
634 use std::sync::atomic::Ordering;
635 if self.batch_depth.fetch_add(1, Ordering::SeqCst) == 0 {
636 self.conn.execute_batch("BEGIN IMMEDIATE")?;
637 }
638 Ok(())
639 }
640
641 pub fn batch_end(&self) -> Result<()> {
643 use std::sync::atomic::Ordering;
644 if self.batch_depth.fetch_sub(1, Ordering::SeqCst) == 1 {
645 self.conn.execute_batch("COMMIT")?;
646 }
647 Ok(())
648 }
649
650 pub fn batch_abort(&self) {
652 use std::sync::atomic::Ordering;
653 if self.batch_depth.swap(0, Ordering::SeqCst) > 0 {
654 let _ = self.conn.execute_batch("ROLLBACK");
655 }
656 }
657
658 pub fn batch_write<T, E>(&self, f: impl FnOnce() -> std::result::Result<T, E>) -> std::result::Result<T, E>
662 where
663 E: From<StoreError>,
664 {
665 self.batch_begin()?;
666 match f() {
667 Ok(v) => self.batch_end().map_err(E::from).map(|_| v),
668 Err(e) => {
669 self.batch_abort();
670 Err(e)
671 }
672 }
673 }
674
675 fn nest(&self) -> Result<NestGuard<'_>> {
681 self.conn.execute_batch("SAVEPOINT scc_nest")?;
682 Ok(NestGuard { conn: &self.conn, committed: false })
683 }
684
685 pub fn open(path: &Path, root: &Path) -> Result<Store> {
687 let existed_nonempty = path.is_file()
688 && std::fs::metadata(path).map(|m| m.len() > 0).unwrap_or(false);
689 refuse_corrupt_existing_db(path)?;
690 let conn = match Connection::open(path) {
691 Ok(c) => c,
692 Err(e) if existed_nonempty => {
693 return Err(StoreError::Corrupt(e.to_string()));
694 }
695 Err(e) => return Err(e.into()),
696 };
697 if existed_nonempty {
698 probe_existing_schema(&conn)?;
699 }
700 conn.pragma_update(None, "journal_mode", "WAL")?;
701 conn.pragma_update(None, "busy_timeout", 30000)?;
707 conn.pragma_update(None, "synchronous", "FULL")?;
713 conn.pragma_update(None, "foreign_keys", "ON")?;
714 apply_migrations(&conn)?;
715
716 let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
717 let name = root
718 .file_name()
719 .map(|s| s.to_string_lossy().to_string())
720 .unwrap_or_else(|| "repository".to_string());
721 let explicit = std::env::var("SCC_REPO_ID")
725 .ok()
726 .filter(|s| !s.trim().is_empty());
727 let mut repo_id = scc_core::sanitize_key(&name);
728 let mut id_source = "basename";
729 if let Some(e) = explicit {
730 repo_id = scc_core::sanitize_key(e.trim());
731 id_source = "explicit";
732 } else if existed_nonempty {
733 if let Some(persisted) = Self::existing_repo_id(&conn)? {
734 repo_id = persisted;
735 id_source = "persistent";
736 }
737 }
738
739 let mut store = Store {
740 conn,
741 root,
742 repo_id,
743 repo_name: name,
744 batch_depth: std::sync::atomic::AtomicUsize::new(0),
745 };
746 store.ensure_repository()?;
747 if id_source == "persistent" {
748 store.refresh_repository_root()?;
749 }
750 store.meta_set("repo_id_source", id_source)?;
751 Ok(store)
752 }
753
754 fn ensure_repository(&mut self) -> Result<()> {
756 let existing: Option<String> = self
757 .conn
758 .query_row(
759 "SELECT id FROM repositories WHERE id = ?1",
760 params![self.repo_id],
761 |r| r.get(0),
762 )
763 .optional()?;
764 if existing.is_none() {
765 self.conn.execute(
766 "INSERT INTO repositories (id, name, root, indexed_at) VALUES (?1, ?2, ?3, ?4)",
767 params![
768 self.repo_id,
769 self.repo_name,
770 self.root.to_string_lossy(),
771 scc_core::now_rfc3339()
772 ],
773 )?;
774 }
775 Ok(())
776 }
777 fn existing_repo_id(conn: &Connection) -> Result<Option<String>> {
781 let id: Option<String> = conn
782 .query_row(
783 "SELECT id FROM repositories ORDER BY rowid LIMIT 1",
784 [],
785 |r| r.get(0),
786 )
787 .optional()?;
788 Ok(id)
789 }
790
791 fn refresh_repository_root(&self) -> Result<()> {
796 self.conn.execute(
797 "UPDATE repositories SET root = ?1, name = ?2 WHERE id = ?3",
798 params![
799 self.root.to_string_lossy(),
800 self.repo_name,
801 self.repo_id
802 ],
803 )?;
804 Ok(())
805 }
806
807 pub fn adopt_stable_identity(
814 &mut self,
815 explicit: Option<&str>,
816 remote_url: Option<&str>,
817 ) -> Result<bool> {
818 if self.meta_get("repo_id_source")?.as_deref() == Some("explicit") {
819 return Ok(false);
820 }
821 let n: u64 = self.conn.query_row(
822 "SELECT COUNT(*) FROM entities",
823 [],
824 |r| r.get(0),
825 )?;
826 if n > 0 {
827 return Ok(false);
828 }
829 let basename = self
830 .root
831 .file_name()
832 .map(|s| s.to_string_lossy().to_string())
833 .unwrap_or_else(|| "repository".to_string());
834 let id = scc_core::identity::stable_repo_id(explicit, remote_url, &basename);
835 if id == self.repo_id {
836 return Ok(false);
837 }
838 self.conn.execute(
839 "DELETE FROM repositories WHERE id = ?1",
840 params![self.repo_id],
841 )?;
842 self.repo_id = id;
843 self.ensure_repository()?;
844 self.meta_set("repo_id_source", "remote")?;
845 Ok(true)
846 }
847
848 pub fn repository(&self) -> Repository {
850 Repository {
851 id: self.repo_id.clone(),
852 name: self.repo_name.clone(),
853 url: self
854 .meta_get("remote_url")
855 .ok()
856 .flatten()
857 .filter(|s| !s.is_empty()),
858 }
859 }
860
861 pub fn plugin_state_put(&self, plugin_id: &str, key: &str, value: &str) -> Result<()> {
869 self.conn.execute(
870 "INSERT INTO plugin_state (plugin_id, key, value, updated_at) VALUES (?1, ?2, ?3, ?4)
871 ON CONFLICT(plugin_id, key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at",
872 params![plugin_id, key, value, scc_core::now_rfc3339()],
873 )?;
874 Ok(())
875 }
876
877 pub fn plugin_state_get(&self, plugin_id: &str, key: &str) -> Result<Option<String>> {
879 let v = self
880 .conn
881 .query_row(
882 "SELECT value FROM plugin_state WHERE plugin_id = ?1 AND key = ?2",
883 params![plugin_id, key],
884 |r| r.get(0),
885 )
886 .optional()?;
887 Ok(v)
888 }
889
890 pub fn sidecar_put(&self, plugin_id: &str, graph: &str, key: &str, value: &str) -> Result<()> {
899 self.conn.execute(
900 "INSERT INTO sidecar_facts (plugin_id, graph, key, value, updated_at) VALUES (?1, ?2, ?3, ?4, ?5)
901 ON CONFLICT(plugin_id, graph, key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at",
902 params![plugin_id, graph, key, value, scc_core::now_rfc3339()],
903 )?;
904 Ok(())
905 }
906
907 pub fn sidecar_get(&self, plugin_id: &str, graph: &str, key: &str) -> Result<Option<String>> {
909 let v = self
910 .conn
911 .query_row(
912 "SELECT value FROM sidecar_facts WHERE plugin_id = ?1 AND graph = ?2 AND key = ?3",
913 params![plugin_id, graph, key],
914 |r| r.get(0),
915 )
916 .optional()?;
917 Ok(v)
918 }
919
920 pub fn sidecar_scan(&self, plugin_id: &str, graph: &str, prefix: &str, limit: usize) -> Result<Vec<(String, String)>> {
922 let esc = prefix.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_");
923 let like = format!("{esc}%");
924 let mut stmt = self.conn.prepare(
925 "SELECT key, value FROM sidecar_facts WHERE plugin_id = ?1 AND graph = ?2 AND key LIKE ?3 ESCAPE '\\' ORDER BY key LIMIT ?4",
926 )?;
927 let rows = stmt.query_map(params![plugin_id, graph, like, limit.max(1) as i64], |r| {
928 Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
929 })?;
930 rows.collect::<std::result::Result<Vec<_>, _>>().map_err(|e| e.into())
931 }
932
933 pub fn plugin_state_delete(&self, plugin_id: &str, key: &str) -> Result<()> {
935 self.conn.execute(
936 "DELETE FROM plugin_state WHERE plugin_id = ?1 AND key = ?2",
937 params![plugin_id, key],
938 )?;
939 Ok(())
940 }
941
942 pub fn plugin_state_scan(&self, plugin_id: &str, prefix: &str, limit: usize) -> Result<Vec<(String, String)>> {
944 let esc = prefix.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_");
945 let like = format!("{esc}%");
946 let mut stmt = self.conn.prepare(
947 "SELECT key, value FROM plugin_state WHERE plugin_id = ?1 AND key LIKE ?2 ESCAPE '\\' ORDER BY key LIMIT ?3",
948 )?;
949 let rows = stmt.query_map(params![plugin_id, like, limit.max(1) as i64], |r| {
950 Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
951 })?;
952 rows.collect::<std::result::Result<Vec<_>, _>>().map_err(|e| e.into())
953 }
954
955 pub fn meta_set(&self, key: &str, value: &str) -> Result<()> {
961 self.conn.execute(
962 "INSERT INTO meta (key, value) VALUES (?1, ?2)
963 ON CONFLICT(key) DO UPDATE SET value = excluded.value",
964 params![key, value],
965 )?;
966 Ok(())
967 }
968
969 pub fn meta_get(&self, key: &str) -> Result<Option<String>> {
971 let v = self
972 .conn
973 .query_row("SELECT value FROM meta WHERE key = ?1", params![key], |r| {
974 r.get(0)
975 })
976 .optional()?;
977 Ok(v)
978 }
979
980 pub fn bump_epoch(&self, kind: ModelEpochKind) -> Result<()> {
985 let key = kind.meta_key();
986 let next: u64 = self
987 .conn
988 .query_row("SELECT value FROM meta WHERE key = ?1", params![key], |r| {
989 r.get::<_, String>(0)
990 })
991 .optional()?
992 .and_then(|s| s.parse::<u64>().ok())
993 .unwrap_or(0)
994 + 1;
995 self.meta_set(key, &next.to_string())
996 }
997
998 pub fn model_epoch(&self) -> Result<ModelEpoch> {
1003 let g = |k: &str| -> Result<u64> {
1004 Ok(self
1005 .meta_get(k)?
1006 .and_then(|s| s.parse::<u64>().ok())
1007 .unwrap_or(0))
1008 };
1009 Ok(ModelEpoch {
1010 source: g(ModelEpochKind::Source.meta_key())?,
1011 semantic: g(ModelEpochKind::Semantic.meta_key())?,
1012 evidence: g(ModelEpochKind::Evidence.meta_key())?,
1013 intent: g(ModelEpochKind::Intent.meta_key())?,
1014 runtime: g(ModelEpochKind::Runtime.meta_key())?,
1015 derived: g(ModelEpochKind::Derived.meta_key())?,
1016 })
1017 }
1018
1019 pub fn begin_snapshot(&self, revision: &str, branch: Option<&str>) -> Result<i64> {
1025 self.conn.execute(
1026 "INSERT INTO snapshots (revision, branch, indexed_at, status) VALUES (?1, ?2, ?3, 'active')",
1027 params![revision, branch, scc_core::now_rfc3339()],
1028 )?;
1029 Ok(self.conn.last_insert_rowid())
1030 }
1031
1032 pub fn finish_snapshot(&self, id: i64, file_count: usize) -> Result<()> {
1034 self.conn.execute(
1035 "UPDATE snapshots SET file_count = ?2, status = 'complete' WHERE id = ?1",
1036 params![id, file_count as i64],
1037 )?;
1038 self.bump_epoch(ModelEpochKind::Source)?;
1040 Ok(())
1041 }
1042
1043 pub fn latest_snapshot(&self) -> Result<Option<Snapshot>> {
1045 let row = self
1046 .conn
1047 .query_row(
1048 "SELECT revision, branch, indexed_at FROM snapshots
1049 WHERE status = 'complete' ORDER BY id DESC LIMIT 1",
1050 [],
1051 |r| {
1052 Ok((
1053 r.get::<_, String>(0)?,
1054 r.get::<_, Option<String>>(1)?,
1055 r.get::<_, String>(2)?,
1056 ))
1057 },
1058 )
1059 .optional()?;
1060 Ok(row.map(|(revision, branch, indexed_at)| Snapshot {
1061 revision,
1062 branch,
1063 indexed_at,
1064 }))
1065 }
1066
1067 pub fn snapshot_status(&self) -> Result<Option<(Snapshot, i64)>> {
1069 let row = self
1070 .conn
1071 .query_row(
1072 "SELECT revision, branch, indexed_at, (SELECT COUNT(*) FROM files) FROM snapshots
1073 WHERE status = 'complete' ORDER BY id DESC LIMIT 1",
1074 [],
1075 |r| {
1076 Ok((
1077 r.get::<_, String>(0)?,
1078 r.get::<_, Option<String>>(1)?,
1079 r.get::<_, String>(2)?,
1080 r.get::<_, i64>(3)?,
1081 ))
1082 },
1083 )
1084 .optional()?;
1085 Ok(row.map(|(revision, branch, indexed_at, files)| {
1086 (
1087 Snapshot {
1088 revision,
1089 branch,
1090 indexed_at,
1091 },
1092 files,
1093 )
1094 }))
1095 }
1096
1097 pub fn upsert_file(&self, path: &str, hash: &str, language: &str, kind: &str, size: u64) -> Result<()> {
1103 self.conn.execute(
1104 "INSERT INTO files (path, hash, language, kind, size, indexed_at)
1105 VALUES (?1, ?2, ?3, ?4, ?5, ?6)
1106 ON CONFLICT(path) DO UPDATE SET hash = excluded.hash, language = excluded.language,
1107 kind = excluded.kind, size = excluded.size, indexed_at = excluded.indexed_at",
1108 params![path, hash, language, kind, size as i64, scc_core::now_rfc3339()],
1109 )?;
1110 Ok(())
1111 }
1112
1113 pub fn file(&self, path: &str) -> Result<Option<(String, String, String, u64)>> {
1115 let row = self
1116 .conn
1117 .query_row(
1118 "SELECT hash, language, kind, size FROM files WHERE path = ?1",
1119 params![path],
1120 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get::<_, i64>(3)? as u64)),
1121 )
1122 .optional()?;
1123 Ok(row)
1124 }
1125
1126 pub fn all_files(&self) -> Result<Vec<(String, String, String, String, u64)>> {
1128 let mut stmt = self
1129 .conn
1130 .prepare("SELECT path, hash, language, kind, size FROM files")?;
1131 let rows = stmt.query_map([], |r| {
1132 Ok((
1133 r.get::<_, String>(0)?,
1134 r.get::<_, String>(1)?,
1135 r.get::<_, String>(2)?,
1136 r.get::<_, String>(3)?,
1137 r.get::<_, i64>(4)? as u64,
1138 ))
1139 })?;
1140 let mut out = Vec::new();
1141 for row in rows {
1142 out.push(row?);
1143 }
1144 Ok(out)
1145 }
1146
1147 pub fn delete_file(&self, path: &str) -> Result<()> {
1149 self.conn.execute("DELETE FROM files WHERE path = ?1", params![path])?;
1150 Ok(())
1151 }
1152
1153pub fn purge_path(&self, path: &str) -> Result<()> {
1168 let _sp = self.nest()?;
1169 let affected_concepts: Vec<String> = {
1172 let mut stmt = self.conn.prepare(
1173 "SELECT DISTINCT r.object FROM relationships r
1174 JOIN entities e ON e.id = r.subject
1175 WHERE r.predicate = ?1 AND e.kind = ?2 AND e.sources LIKE ?3",
1176 )?;
1177 let rows = stmt.query_map(
1178 params![
1179 scc_core::predicates::OCCURS,
1180 scc_core::kinds::OCCURRENCE,
1181 format!("%\"{path}\"%")
1182 ],
1183 |r| r.get::<_, String>(0),
1184 )?;
1185 let mut v = Vec::new();
1186 for r in rows {
1187 v.push(r?);
1188 }
1189 v.sort();
1190 v.dedup();
1191 v
1192 };
1193 let occ_ids: Vec<String> = {
1195 let mut stmt = self.conn.prepare(
1196 "SELECT id FROM entities WHERE kind = ?1 AND sources LIKE ?2",
1197 )?;
1198 let rows = stmt.query_map(
1199 params![scc_core::kinds::OCCURRENCE, format!("%\"{path}\"%")],
1200 |r| r.get::<_, String>(0),
1201 )?;
1202 let mut v = Vec::new();
1203 for r in rows {
1204 v.push(r?);
1205 }
1206 v
1207 };
1208 let ev_ids: Vec<String> = {
1210 let mut stmt = self.conn.prepare("SELECT id FROM evidence WHERE path = ?1")?;
1211 let rows = stmt.query_map(params![path], |r| r.get::<_, String>(0))?;
1212 let mut v = Vec::new();
1213 for r in rows {
1214 v.push(r?);
1215 }
1216 v
1217 };
1218 let sym_names: Vec<String> = {
1220 let mut stmt = self.conn.prepare("SELECT name FROM symbols WHERE file = ?1")?;
1221 let rows = stmt.query_map(params![path], |r| r.get::<_, String>(0))?;
1222 let mut v = Vec::new();
1223 for r in rows {
1224 v.push(r?);
1225 }
1226 v
1227 };
1228 self.conn.execute("DELETE FROM symbols WHERE file = ?1", params![path])?;
1229 self.conn.execute("DELETE FROM imports WHERE file = ?1", params![path])?;
1230 self.conn.execute("DELETE FROM evidence WHERE path = ?1", params![path])?;
1231 let mut orphaned_evidence: Vec<String> = Vec::new();
1234 for name in sym_names {
1235 let sid = scc_core::symbol_id(&self.repo_id, path, &name);
1236 let mut stmt = self.conn.prepare(
1237 "SELECT id, evidence FROM relationships WHERE subject = ?1 OR object = ?1",
1238 )?;
1239 let rows = stmt.query_map(params![sid], |r| {
1240 Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
1241 })?;
1242 let mut to_delete: Vec<(String, String)> = Vec::new();
1243 for r in rows {
1244 to_delete.push(r?);
1245 }
1246 drop(stmt);
1247 for (rid, ev_json) in to_delete {
1248 if let Ok(ev_ids) = serde_json::from_str::<Vec<String>>(&ev_json) {
1249 orphaned_evidence.extend(ev_ids);
1250 }
1251 self.conn.execute("DELETE FROM relationships WHERE id = ?1", params![rid])?;
1252 }
1253 }
1254 let _ = orphaned_evidence;
1258 self.conn.execute(
1259 "DELETE FROM entities WHERE id LIKE ?1",
1260 params![format!("%/symbol/{path}/%")],
1261 )?;
1262 self.conn.execute(
1268 "DELETE FROM entities WHERE sources LIKE ?1 AND kind NOT IN (?2, ?3)",
1269 params![
1270 format!("%\"{path}\"%"),
1271 scc_core::kinds::SCHEMA,
1272 scc_core::kinds::REACTIVE
1273 ],
1274 )?;
1275 self.conn.execute(
1276 "DELETE FROM relationships WHERE source_path = ?1",
1277 params![path],
1278 )?;
1279 for ev in ev_ids {
1281 self.conn.execute(
1282 "DELETE FROM relationships WHERE evidence LIKE ?1",
1283 params![format!("%\"{ev}\"%")],
1284 )?;
1285 }
1286 self.conn.execute(
1287 "DELETE FROM tests WHERE file = ?1",
1288 params![path],
1289 )?;
1290 for oid in &occ_ids {
1293 self.conn.execute(
1294 "DELETE FROM relationships WHERE subject = ?1 OR object = ?1",
1295 params![oid],
1296 )?;
1297 self.conn.execute("DELETE FROM entities_fts WHERE id = ?1", params![oid])?;
1298 }
1299 self.conn.execute(
1300 "DELETE FROM entities WHERE kind = ?1 AND sources LIKE ?2",
1301 params![scc_core::kinds::OCCURRENCE, format!("%\"{path}\"%")],
1302 )?;
1303 for concept in &affected_concepts {
1308 let remaining: Vec<String> = {
1309 let mut stmt = self.conn.prepare(
1310 "SELECT DISTINCT json_extract(e.attributes, '$.path') FROM entities e
1311 JOIN relationships r ON r.subject = e.id
1312 WHERE r.predicate = ?1 AND r.object = ?2 AND e.kind = ?3
1313 AND json_extract(e.attributes, '$.path') IS NOT NULL
1314 ORDER BY 1",
1315 )?;
1316 let rows = stmt.query_map(
1317 params![
1318 scc_core::predicates::OCCURS,
1319 concept,
1320 scc_core::kinds::OCCURRENCE
1321 ],
1322 |r| r.get::<_, String>(0),
1323 )?;
1324 let mut v = Vec::new();
1325 for r in rows {
1326 v.push(r?);
1327 }
1328 v
1329 };
1330 if remaining.is_empty() {
1331 self.conn.execute(
1332 "DELETE FROM relationships WHERE subject = ?1 OR object = ?1",
1333 params![concept],
1334 )?;
1335 self.conn.execute("DELETE FROM entities_fts WHERE id = ?1", params![concept])?;
1336 self.conn.execute("DELETE FROM entities WHERE id = ?1", params![concept])?;
1337 } else {
1338 self.conn.execute(
1339 "UPDATE entities SET sources = ?1 WHERE id = ?2",
1340 params![serde_json::to_string(&remaining)?, concept],
1341 )?;
1342 }
1343 }
1344 _sp.commit()?;
1345 Ok(())
1346 }
1347
1348 pub fn paths_depending_on(&self, path: &str) -> Result<Vec<String>> {
1353 let file_id = scc_core::entity_id(&self.repo_id, scc_core::kinds::FILE, path);
1354 let mut stmt = self.conn.prepare(
1355 "SELECT DISTINCT source_path FROM relationships
1356 WHERE source_path != ?1 AND source_path != ''
1357 AND predicate IN (?4, ?5)
1358 AND (
1359 object = ?2
1360 OR object IN (
1361 SELECT id FROM entities
1362 WHERE kind = ?3 AND json_extract(attributes, '$.file') = ?1
1363 )
1364 )
1365 ORDER BY source_path",
1366 )?;
1367 let rows = stmt.query_map(
1368 params![
1369 path,
1370 file_id,
1371 scc_core::kinds::SYMBOL,
1372 scc_core::predicates::IMPORTS,
1373 scc_core::predicates::CALLS
1374 ],
1375 |r| r.get::<_, String>(0),
1376 )?;
1377 let mut out = Vec::new();
1378 for r in rows {
1379 let p = r?;
1380 if !p.is_empty() {
1381 out.push(p);
1382 }
1383 }
1384 Ok(out)
1385 }
1386
1387 pub fn purge_all(&self) -> Result<()> {
1390 let _sp = self.nest()?;
1391 for table in [
1392 "symbols",
1393 "entities",
1394 "relationships",
1395 "evidence",
1396 "components",
1397 "flows",
1398 "invariants",
1399 "tests",
1400 "context_cache",
1401 "drift_findings",
1402 ] {
1403 self.conn.execute(&format!("DELETE FROM {table}"), [])?;
1404 }
1405 self.conn.execute("DELETE FROM files", [])?;
1406 self.conn.execute("DELETE FROM snapshots", [])?;
1407 _sp.commit()?;
1408 Ok(())
1409 }
1410
1411 #[allow(clippy::too_many_arguments)]
1416 pub fn insert_symbol(
1418 &self,
1419 file: &str,
1420 name: &str,
1421 kind: &str,
1422 signature: Option<&str>,
1423 start_line: u32,
1424 end_line: u32,
1425 exported: bool,
1426 docstring: Option<&str>,
1427 ) -> Result<i64> {
1428 self.conn.execute(
1429 "INSERT INTO symbols (file, name, symbol_kind, signature, start_line, end_line, exported, docstring)
1430 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
1431 params![
1432 file,
1433 name,
1434 kind,
1435 signature,
1436 start_line as i64,
1437 end_line as i64,
1438 exported as i64,
1439 docstring
1440 ],
1441 )?;
1442 let id = self.conn.last_insert_rowid();
1443 self.conn.execute(
1444 "INSERT INTO symbols_fts (name, signature, symbol_kind, file) VALUES (?1, ?2, ?3, ?4)",
1445 params![name, signature.unwrap_or(""), kind, file],
1446 )?;
1447 Ok(id)
1448 }
1449
1450 pub fn symbols_in_file(&self, file: &str) -> Result<Vec<(i64, String, String, Option<String>, u32, u32, bool, Option<String>)>> {
1452 let mut stmt = self
1453 .conn
1454 .prepare("SELECT id, name, symbol_kind, signature, start_line, end_line, exported, docstring FROM symbols WHERE file = ?1 ORDER BY start_line")?;
1455 let rows = stmt.query_map(params![file], |r| {
1456 Ok((
1457 r.get::<_, i64>(0)?,
1458 r.get::<_, String>(1)?,
1459 r.get::<_, String>(2)?,
1460 r.get::<_, Option<String>>(3)?,
1461 r.get::<_, i64>(4)? as u32,
1462 r.get::<_, i64>(5)? as u32,
1463 r.get::<_, i64>(6)? != 0,
1464 r.get::<_, Option<String>>(7)?,
1465 ))
1466 })?;
1467 let mut out = Vec::new();
1468 for r in rows {
1469 out.push(r?);
1470 }
1471 Ok(out)
1472 }
1473
1474 pub fn symbols_named(&self, name: &str) -> Result<Vec<(String, String, String)>> {
1476 let mut stmt = self
1477 .conn
1478 .prepare("SELECT file, symbol_kind, signature FROM symbols WHERE name = ?1")?;
1479 let rows = stmt.query_map(params![name], |r| {
1480 Ok((r.get(0)?, r.get(1)?, r.get(2)?))
1481 })?;
1482 let mut out = Vec::new();
1483 for r in rows {
1484 out.push(r?);
1485 }
1486 Ok(out)
1487 }
1488
1489 pub fn insert_imports(&self, file: &str, imports: &[(String, Vec<(String, String)>, u32, String)]) -> Result<()> {
1495 let _sp = self.nest()?;
1496 self.conn.execute("DELETE FROM imports WHERE file = ?1", params![file])?;
1497 for (module, names, line, typ) in imports {
1498 self.conn.execute(
1499 "INSERT INTO imports (file, module, names, line, type) VALUES (?1, ?2, ?3, ?4, ?5)",
1500 params![file, module, serde_json::to_string(names)?, *line as i64, typ],
1501 )?;
1502 }
1503 _sp.commit()?;
1504 Ok(())
1505 }
1506
1507 pub fn imports_in_file(&self, file: &str) -> Result<Vec<(String, Vec<(String, String)>, u32, String)>> {
1509 let mut stmt = self
1510 .conn
1511 .prepare("SELECT module, names, line, type FROM imports WHERE file = ?1 ORDER BY line")?;
1512 let rows = stmt.query_map(params![file], |r| {
1513 Ok((
1514 r.get::<_, String>(0)?,
1515 r.get::<_, String>(1)?,
1516 r.get::<_, i64>(2)? as u32,
1517 r.get::<_, String>(3)?,
1518 ))
1519 })?;
1520 let mut out = Vec::new();
1521 for r in rows {
1522 let (module, names, line, typ) = r?;
1523 out.push((
1524 module,
1525 serde_json::from_str(&names).unwrap_or_default(),
1526 line,
1527 typ,
1528 ));
1529 }
1530 Ok(out)
1531 }
1532
1533 pub fn all_imports(&self) -> Result<Vec<(String, String, Vec<(String, String)>, u32, String)>> {
1535 let mut stmt = self
1536 .conn
1537 .prepare("SELECT file, module, names, line, type FROM imports ORDER BY file, line")?;
1538 let rows = stmt.query_map([], |r| {
1539 Ok((
1540 r.get::<_, String>(0)?,
1541 r.get::<_, String>(1)?,
1542 r.get::<_, String>(2)?,
1543 r.get::<_, i64>(3)? as u32,
1544 r.get::<_, String>(4)?,
1545 ))
1546 })?;
1547 let mut out = Vec::new();
1548 for r in rows {
1549 let (file, module, names, line, typ) = r?;
1550 out.push((
1551 file,
1552 module,
1553 serde_json::from_str(&names).unwrap_or_default(),
1554 line,
1555 typ,
1556 ));
1557 }
1558 Ok(out)
1559 }
1560
1561 pub fn insert_entity(&self, entity: &Entity, sources: &[String]) -> Result<()> {
1567 self.conn.execute(
1568 "INSERT OR REPLACE INTO entities (id, kind, name, attributes, evidence, sources)
1569 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
1570 params![
1571 entity.id,
1572 entity.kind,
1573 entity.name,
1574 serde_json::to_string(&entity.attributes)?,
1575 serde_json::to_string(&entity.evidence)?,
1576 serde_json::to_string(sources)?,
1577 ],
1578 )?;
1579 self.conn.execute(
1580 "INSERT OR REPLACE INTO entities_fts (id, kind, name, attributes) VALUES (?1, ?2, ?3, ?4)",
1581 params![
1582 entity.id,
1583 entity.kind,
1584 entity.name,
1585 serde_json::to_string(&entity.attributes)?
1586 ],
1587 )?;
1588 Ok(())
1589 }
1590
1591 pub fn get_entity(&self, id: &str) -> Result<Option<Entity>> {
1593 let row = self
1594 .conn
1595 .query_row(
1596 "SELECT id, kind, name, attributes, evidence FROM entities WHERE id = ?1",
1597 params![id],
1598 |r| {
1599 Ok((
1600 r.get::<_, String>(0)?,
1601 r.get::<_, String>(1)?,
1602 r.get::<_, String>(2)?,
1603 r.get::<_, String>(3)?,
1604 r.get::<_, String>(4)?,
1605 ))
1606 },
1607 )
1608 .optional()?;
1609 Ok(row.map(|(id, kind, name, attributes, evidence)| Entity {
1610 id,
1611 kind,
1612 name,
1613 attributes: serde_json::from_str(&attributes).unwrap_or_default(),
1614 evidence: serde_json::from_str(&evidence).unwrap_or_default(),
1615 }))
1616 }
1617
1618 pub fn entity_sources(&self, id: &str) -> Result<Vec<String>> {
1623 let row = self
1624 .conn
1625 .query_row(
1626 "SELECT sources FROM entities WHERE id = ?1",
1627 params![id],
1628 |r| r.get::<_, String>(0),
1629 )
1630 .optional()?;
1631 Ok(row
1632 .map(|s| serde_json::from_str(&s).unwrap_or_default())
1633 .unwrap_or_default())
1634 }
1635
1636 pub fn concept_occurrences(&self, concept_id: &str) -> Result<Vec<Entity>> {
1641 let mut stmt = self.conn.prepare(
1642 "SELECT e.id, e.kind, e.name, e.attributes, e.evidence FROM entities e
1643 JOIN relationships r ON r.subject = e.id
1644 WHERE r.predicate = ?1 AND r.object = ?2 AND e.kind = ?3
1645 ORDER BY e.id",
1646 )?;
1647 let rows = stmt.query_map(
1648 params![
1649 scc_core::predicates::OCCURS,
1650 concept_id,
1651 scc_core::kinds::OCCURRENCE
1652 ],
1653 |r| {
1654 Ok((
1655 r.get::<_, String>(0)?,
1656 r.get::<_, String>(1)?,
1657 r.get::<_, String>(2)?,
1658 r.get::<_, String>(3)?,
1659 r.get::<_, String>(4)?,
1660 ))
1661 },
1662 )?;
1663 let mut out = Vec::new();
1664 for r in rows {
1665 let (id, kind, name, attributes, evidence) = r?;
1666 out.push(Entity {
1667 id,
1668 kind,
1669 name,
1670 attributes: serde_json::from_str(&attributes).unwrap_or_default(),
1671 evidence: serde_json::from_str(&evidence).unwrap_or_default(),
1672 });
1673 }
1674 Ok(out)
1675 }
1676
1677 pub fn entities_by_kind(&self, kind: &str) -> Result<Vec<Entity>> {
1679 let mut stmt = self
1680 .conn
1681 .prepare("SELECT id, kind, name, attributes, evidence FROM entities WHERE kind = ?1 ORDER BY name")?;
1682 let rows = stmt.query_map(params![kind], |r| {
1683 Ok((
1684 r.get::<_, String>(0)?,
1685 r.get::<_, String>(1)?,
1686 r.get::<_, String>(2)?,
1687 r.get::<_, String>(3)?,
1688 r.get::<_, String>(4)?,
1689 ))
1690 })?;
1691 let mut out = Vec::new();
1692 for r in rows {
1693 let (id, kind, name, attributes, evidence) = r?;
1694 out.push(Entity {
1695 id,
1696 kind,
1697 name,
1698 attributes: serde_json::from_str(&attributes).unwrap_or_default(),
1699 evidence: serde_json::from_str(&evidence).unwrap_or_default(),
1700 });
1701 }
1702 Ok(out)
1703 }
1704
1705 pub fn all_entities(&self) -> Result<Vec<Entity>> {
1707 self.all_entities_impl()
1708 }
1709
1710 fn all_entities_impl(&self) -> Result<Vec<Entity>> {
1712 let mut stmt = self
1713 .conn
1714 .prepare("SELECT id, kind, name, attributes, evidence FROM entities ORDER BY kind, name")?;
1715 let rows = stmt.query_map([], |r| {
1716 Ok((
1717 r.get::<_, String>(0)?,
1718 r.get::<_, String>(1)?,
1719 r.get::<_, String>(2)?,
1720 r.get::<_, String>(3)?,
1721 r.get::<_, String>(4)?,
1722 ))
1723 })?;
1724 let mut out = Vec::new();
1725 for r in rows {
1726 let (id, kind, name, attributes, evidence) = r?;
1727 out.push(Entity {
1728 id,
1729 kind,
1730 name,
1731 attributes: serde_json::from_str(&attributes).unwrap_or_default(),
1732 evidence: serde_json::from_str(&evidence).unwrap_or_default(),
1733 });
1734 }
1735 Ok(out)
1736 }
1737
1738 pub fn delete_entity(&self, id: &str) -> Result<()> {
1740 self.conn.execute("DELETE FROM entities WHERE id = ?1", params![id])?;
1741 self.conn
1742 .execute("DELETE FROM entities_fts WHERE id = ?1", params![id])?;
1743 Ok(())
1744 }
1745
1746 pub fn delete_entities(&self, ids: &[String]) -> Result<()> {
1748 for id in ids {
1749 self.delete_entity(id)?;
1750 }
1751 Ok(())
1752 }
1753
1754 pub fn insert_relationship(&self, rel: &Relationship, source_path: &str) -> Result<()> {
1760 self.conn.execute(
1761 "INSERT OR REPLACE INTO relationships (id, subject, predicate, object, provenance, confidence, evidence, verified_at, source_path)
1762 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
1763 params![
1764 rel.id,
1765 rel.subject,
1766 rel.predicate,
1767 rel.object,
1768 rel.provenance.as_str(),
1769 rel.confidence,
1770 serde_json::to_string(&rel.evidence)?,
1771 rel.verified_at,
1772 source_path,
1773 ],
1774 )?;
1775 Ok(())
1776 }
1777
1778 pub fn relationships_for(&self, subject: &str) -> Result<Vec<Relationship>> {
1780 self.query_relationships("SELECT * FROM relationships WHERE subject = ?1 ORDER BY id", params![subject])
1781 }
1782
1783 pub fn relationships_to(&self, object: &str) -> Result<Vec<Relationship>> {
1785 self.query_relationships("SELECT * FROM relationships WHERE object = ?1 ORDER BY id", params![object])
1786 }
1787
1788 pub fn relationships_between(&self, subject: &str, predicate: &str, object: &str) -> Result<Vec<Relationship>> {
1790 self.query_relationships(
1791 "SELECT * FROM relationships WHERE subject = ?1 AND predicate = ?2 AND object = ?3",
1792 params![subject, predicate, object],
1793 )
1794 }
1795
1796 pub fn all_relationships(&self) -> Result<Vec<Relationship>> {
1798 self.query_relationships("SELECT * FROM relationships ORDER BY id", [])
1799 }
1800
1801 pub fn relationship_ids_with_source(&self, path: &str, predicate: &str) -> Result<Vec<String>> {
1804 let mut stmt = self.conn.prepare(
1805 "SELECT id FROM relationships WHERE source_path = ?1 AND predicate = ?2",
1806 )?;
1807 let rows = stmt.query_map(params![path, predicate], |r| r.get::<_, String>(0))?;
1808 let mut out = Vec::new();
1809 for r in rows {
1810 out.push(r?);
1811 }
1812 Ok(out)
1813 }
1814
1815 pub fn count_relationships(&self) -> Result<u64> {
1817 Ok(self
1818 .conn
1819 .query_row("SELECT COUNT(*) FROM relationships", [], |r| r.get(0))?)
1820 }
1821
1822 pub fn delete_relationship(&self, id: &str) -> Result<()> {
1824 self.conn
1825 .execute("DELETE FROM relationships WHERE id = ?1", params![id])?;
1826 Ok(())
1827 }
1828
1829 fn query_relationships(
1831 &self,
1832 sql: &str,
1833 p: impl rusqlite::Params,
1835 ) -> Result<Vec<Relationship>> {
1836 let mut stmt = self.conn.prepare(sql)?;
1837 let rows = stmt.query_map(p, |r| {
1838 Ok((
1839 r.get::<_, String>(0)?,
1840 r.get::<_, String>(1)?,
1841 r.get::<_, String>(2)?,
1842 r.get::<_, String>(3)?,
1843 r.get::<_, String>(4)?,
1844 r.get::<_, f64>(5)?,
1845 r.get::<_, String>(6)?,
1846 r.get::<_, String>(7)?,
1847 ))
1848 })?;
1849 let mut out = Vec::new();
1850 for row in rows {
1851 let (id, subject, predicate, object, provenance, confidence, evidence, verified_at) =
1852 row?;
1853 out.push(Relationship {
1854 id,
1855 subject,
1856 predicate,
1857 object,
1858 provenance: parse_provenance(&provenance),
1859 confidence,
1860 evidence: serde_json::from_str(&evidence).unwrap_or_default(),
1861 verified_at,
1862 });
1863 }
1864 Ok(out)
1865 }
1866
1867 pub fn insert_evidence(&self, ev: &Evidence) -> Result<()> {
1873 self.conn.execute(
1874 "INSERT OR REPLACE INTO evidence (id, type, path, symbol, start_line, end_line, revision, content_hash, extractor, extractor_version)
1875 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
1876 params![
1877 ev.id,
1878 evidence_type_str(&ev.r#type),
1879 ev.path,
1880 ev.symbol,
1881 ev.start_line.map(|l| l as i64),
1882 ev.end_line.map(|l| l as i64),
1883 ev.revision,
1884 ev.content_hash,
1885 ev.extractor,
1886 ev.extractor_version,
1887 ],
1888 )?;
1889 Ok(())
1890 }
1891
1892 pub fn get_evidence(&self, id: &str) -> Result<Option<Evidence>> {
1894 let row = self
1895 .conn
1896 .query_row(
1897 "SELECT id, type, path, symbol, start_line, end_line, revision, content_hash, extractor, extractor_version FROM evidence WHERE id = ?1",
1898 params![id],
1899 |r| {
1900 Ok((
1901 r.get::<_, String>(0)?,
1902 r.get::<_, String>(1)?,
1903 r.get::<_, Option<String>>(2)?,
1904 r.get::<_, Option<String>>(3)?,
1905 r.get::<_, Option<i64>>(4)?,
1906 r.get::<_, Option<i64>>(5)?,
1907 r.get::<_, Option<String>>(6)?,
1908 r.get::<_, Option<String>>(7)?,
1909 r.get::<_, Option<String>>(8)?,
1910 r.get::<_, Option<String>>(9)?,
1911 ))
1912 },
1913 )
1914 .optional()?;
1915 Ok(row.map(|(id, typ, path, symbol, sl, el, rev, hash, ext, extv)| Evidence {
1916 id,
1917 r#type: parse_evidence_type(&typ),
1918 path,
1919 symbol,
1920 start_line: sl.map(|v| v as u32),
1921 end_line: el.map(|v| v as u32),
1922 revision: rev,
1923 content_hash: hash,
1924 extractor: ext,
1925 extractor_version: extv,
1926 }))
1927 }
1928
1929 pub fn all_evidence(&self) -> Result<Vec<Evidence>> {
1931 let mut stmt = self
1932 .conn
1933 .prepare("SELECT id, type, path, symbol, start_line, end_line, revision, content_hash, extractor, extractor_version FROM evidence ORDER BY id")?;
1934 let rows = stmt.query_map([], |r| {
1935 Ok((
1936 r.get::<_, String>(0)?,
1937 r.get::<_, String>(1)?,
1938 r.get::<_, Option<String>>(2)?,
1939 r.get::<_, Option<String>>(3)?,
1940 r.get::<_, Option<i64>>(4)?,
1941 r.get::<_, Option<i64>>(5)?,
1942 r.get::<_, Option<String>>(6)?,
1943 r.get::<_, Option<String>>(7)?,
1944 r.get::<_, Option<String>>(8)?,
1945 r.get::<_, Option<String>>(9)?,
1946 ))
1947 })?;
1948 let mut out = Vec::new();
1949 for row in rows {
1950 let (id, typ, path, symbol, sl, el, rev, hash, ext, extv) = row?;
1951 out.push(Evidence {
1952 id,
1953 r#type: parse_evidence_type(&typ),
1954 path,
1955 symbol,
1956 start_line: sl.map(|v| v as u32),
1957 end_line: el.map(|v| v as u32),
1958 revision: rev,
1959 content_hash: hash,
1960 extractor: ext,
1961 extractor_version: extv,
1962 });
1963 }
1964 Ok(out)
1965 }
1966
1967 pub fn evidence_for_path(&self, path: &str) -> Result<Vec<Evidence>> {
1969 let mut stmt = self
1970 .conn
1971 .prepare("SELECT id, type, path, symbol, start_line, end_line, revision, content_hash, extractor, extractor_version FROM evidence WHERE path = ?1 ORDER BY id")?;
1972 let rows = stmt.query_map(params![path], |r| {
1973 Ok((
1974 r.get::<_, String>(0)?,
1975 r.get::<_, String>(1)?,
1976 r.get::<_, Option<String>>(2)?,
1977 r.get::<_, Option<String>>(3)?,
1978 r.get::<_, Option<i64>>(4)?,
1979 r.get::<_, Option<i64>>(5)?,
1980 r.get::<_, Option<String>>(6)?,
1981 r.get::<_, Option<String>>(7)?,
1982 r.get::<_, Option<String>>(8)?,
1983 r.get::<_, Option<String>>(9)?,
1984 ))
1985 })?;
1986 let mut out = Vec::new();
1987 for row in rows {
1988 let (id, typ, path, symbol, sl, el, rev, hash, ext, extv) = row?;
1989 out.push(Evidence {
1990 id,
1991 r#type: parse_evidence_type(&typ),
1992 path,
1993 symbol,
1994 start_line: sl.map(|v| v as u32),
1995 end_line: el.map(|v| v as u32),
1996 revision: rev,
1997 content_hash: hash,
1998 extractor: ext,
1999 extractor_version: extv,
2000 });
2001 }
2002 Ok(out)
2003 }
2004
2005 pub fn replace_components(&self, components: &[Entity]) -> Result<()> {
2011 let _sp = self.nest()?;
2012 self.conn.execute("DELETE FROM components", [])?;
2013 let keep: HashSet<&str> = components.iter().map(|c| c.id.as_str()).collect();
2018 let stale: Vec<String> = {
2019 let mut stmt = self.conn.prepare("SELECT id FROM entities WHERE kind = ?1")?;
2020 let rows = stmt.query_map(params![scc_core::kinds::COMPONENT], |r| {
2021 r.get::<_, String>(0)
2022 })?;
2023 let mut v = Vec::new();
2024 for r in rows {
2025 let id = r?;
2026 if !keep.contains(id.as_str()) {
2027 v.push(id);
2028 }
2029 }
2030 v
2031 };
2032 for id in &stale {
2033 self.conn.execute("DELETE FROM entities WHERE id = ?1", params![id])?;
2034 self.conn.execute("DELETE FROM entities_fts WHERE id = ?1", params![id])?;
2035 }
2036 for c in components {
2037 self.conn.execute(
2038 "INSERT INTO components (id, name, kind, responsibility, implementation, evidence, attributes)
2039 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
2040 params![
2041 c.id,
2042 c.name,
2043 c.kind,
2044 serde_json::to_string(&c.attributes.get("responsibility").cloned().unwrap_or(serde_json::json!([])))?,
2045 serde_json::to_string(&c.attributes.get("implementation").cloned().unwrap_or(serde_json::json!([])))?,
2046 serde_json::to_string(&c.evidence)?,
2047 serde_json::to_string(&c.attributes)?,
2048 ],
2049 )?;
2050 self.conn.execute(
2051 "INSERT OR REPLACE INTO entities (id, kind, name, attributes, evidence, sources) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
2052 params![c.id, c.kind, c.name, serde_json::to_string(&c.attributes)?, serde_json::to_string(&c.evidence)?, "[]"],
2053 )?;
2054 self.conn.execute(
2055 "INSERT OR REPLACE INTO entities_fts (id, kind, name, attributes) VALUES (?1, ?2, ?3, ?4)",
2056 params![c.id, c.kind, c.name, serde_json::to_string(&c.attributes)?],
2057 )?;
2058 }
2059 _sp.commit()?;
2060 Ok(())
2061 }
2062
2063 pub fn components(&self) -> Result<Vec<Entity>> {
2065 let mut stmt = self
2066 .conn
2067 .prepare("SELECT id, name, kind, responsibility, implementation, evidence, attributes FROM components ORDER BY name")?;
2068 let rows = stmt.query_map([], |r| {
2069 Ok((
2070 r.get::<_, String>(0)?,
2071 r.get::<_, String>(1)?,
2072 r.get::<_, String>(2)?,
2073 r.get::<_, String>(3)?,
2074 r.get::<_, String>(4)?,
2075 r.get::<_, String>(5)?,
2076 r.get::<_, String>(6)?,
2077 ))
2078 })?;
2079 let mut out = Vec::new();
2080 for row in rows {
2081 let (id, name, kind, resp, implm, ev, attrs) = row?;
2082 let mut attributes: std::collections::BTreeMap<String, serde_json::Value> =
2083 serde_json::from_str(&attrs).unwrap_or_default();
2084 if let Ok(r) = serde_json::from_str::<Vec<serde_json::Value>>(&resp) {
2085 attributes.insert("responsibility".into(), serde_json::Value::Array(r));
2086 }
2087 if let Ok(r) = serde_json::from_str::<Vec<serde_json::Value>>(&implm) {
2088 attributes.insert("implementation".into(), serde_json::Value::Array(r));
2089 }
2090 out.push(Entity {
2091 id,
2092 kind,
2093 name,
2094 attributes,
2095 evidence: serde_json::from_str(&ev).unwrap_or_default(),
2096 });
2097 }
2098 Ok(out)
2099 }
2100
2101 pub fn replace_flow_graphs(&self, graphs: &[scc_core::FlowGraph]) -> Result<()> {
2107 let _sp = self.nest()?;
2108 self.conn.execute("DELETE FROM flow_graphs", [])?;
2109 for g in graphs {
2110 let kind = scc_core::flow_kind_str(&g.kind);
2111 let trigger = g.trigger.clone().unwrap_or_default();
2112 self.conn.execute(
2113 "INSERT OR REPLACE INTO flow_graphs (id, kind, name, trigger, graph) VALUES (?1, ?2, ?3, ?4, ?5)",
2114 params![g.id, kind, g.name, trigger, serde_json::to_string(g)?],
2115 )?;
2116 }
2117 _sp.commit()?;
2118 Ok(())
2119 }
2120
2121 pub fn flow_graphs(&self) -> Result<Vec<scc_core::FlowGraph>> {
2123 let mut stmt = self
2124 .conn
2125 .prepare("SELECT graph FROM flow_graphs ORDER BY id")?;
2126 let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
2127 let mut out = Vec::new();
2128 for r in rows {
2129 let json = r?;
2130 if let Ok(g) = serde_json::from_str(&json) {
2131 out.push(g);
2132 }
2133 }
2134 Ok(out)
2135 }
2136
2137 pub fn replace_flows(&self, flows: &[Flow]) -> Result<()> {
2139 let _sp = self.nest()?;
2140 self.conn.execute("DELETE FROM flows", [])?;
2141 for f in flows {
2142 self.conn.execute(
2143 "INSERT INTO flows (id, kind, name, trigger, steps, attributes) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
2144 params![
2145 f.id,
2146 flow_kind_str(&f.kind),
2147 f.name,
2148 f.trigger,
2149 serde_json::to_string(&f.steps)?,
2150 serde_json::to_string(&f.attributes)?
2151 ],
2152 )?;
2153 }
2154 _sp.commit()?;
2155 Ok(())
2156 }
2157
2158 pub fn flows(&self) -> Result<Vec<Flow>> {
2160 let mut stmt = self
2161 .conn
2162 .prepare("SELECT id, kind, name, trigger, steps, attributes FROM flows ORDER BY kind, name")?;
2163 let rows = stmt.query_map([], |r| {
2164 Ok((
2165 r.get::<_, String>(0)?,
2166 r.get::<_, String>(1)?,
2167 r.get::<_, String>(2)?,
2168 r.get::<_, Option<String>>(3)?,
2169 r.get::<_, String>(4)?,
2170 r.get::<_, String>(5)?,
2171 ))
2172 })?;
2173 let mut out = Vec::new();
2174 for row in rows {
2175 let (id, kind, name, trigger, steps, attrs) = row?;
2176 out.push(Flow {
2177 id,
2178 kind: parse_flow_kind(&kind),
2179 name,
2180 trigger,
2181 steps: serde_json::from_str(&steps).unwrap_or_default(),
2182 attributes: serde_json::from_str(&attrs).unwrap_or_default(),
2183 });
2184 }
2185 Ok(out)
2186 }
2187
2188 pub fn flow(&self, id: &str) -> Result<Option<Flow>> {
2190 let row = self
2191 .conn
2192 .query_row(
2193 "SELECT id, kind, name, trigger, steps, attributes FROM flows WHERE id = ?1",
2194 params![id],
2195 |r| {
2196 Ok((
2197 r.get::<_, String>(0)?,
2198 r.get::<_, String>(1)?,
2199 r.get::<_, String>(2)?,
2200 r.get::<_, Option<String>>(3)?,
2201 r.get::<_, String>(4)?,
2202 r.get::<_, String>(5)?,
2203 ))
2204 },
2205 )
2206 .optional()?;
2207 Ok(row.map(|(id, kind, name, trigger, steps, attrs)| Flow {
2208 id,
2209 kind: parse_flow_kind(&kind),
2210 name,
2211 trigger,
2212 steps: serde_json::from_str(&steps).unwrap_or_default(),
2213 attributes: serde_json::from_str(&attrs).unwrap_or_default(),
2214 }))
2215 }
2216
2217 pub fn replace_invariants(&self, invariants: &[Invariant]) -> Result<()> {
2219 let _sp = self.nest()?;
2220 self.conn.execute("DELETE FROM invariants", [])?;
2221 for inv in invariants {
2222 self.conn.execute(
2223 "INSERT INTO invariants (id, statement, severity, scope, enforced_by, provenance, evidence)
2224 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
2225 params![
2226 inv.id,
2227 inv.statement,
2228 severity_str(&inv.severity),
2229 serde_json::to_string(&inv.scope)?,
2230 serde_json::to_string(&inv.enforced_by)?,
2231 inv.provenance.map(|p| p.as_str().to_string()).unwrap_or_else(|| "DECLARED".into()),
2232 serde_json::to_string(&inv.evidence)?,
2233 ],
2234 )?;
2235 }
2236 _sp.commit()?;
2237 Ok(())
2238 }
2239
2240 pub fn invariants(&self) -> Result<Vec<Invariant>> {
2242 let mut stmt = self
2243 .conn
2244 .prepare("SELECT id, statement, severity, scope, enforced_by, provenance, evidence FROM invariants ORDER BY id")?;
2245 let rows = stmt.query_map([], |r| {
2246 Ok((
2247 r.get::<_, String>(0)?,
2248 r.get::<_, String>(1)?,
2249 r.get::<_, String>(2)?,
2250 r.get::<_, String>(3)?,
2251 r.get::<_, String>(4)?,
2252 r.get::<_, String>(5)?,
2253 r.get::<_, String>(6)?,
2254 ))
2255 })?;
2256 let mut out = Vec::new();
2257 for row in rows {
2258 let (id, statement, severity, scope, enforced_by, provenance, ev) = row?;
2259 out.push(Invariant {
2260 id,
2261 statement,
2262 severity: parse_severity(&severity),
2263 scope: serde_json::from_str(&scope).unwrap_or_default(),
2264 enforced_by: serde_json::from_str(&enforced_by).unwrap_or_default(),
2265 provenance: Some(parse_provenance(&provenance)),
2266 evidence: serde_json::from_str(&ev).unwrap_or_default(),
2267 });
2268 }
2269 Ok(out)
2270 }
2271
2272 pub fn insert_test(&self, id: &str, name: &str, file: &str, kind: &str, symbol: Option<&str>) -> Result<()> {
2274 self.conn.execute(
2275 "INSERT OR REPLACE INTO tests (id, name, file, kind, symbol) VALUES (?1, ?2, ?3, ?4, ?5)",
2276 params![id, name, file, kind, symbol],
2277 )?;
2278 Ok(())
2279 }
2280
2281 pub fn tests(&self) -> Result<Vec<(String, String, String, String, Option<String>)>> {
2283 let mut stmt = self
2284 .conn
2285 .prepare("SELECT id, name, file, kind, symbol FROM tests ORDER BY file, name")?;
2286 let rows = stmt.query_map([], |r| {
2287 Ok((
2288 r.get::<_, String>(0)?,
2289 r.get::<_, String>(1)?,
2290 r.get::<_, String>(2)?,
2291 r.get::<_, String>(3)?,
2292 r.get::<_, Option<String>>(4)?,
2293 ))
2294 })?;
2295 let mut out = Vec::new();
2296 for r in rows {
2297 out.push(r?);
2298 }
2299 Ok(out)
2300 }
2301
2302 pub fn sweep_orphan_evidence(&self) -> Result<u64> {
2309 let mut referenced: HashSet<String> = HashSet::new();
2313
2314 for (table, column) in [
2316 ("entities", "evidence"),
2317 ("relationships", "evidence"),
2318 ("components", "evidence"),
2319 ("invariants", "evidence"),
2320 ] {
2321 let sql = format!("SELECT {column} FROM {table}");
2322 let mut stmt = self.conn.prepare(&sql)?;
2323 let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
2324 for r in rows {
2325 if let Ok(ids) = serde_json::from_str::<Vec<String>>(&r?) {
2326 referenced.extend(ids);
2327 }
2328 }
2329 drop(stmt);
2330 }
2331
2332 {
2334 let mut stmt = self.conn.prepare("SELECT steps FROM flows")?;
2335 let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
2336 for r in rows {
2337 if let Ok(steps) = serde_json::from_str::<Vec<scc_core::FlowStep>>(&r?) {
2338 for step in steps {
2339 referenced.extend(step.evidence);
2340 }
2341 }
2342 }
2343 drop(stmt);
2344 }
2345
2346 {
2348 let mut stmt = self.conn.prepare("SELECT graph FROM flow_graphs")?;
2349 let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
2350 for r in rows {
2351 if let Ok(g) = serde_json::from_str::<scc_core::FlowGraph>(&r?) {
2352 for node in &g.nodes {
2353 referenced.extend(node.evidence.iter().cloned());
2354 }
2355 for edge in &g.edges {
2356 referenced.extend(edge.evidence.iter().cloned());
2357 }
2358 }
2359 }
2360 drop(stmt);
2361 }
2362
2363 let mut stmt = self.conn.prepare("SELECT id FROM evidence")?;
2364 let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
2365 let mut orphans: Vec<String> = Vec::new();
2366 for r in rows {
2367 let id = r?;
2368 if !referenced.contains(&id) {
2369 orphans.push(id);
2370 }
2371 }
2372 drop(stmt);
2373
2374 let count = orphans.len() as u64;
2375 for chunk in orphans.chunks(500) {
2377 let placeholders = vec!["?"; chunk.len()].join(",");
2378 let sql = format!("DELETE FROM evidence WHERE id IN ({placeholders})");
2379 self.conn
2380 .execute(&sql, rusqlite::params_from_iter(chunk.iter()))?;
2381 }
2382 Ok(count)
2383 }
2384
2385 pub fn cache_epoch(&self) -> Result<String> {
2394 let revision = self
2395 .latest_snapshot()?
2396 .map(|s| s.revision)
2397 .unwrap_or_else(|| "not-indexed".to_string());
2398 Ok(self.model_epoch()?.composite(&revision))
2399 }
2400
2401 pub fn cache_get(&self, key: &str, epoch: &str) -> Result<Option<String>> {
2403 let row = self
2404 .conn
2405 .query_row(
2406 "SELECT pack FROM context_cache WHERE key = ?1 AND epoch = ?2",
2407 params![key, epoch],
2408 |r| r.get(0),
2409 )
2410 .optional()?;
2411 Ok(row)
2412 }
2413
2414 pub fn cache_put(&self, key: &str, pack: &str, epoch: &str) -> Result<()> {
2416 self.conn.execute(
2417 "INSERT OR REPLACE INTO context_cache (key, pack, epoch, created_at) VALUES (?1, ?2, ?3, ?4)",
2418 params![key, pack, epoch, scc_core::now_rfc3339()],
2419 )?;
2420 Ok(())
2421 }
2422
2423 pub fn cache_clear(&self) -> Result<()> {
2425 self.conn.execute("DELETE FROM context_cache", [])?;
2426 Ok(())
2427 }
2428
2429 pub fn replace_intent_claims(&self, claims: &[(String, serde_json::Value)]) -> Result<()> {
2435 let _sp = self.nest()?;
2436 self.conn.execute("DELETE FROM intent_claims", [])?;
2437 for (source, claim) in claims {
2438 self.conn.execute(
2439 "INSERT INTO intent_claims (source, claim, created_at) VALUES (?1, ?2, ?3)",
2440 params![source, serde_json::to_string(claim)?, scc_core::now_rfc3339()],
2441 )?;
2442 }
2443 _sp.commit()?;
2444 self.bump_epoch(ModelEpochKind::Intent)?;
2446 Ok(())
2447 }
2448
2449 pub fn intent_claims(&self) -> Result<Vec<(String, serde_json::Value)>> {
2451 let mut stmt = self
2452 .conn
2453 .prepare("SELECT source, claim FROM intent_claims ORDER BY id")?;
2454 let rows = stmt.query_map([], |r| {
2455 Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
2456 })?;
2457 let mut out = Vec::new();
2458 for r in rows {
2459 let (source, claim) = r?;
2460 if let Ok(v) = serde_json::from_str(&claim) {
2461 out.push((source, v));
2462 }
2463 }
2464 Ok(out)
2465 }
2466
2467 pub fn runtime_edge_rows(&self) -> Result<Vec<RuntimeEdgeRow>> {
2473 let mut stmt = self.conn.prepare(
2474 "SELECT source, target, count, latency_ms, errors, last_observed
2475 FROM runtime_edges ORDER BY source, target",
2476 )?;
2477 let rows = stmt.query_map([], |r| {
2478 Ok(RuntimeEdgeRow {
2479 source: r.get(0)?,
2480 target: r.get(1)?,
2481 count: r.get::<_, i64>(2)? as u64,
2482 latency_ms: r.get(3)?,
2483 errors: r.get::<_, i64>(4)? as u64,
2484 last_observed: r.get(5)?,
2485 })
2486 })?;
2487 let mut out = Vec::new();
2488 for r in rows {
2489 out.push(r?);
2490 }
2491 Ok(out)
2492 }
2493
2494 pub fn upsert_trace_signature(&self, signature: &str, latency_ms: f64, errors: u64) -> Result<()> {
2505 self.conn.execute(
2506 "INSERT INTO trace_signatures (signature, count, latency_ms, errors, last_observed)
2507 VALUES (?1, 1, ?2, ?3, ?4)
2508 ON CONFLICT(signature) DO UPDATE SET
2509 count = trace_signatures.count + 1,
2510 latency_ms = (trace_signatures.latency_ms * trace_signatures.count + excluded.latency_ms)
2511 / (trace_signatures.count + 1),
2512 errors = trace_signatures.errors + excluded.errors,
2513 last_observed = excluded.last_observed",
2514 params![signature, latency_ms, errors as i64, scc_core::now_rfc3339()],
2515 )?;
2516 Ok(())
2517 }
2518
2519 pub fn trace_signatures(&self) -> Result<Vec<(String, u64, f64, u64, String)>> {
2522 let mut stmt = self.conn.prepare(
2523 "SELECT signature, count, latency_ms, errors, last_observed
2524 FROM trace_signatures ORDER BY count DESC, signature",
2525 )?;
2526 let rows = stmt.query_map([], |r| {
2527 Ok((
2528 r.get::<_, String>(0)?,
2529 r.get::<_, i64>(1)? as u64,
2530 r.get::<_, f64>(2)?,
2531 r.get::<_, i64>(3)? as u64,
2532 r.get::<_, String>(4)?,
2533 ))
2534 })?;
2535 let mut out = Vec::new();
2536 for r in rows {
2537 out.push(r?);
2538 }
2539 Ok(out)
2540 }
2541
2542 pub fn put_embedding(&self, entity_id: &str, vector: &[f32], model: &str) -> Result<()> {
2548 let bytes: Vec<u8> = vector
2549 .iter()
2550 .flat_map(|f| f.to_le_bytes())
2551 .collect();
2552 self.conn.execute(
2553 "INSERT OR REPLACE INTO embeddings (entity_id, vector, model, updated_at)
2554 VALUES (?1, ?2, ?3, ?4)",
2555 params![entity_id, bytes, model, scc_core::now_rfc3339()],
2556 )?;
2557 Ok(())
2558 }
2559
2560 pub fn get_embedding(&self, entity_id: &str) -> Result<Option<(Vec<f32>, String)>> {
2562 let row = self
2563 .conn
2564 .query_row(
2565 "SELECT vector, model FROM embeddings WHERE entity_id = ?1",
2566 params![entity_id],
2567 |r| Ok((r.get::<_, Vec<u8>>(0)?, r.get::<_, String>(1)?)),
2568 )
2569 .optional()?;
2570 Ok(row.map(|(bytes, model)| {
2571 let v = bytes
2572 .as_chunks::<4>().0.iter()
2573 .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
2574 .collect();
2575 (v, model)
2576 }))
2577 }
2578
2579 pub fn embedding_count(&self) -> Result<u64> {
2581 Ok(self
2582 .conn
2583 .query_row("SELECT COUNT(*) FROM embeddings", [], |r| r.get(0))?)
2584 }
2585
2586 pub fn add_drift_finding(&self, kind: &str, severity: &str, message: &str) -> Result<i64> {
2588 self.conn.execute(
2589 "INSERT INTO drift_findings (kind, severity, message, created_at, resolved) VALUES (?1, ?2, ?3, ?4, 0)",
2590 params![kind, severity, message, scc_core::now_rfc3339()],
2591 )?;
2592 Ok(self.conn.last_insert_rowid())
2593 }
2594
2595 pub fn drift_findings(&self, unresolved_only: bool) -> Result<Vec<(i64, String, String, String, String)>> {
2597 let sql = if unresolved_only {
2598 "SELECT id, kind, severity, message, created_at FROM drift_findings WHERE resolved = 0 ORDER BY id"
2599 } else {
2600 "SELECT id, kind, severity, message, created_at FROM drift_findings ORDER BY id"
2601 };
2602 let mut stmt = self.conn.prepare(sql)?;
2603 let rows = stmt.query_map([], |r| {
2604 Ok((
2605 r.get::<_, i64>(0)?,
2606 r.get::<_, String>(1)?,
2607 r.get::<_, String>(2)?,
2608 r.get::<_, String>(3)?,
2609 r.get::<_, String>(4)?,
2610 ))
2611 })?;
2612 let mut out = Vec::new();
2613 for r in rows {
2614 out.push(r?);
2615 }
2616 Ok(out)
2617 }
2618
2619 pub fn clear_drift_findings(&self) -> Result<()> {
2621 self.conn.execute("DELETE FROM drift_findings", [])?;
2622 Ok(())
2623 }
2624
2625 pub fn search_entities(&self, query: &str, limit: usize) -> Result<Vec<Entity>> {
2632 let q = fts_query(query);
2633 let mut stmt = self.conn.prepare(
2634 "SELECT e.id, e.kind, e.name, e.attributes, e.evidence
2635 FROM entities_fts f JOIN entities e ON e.id = f.id
2636 WHERE entities_fts MATCH ?1 ORDER BY bm25(entities_fts) LIMIT ?2",
2637 )?;
2638 let rows = stmt.query_map(params![q, limit as i64], |r| {
2639 Ok((
2640 r.get::<_, String>(0)?,
2641 r.get::<_, String>(1)?,
2642 r.get::<_, String>(2)?,
2643 r.get::<_, String>(3)?,
2644 r.get::<_, String>(4)?,
2645 ))
2646 })?;
2647 let mut out = Vec::new();
2648 for r in rows {
2649 let (id, kind, name, attributes, evidence) = r?;
2650 out.push(Entity {
2651 id,
2652 kind,
2653 name,
2654 attributes: serde_json::from_str(&attributes).unwrap_or_default(),
2655 evidence: serde_json::from_str(&evidence).unwrap_or_default(),
2656 });
2657 }
2658 Ok(out)
2659 }
2660
2661 pub fn search_symbols(&self, query: &str, limit: usize) -> Result<Vec<(String, String, String, String, u32)>> {
2664 let q = fts_query(query);
2665 let mut stmt = self.conn.prepare(
2666 "SELECT f.name, f.signature, f.symbol_kind, f.file,
2667 COALESCE((SELECT s.start_line FROM symbols s
2668 WHERE s.file = f.file AND s.name = f.name LIMIT 1), 0)
2669 FROM symbols_fts f
2670 WHERE symbols_fts MATCH ?1 ORDER BY bm25(symbols_fts) LIMIT ?2",
2671 )?;
2672 let rows = stmt.query_map(params![q, limit as i64], |r| {
2673 Ok((
2674 r.get::<_, String>(0)?,
2675 r.get::<_, Option<String>>(1)?.unwrap_or_default(),
2676 r.get::<_, String>(2)?,
2677 r.get::<_, String>(3)?,
2678 r.get::<_, i64>(4)? as u32,
2679 ))
2680 })?;
2681 let mut out = Vec::new();
2682 for r in rows {
2683 out.push(r?);
2684 }
2685 Ok(out)
2686 }
2687
2688 pub fn search_entities_like(&self, term: &str, limit: usize) -> Result<Vec<Entity>> {
2694 let pat = format!("%{}%", term.to_ascii_lowercase());
2695 let mut stmt = self.conn.prepare(
2696 "SELECT id, kind, name, attributes, evidence FROM entities
2697 WHERE lower(name) LIKE ?1 OR lower(attributes) LIKE ?1
2698 ORDER BY CASE WHEN lower(name) LIKE ?1 THEN 0 ELSE 1 END, length(name)
2699 LIMIT ?2",
2700 )?;
2701 let rows = stmt.query_map(params![pat, limit as i64], |r| {
2702 Ok((
2703 r.get::<_, String>(0)?,
2704 r.get::<_, String>(1)?,
2705 r.get::<_, String>(2)?,
2706 r.get::<_, String>(3)?,
2707 r.get::<_, String>(4)?,
2708 ))
2709 })?;
2710 let mut out = Vec::new();
2711 for r in rows {
2712 let (id, kind, name, attributes, evidence) = r?;
2713 out.push(Entity {
2714 id,
2715 kind,
2716 name,
2717 attributes: serde_json::from_str(&attributes).unwrap_or_default(),
2718 evidence: serde_json::from_str(&evidence).unwrap_or_default(),
2719 });
2720 }
2721 Ok(out)
2722 }
2723
2724 pub fn search_entities_like_kind(&self, kind: &str, term: &str, limit: usize) -> Result<Vec<Entity>> {
2729 let pat = format!("%{}%", term.to_ascii_lowercase());
2730 let mut stmt = self.conn.prepare(
2731 "SELECT id, kind, name, attributes, evidence FROM entities
2732 WHERE kind = ?1 AND lower(name) LIKE ?2
2733 ORDER BY length(name) LIMIT ?3",
2734 )?;
2735 let rows = stmt.query_map(params![kind, pat, limit as i64], |r| {
2736 Ok((
2737 r.get::<_, String>(0)?,
2738 r.get::<_, String>(1)?,
2739 r.get::<_, String>(2)?,
2740 r.get::<_, String>(3)?,
2741 r.get::<_, String>(4)?,
2742 ))
2743 })?;
2744 let mut out = Vec::new();
2745 for r in rows {
2746 let (id, kind, name, attributes, evidence) = r?;
2747 out.push(Entity {
2748 id,
2749 kind,
2750 name,
2751 attributes: serde_json::from_str(&attributes).unwrap_or_default(),
2752 evidence: serde_json::from_str(&evidence).unwrap_or_default(),
2753 });
2754 }
2755 Ok(out)
2756 }
2757
2758 pub fn search_symbols_like(&self, term: &str, limit: usize) -> Result<Vec<(String, String, String, String, u32)>> {
2761 let pat = format!("%{}%", term.to_ascii_lowercase());
2762 let mut stmt = self.conn.prepare(
2763 "SELECT name, signature, symbol_kind, file, start_line FROM symbols
2764 WHERE lower(name) LIKE ?1 OR lower(signature) LIKE ?1 OR lower(docstring) LIKE ?1
2765 ORDER BY CASE WHEN lower(name) LIKE ?1 THEN 0 ELSE 1 END, length(name)
2766 LIMIT ?2",
2767 )?;
2768 let rows = stmt.query_map(params![pat, limit as i64], |r| {
2769 Ok((
2770 r.get::<_, String>(0)?,
2771 r.get::<_, Option<String>>(1)?.unwrap_or_default(),
2772 r.get::<_, String>(2)?,
2773 r.get::<_, String>(3)?,
2774 r.get::<_, i64>(4)? as u32,
2775 ))
2776 })?;
2777 let mut out = Vec::new();
2778 for r in rows {
2779 out.push(r?);
2780 }
2781 Ok(out)
2782 }
2783
2784 pub fn stats(&self) -> Result<HashMap<String, u64>> {
2790 let mut m = HashMap::new();
2791 for (name, sql) in [
2792 ("files", "SELECT COUNT(*) FROM files"),
2793 ("symbols", "SELECT COUNT(*) FROM symbols"),
2794 ("entities", "SELECT COUNT(*) FROM entities"),
2795 ("relationships", "SELECT COUNT(*) FROM relationships"),
2796 ("evidence", "SELECT COUNT(*) FROM evidence"),
2797 ("components", "SELECT COUNT(*) FROM components"),
2798 ("flows", "SELECT COUNT(*) FROM flows"),
2799 ("invariants", "SELECT COUNT(*) FROM invariants"),
2800 ("tests", "SELECT COUNT(*) FROM tests"),
2801 ] {
2802 let n: i64 = self.conn.query_row(sql, [], |r| r.get(0))?;
2803 m.insert(name.to_string(), n as u64);
2804 }
2805 Ok(m)
2806 }
2807}
2808
2809fn apply_migrations(conn: &Connection) -> Result<()> {
2815 let version: i64 = conn.query_row("PRAGMA user_version", [], |r| r.get(0))?;
2816 let current = SCHEMA_VERSION as i64;
2817 if version > current {
2818 return Err(StoreError::Sqlite(rusqlite::Error::InvalidParameterName(
2819 format!(
2820 "database schema v{version} is newer than supported v{SCHEMA_VERSION}"
2821 )
2822 .into_boxed_str()
2823 .to_string(),
2824 )));
2825 }
2826 for (i, m) in MIGRATIONS.iter().enumerate() {
2828 let target = (i + 1) as i64;
2829 if version < target {
2830 conn.execute_batch(m)?;
2831 }
2832 }
2833 if version < current {
2834 conn.pragma_update(None, "user_version", current)?;
2835 }
2836 Ok(())
2837}
2838
2839pub fn parse_provenance(s: &str) -> Provenance {
2841 match s {
2842 "EXTRACTED" => Provenance::Extracted,
2843 "RESOLVED" => Provenance::Resolved,
2844 "OBSERVED" => Provenance::Observed,
2845 "DECLARED" => Provenance::Declared,
2846 "INFERRED" => Provenance::Inferred,
2847 "STALE" => Provenance::Stale,
2848 _ => Provenance::Inferred,
2849 }
2850}
2851
2852pub fn evidence_type_str(t: &scc_core::EvidenceType) -> &'static str {
2854 match t {
2855 scc_core::EvidenceType::Source => "source",
2856 scc_core::EvidenceType::Config => "config",
2857 scc_core::EvidenceType::Runtime => "runtime",
2858 scc_core::EvidenceType::Test => "test",
2859 scc_core::EvidenceType::Intent => "intent",
2860 scc_core::EvidenceType::History => "history",
2861 }
2862}
2863
2864pub fn parse_evidence_type(s: &str) -> scc_core::EvidenceType {
2866 match s {
2867 "source" => scc_core::EvidenceType::Source,
2868 "config" => scc_core::EvidenceType::Config,
2869 "runtime" => scc_core::EvidenceType::Runtime,
2870 "test" => scc_core::EvidenceType::Test,
2871 "intent" => scc_core::EvidenceType::Intent,
2872 "history" => scc_core::EvidenceType::History,
2873 _ => scc_core::EvidenceType::Source,
2874 }
2875}
2876
2877pub fn flow_kind_str(k: &scc_core::FlowKind) -> &'static str {
2879 match k {
2880 scc_core::FlowKind::Architecture => "architecture",
2881 scc_core::FlowKind::Workflow => "workflow",
2882 scc_core::FlowKind::Sequence => "sequence",
2883 scc_core::FlowKind::Dataflow => "dataflow",
2884 scc_core::FlowKind::Lifecycle => "lifecycle",
2885 }
2886}
2887
2888pub fn parse_flow_kind(s: &str) -> scc_core::FlowKind {
2890 match s {
2891 "architecture" => scc_core::FlowKind::Architecture,
2892 "workflow" => scc_core::FlowKind::Workflow,
2893 "sequence" => scc_core::FlowKind::Sequence,
2894 "dataflow" => scc_core::FlowKind::Dataflow,
2895 "lifecycle" => scc_core::FlowKind::Lifecycle,
2896 _ => scc_core::FlowKind::Sequence,
2897 }
2898}
2899
2900pub fn severity_str(s: &Severity) -> &'static str {
2902 match s {
2903 Severity::Info => "info",
2904 Severity::Low => "low",
2905 Severity::Medium => "medium",
2906 Severity::High => "high",
2907 Severity::Critical => "critical",
2908 }
2909}
2910
2911pub fn parse_severity(s: &str) -> Severity {
2913 match s {
2914 "info" => Severity::Info,
2915 "low" => Severity::Low,
2916 "medium" => Severity::Medium,
2917 "high" => Severity::High,
2918 "critical" => Severity::Critical,
2919 _ => Severity::Medium,
2920 }
2921}
2922
2923fn fts_query(text: &str) -> String {
2927 let tokens: Vec<String> = text
2928 .split(|c: char| !c.is_alphanumeric() && c != '_' && c != '-' && c != '.')
2929 .filter(|t| !t.is_empty())
2930 .map(|t| {
2931 let t = t.trim_matches('"');
2932 format!("\"{t}\"*")
2933 })
2934 .collect();
2935 if tokens.is_empty() {
2936 return "\"\"".to_string();
2937 }
2938 tokens.join(" ")
2939}
2940
2941#[cfg(test)]
2942mod tests {
2944 use super::*;
2945 use tempfile::TempDir;
2946
2947 pub(crate) fn tmp_store() -> (Store, TempDir) {
2949 let dir = TempDir::new().unwrap();
2950 let root = dir.path().join("repo");
2951 std::fs::create_dir_all(&root).unwrap();
2952 let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
2953 (store, dir)
2954 }
2955
2956 #[test]
2957 fn recovering_open_quarantines_malformed_db() {
2959 let dir = TempDir::new().unwrap();
2960 let root = dir.path().join("repo");
2961 std::fs::create_dir_all(&root).unwrap();
2962 let db = dir.path().join("scc.db");
2963 let mut bytes = b"SQLite format 3\0".to_vec();
2966 bytes.resize(4096, 0xFF);
2967 std::fs::write(&db, &bytes).unwrap();
2968 let (store, quarantined) = Store::open_recovering(&db, &root).unwrap();
2969 let q = quarantined.expect("corrupt db must be quarantined");
2970 assert!(q.is_file(), "quarantine evidence preserved");
2971 let v: i64 = store
2973 .conn
2974 .query_row("PRAGMA user_version", [], |r| r.get(0))
2975 .unwrap();
2976 assert!(v >= 0);
2977 let (_, q2) = Store::open_recovering(&db, &root).unwrap();
2979 assert!(q2.is_none(), "healthy db must not quarantine");
2980 }
2981
2982 #[test]
2983 fn persistent_identity_survives_checkout_move() {
2985 let dir = TempDir::new().unwrap();
2986 let root_a = dir.path().join("aaa");
2987 std::fs::create_dir_all(&root_a).unwrap();
2988 let db = dir.path().join("scc.db");
2989 let s1 = Store::open(&db, &root_a).unwrap();
2990 assert_eq!(s1.repo_id, "aaa");
2991 let root_b = dir.path().join("bbb");
2993 std::fs::create_dir_all(&root_b).unwrap();
2994 let s2 = Store::open(&db, &root_b).unwrap();
2995 assert_eq!(s2.repo_id, "aaa", "move must not fork identity");
2996 assert_eq!(
2997 s2.meta_get("repo_id_source").unwrap().as_deref(),
2998 Some("persistent")
2999 );
3000 let row: String = s2.conn.query_row(
3002 "SELECT root FROM repositories WHERE id = 'aaa'",
3003 [],
3004 |r| r.get(0),
3005 ).unwrap();
3006 assert!(row.ends_with("bbb"), "{row}");
3007 }
3008
3009 #[test]
3010 fn explicit_and_remote_identity_adopt_on_fresh_db() {
3012 let (mut s, _d) = tmp_store();
3013 assert_eq!(s.repo_id, "repo");
3014 assert!(s.adopt_stable_identity(Some("MyRepo"), None).unwrap());
3015 assert_eq!(s.repo_id, "myrepo");
3016 assert!(!s.adopt_stable_identity(Some("MyRepo"), None).unwrap());
3017 let (mut t, _e) = tmp_store();
3018 assert!(t.adopt_stable_identity(None, Some("git@github.com:acme/billing.git")).unwrap());
3019 assert_eq!(t.repo_id, "github.com/acme/billing");
3020 assert_eq!(t.meta_get("repo_id_source").unwrap().as_deref(), Some("remote"));
3022 t.insert_entity(&Entity::new("x", "symbol", "f"), &["a.py".into()]).unwrap();
3024 assert!(!t.adopt_stable_identity(Some("other"), None).unwrap());
3025 assert_eq!(t.repo_id, "github.com/acme/billing");
3026 }
3027
3028
3029 #[test]
3030 fn migrations_apply_and_reopen() {
3032 let dir = TempDir::new().unwrap();
3033 let root = dir.path().join("repo");
3034 std::fs::create_dir_all(&root).unwrap();
3035 let db = dir.path().join("scc.db");
3036 {
3037 let s = Store::open(&db, &root).unwrap();
3038 s.insert_entity(&Entity::new("repo://t/component/a", "component", "A"), &["x.py".into()]).unwrap();
3039 }
3040 let s = Store::open(&db, &root).unwrap();
3042 assert!(s.get_entity("repo://t/component/a").unwrap().is_some());
3043 }
3044
3045 #[test]
3046 fn truncated_or_garbage_db_refuses_to_open() {
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 std::fs::write(&db, b"not a sqlite database at all").unwrap();
3054 let err = match Store::open(&db, &root) {
3055 Err(e) => e,
3056 Ok(_) => panic!("garbage db must not open"),
3057 };
3058 assert!(
3059 matches!(err, StoreError::Corrupt(_)),
3060 "garbage must be Corrupt, got {err}"
3061 );
3062 assert!(
3063 err.to_string().contains("corrupt") || err.to_string().contains("not a SQLite"),
3064 "{err}"
3065 );
3066
3067 {
3068 let s = Store::open(&dir.path().join("fresh.db"), &root).unwrap();
3069 s.meta_set("k", "v").unwrap();
3070 }
3071 let good = std::fs::read(dir.path().join("fresh.db")).unwrap();
3072 assert!(good.len() > 32, "expected a real sqlite file");
3073 std::fs::write(&db, &good[..32]).unwrap();
3074 let err = match Store::open(&db, &root) {
3075 Err(e) => e,
3076 Ok(_) => panic!("truncated sqlite must not open"),
3077 };
3078 assert!(
3079 matches!(err, StoreError::Corrupt(_)),
3080 "truncated sqlite must be Corrupt, got {err}"
3081 );
3082
3083 let empty = dir.path().join("empty.db");
3084 std::fs::write(&empty, b"").unwrap();
3085 Store::open(&empty, &root).expect("empty file is a fresh index");
3086 }
3087
3088 #[test]
3089 fn model_epoch_bumps_and_composites() {
3091 let (s, _d) = tmp_store();
3092 assert_eq!(s.model_epoch().unwrap(), ModelEpoch::zero());
3093 let e0 = s.cache_epoch().unwrap();
3094
3095 let id = s.begin_snapshot("abc", None).unwrap();
3097 s.finish_snapshot(id, 3).unwrap();
3098 let e1 = s.model_epoch().unwrap();
3099 assert_eq!(e1.source, 1);
3100 assert_ne!(s.cache_epoch().unwrap(), e0);
3101
3102 s.bump_epoch(ModelEpochKind::Semantic).unwrap();
3104 let e2 = s.model_epoch().unwrap();
3105 assert_eq!(e2.semantic, 1);
3106 assert_eq!(e2.source, 1);
3107
3108 let c1 = e1.composite("abc");
3111 let c2 = e2.composite("abc");
3112 assert_ne!(c1, c2);
3113 assert_ne!(c1, e1.composite("def"));
3114 assert_eq!(c1, e1.composite("abc"));
3116 }
3117
3118 #[test]
3119 fn cache_is_keyed_on_epoch() {
3121 let (s, _d) = tmp_store();
3122 let e0 = s.cache_epoch().unwrap();
3123 s.cache_put("task:1", "pack-A", &e0).unwrap();
3124 assert_eq!(s.cache_get("task:1", &e0).unwrap().as_deref(), Some("pack-A"));
3125
3126 s.bump_epoch(ModelEpochKind::Runtime).unwrap();
3128 let e1 = s.cache_epoch().unwrap();
3129 assert_ne!(e0, e1);
3130 assert_eq!(s.cache_get("task:1", &e1).unwrap(), None);
3131 assert_eq!(s.cache_get("task:1", &e0).unwrap().as_deref(), Some("pack-A"));
3133 }
3134
3135 #[test]
3136 fn intent_replacement_bumps_intent_epoch() {
3138 let (s, _d) = tmp_store();
3139 let before = s.model_epoch().unwrap().intent;
3140 s.replace_intent_claims(&[("component".into(), serde_json::json!({"name": "a"}))])
3141 .unwrap();
3142 let after = s.model_epoch().unwrap();
3143 assert_eq!(after.intent, before + 1);
3144 }
3145
3146 #[test]
3147 fn trace_signature_upsert_roundtrip_and_epoch_neutral() {
3149 let dir = TempDir::new().unwrap();
3150 let root = dir.path().join("repo");
3151 std::fs::create_dir_all(&root).unwrap();
3152 let db = dir.path().join("scc.db");
3153 {
3154 let s = Store::open(&db, &root).unwrap();
3155 assert!(s.trace_signatures().unwrap().is_empty());
3156
3157 let epoch_before = s.model_epoch().unwrap();
3160 s.upsert_trace_signature("root -> api -> db", 4.5, 1).unwrap();
3161 s.upsert_trace_signature("root -> api -> db", 5.5, 0).unwrap();
3162 s.upsert_trace_signature("root -> web -> api", 2.0, 0).unwrap();
3163 assert_eq!(s.model_epoch().unwrap(), epoch_before);
3164
3165 let sigs = s.trace_signatures().unwrap();
3166 assert_eq!(sigs.len(), 2);
3167 assert_eq!(sigs[0].0, "root -> api -> db");
3170 assert_eq!(sigs[0].1, 2);
3171 assert!((sigs[0].2 - 5.0).abs() < 1e-9);
3172 assert_eq!(sigs[0].3, 1);
3173 assert!(!sigs[0].4.is_empty());
3174 assert_eq!(sigs[1].0, "root -> web -> api");
3176 assert_eq!(sigs[1].1, 1);
3177 }
3178 let s = Store::open(&db, &root).unwrap();
3180 let sigs = s.trace_signatures().unwrap();
3181 assert_eq!(sigs.len(), 2);
3182 assert_eq!(sigs[0].0, "root -> api -> db");
3183 assert_eq!(sigs[0].1, 2);
3184 }
3185
3186 #[test]
3187 fn entity_roundtrip_and_fts() {
3189 let (s, _d) = tmp_store();
3190 let mut e = Entity::new("repo://r/component/transcript", "component", "transcript-normalizer");
3191 e.attr("responsibility", serde_json::json!(["normalize transcripts"]));
3192 s.insert_entity(&e, &["src/normalize.py".into()]).unwrap();
3193 let got = s.get_entity("repo://r/component/transcript").unwrap().unwrap();
3194 assert_eq!(got.name, "transcript-normalizer");
3195 let hits = s.search_entities("normalize", 10).unwrap();
3196 assert_eq!(hits.len(), 1);
3197 assert_eq!(hits[0].id, "repo://r/component/transcript");
3198 }
3199
3200 #[test]
3201 fn relationship_roundtrip() {
3203 let (s, _d) = tmp_store();
3204 let rel = Relationship::new(
3205 "rel:1",
3206 "repo://r/component/a",
3207 "calls",
3208 "repo://r/component/b",
3209 Provenance::Resolved,
3210 )
3211 .with_evidence(vec!["evidence:1".into()]);
3212 s.insert_relationship(&rel, "src/a.py").unwrap();
3213 let got = s.relationships_for("repo://r/component/a").unwrap();
3214 assert_eq!(got.len(), 1);
3215 assert_eq!(got[0].predicate, "calls");
3216 assert_eq!(got[0].provenance, Provenance::Resolved);
3217 }
3218
3219 #[test]
3220 fn sweep_orphan_evidence_removes_only_unreferenced() {
3222 let (s, _d) = tmp_store();
3223 s.insert_evidence(&Evidence::source("evidence:ent", "src/a.py")).unwrap();
3225 s.insert_evidence(&Evidence::source("evidence:rel", "src/b.py")).unwrap();
3226 s.insert_evidence(&Evidence::source("evidence:orphan", "src/c.py")).unwrap();
3227
3228 let mut e = Entity::new("repo://r/component/keep", "component", "keep");
3229 e.evidence = vec!["evidence:ent".into()];
3230 s.insert_entity(&e, &["src/a.py".into()]).unwrap();
3231 let rel = Relationship::new(
3232 "rel:1",
3233 "repo://r/component/keep",
3234 "calls",
3235 "repo://r/component/other",
3236 Provenance::Resolved,
3237 )
3238 .with_evidence(vec!["evidence:rel".into()]);
3239 s.insert_relationship(&rel, "src/b.py").unwrap();
3240
3241 assert_eq!(s.sweep_orphan_evidence().unwrap(), 1);
3243 assert!(s.get_evidence("evidence:ent").unwrap().is_some());
3244 assert!(s.get_evidence("evidence:rel").unwrap().is_some());
3245 assert!(s.get_evidence("evidence:orphan").unwrap().is_none());
3246
3247 let e2 = Entity::new("repo://r/component/keep", "component", "keep");
3249 s.insert_entity(&e2, &["src/a.py".into()]).unwrap();
3250 assert_eq!(s.sweep_orphan_evidence().unwrap(), 1);
3251 assert!(s.get_evidence("evidence:ent").unwrap().is_none());
3252 assert!(s.get_evidence("evidence:rel").unwrap().is_some());
3253 }
3254
3255 #[test]
3256 fn sweep_orphan_evidence_keeps_flow_and_component_references() {
3258 let (s, _d) = tmp_store();
3259 s.insert_evidence(&Evidence::source("evidence:flow", "src/a.py")).unwrap();
3262 s.insert_evidence(&Evidence::source("evidence:comp", "src/b.py")).unwrap();
3263 s.insert_evidence(&Evidence::source("evidence:gone", "src/c.py")).unwrap();
3264
3265 let mut comp = Entity::new("repo://r/component/keep", "component", "keep");
3267 comp.evidence = vec!["evidence:comp".into()];
3268 s.replace_components(&[comp]).unwrap();
3269
3270 let flow = scc_core::Flow {
3272 id: "flow:1".into(),
3273 kind: scc_core::FlowKind::Workflow,
3274 name: "wf".into(),
3275 trigger: None,
3276 steps: vec![scc_core::FlowStep {
3277 id: "step:1".into(),
3278 order: 0,
3279 actor: "repo://r/component/keep".into(),
3280 operation: "run".into(),
3281 condition: None,
3282 r#async: None,
3283 timeout_ms: None,
3284 retry_policy: None,
3285 failure_outcome: None,
3286 provenance: None,
3287 evidence: vec!["evidence:flow".into()],
3288 }],
3289 attributes: Default::default(),
3290 };
3291 s.replace_flows(&[flow]).unwrap();
3292
3293 assert_eq!(s.sweep_orphan_evidence().unwrap(), 1);
3294 assert!(s.get_evidence("evidence:flow").unwrap().is_some());
3295 assert!(s.get_evidence("evidence:comp").unwrap().is_some());
3296 assert!(s.get_evidence("evidence:gone").unwrap().is_none());
3297 }
3298
3299 #[test]
3300 fn purge_path_cascades() {
3302 let (s, _d) = tmp_store();
3303 s.insert_symbol("a.py", "foo", "function", None, 1, 5, true, None).unwrap();
3304 s.insert_evidence(&Evidence::source("evidence:1", "a.py")).unwrap();
3305 let rel = Relationship::new("rel:1", "repo://r/symbol/a.py/foo", "calls", "repo://r/symbol/b.py/bar", Provenance::Extracted)
3306 .with_evidence(vec!["evidence:1".into()]);
3307 s.insert_relationship(&rel, "a.py").unwrap();
3308 s.purge_path("a.py").unwrap();
3309 assert_eq!(s.symbols_in_file("a.py").unwrap().len(), 0);
3310 assert_eq!(s.all_relationships().unwrap().len(), 0);
3311 assert!(s.get_evidence("evidence:1").unwrap().is_none());
3312 }
3313
3314 #[test]
3315 fn paths_depending_on_finds_importers_and_callers() {
3317 let (s, _d) = tmp_store();
3318 let repo = s.repo_id.clone();
3319 let file_b = scc_core::entity_id(&repo, scc_core::kinds::FILE, "b.py");
3320 let file_a = scc_core::entity_id(&repo, scc_core::kinds::FILE, "a.py");
3321 s.insert_entity(
3322 &Entity::new(file_b.clone(), scc_core::kinds::FILE, "b.py"),
3323 &["b.py".into()],
3324 )
3325 .unwrap();
3326 s.insert_entity(
3327 &Entity::new(file_a.clone(), scc_core::kinds::FILE, "a.py"),
3328 &["a.py".into()],
3329 )
3330 .unwrap();
3331 let mut meth = Entity::new(
3332 scc_core::symbol_id(&repo, "b.py", "Order.process"),
3333 scc_core::kinds::SYMBOL,
3334 "Order.process",
3335 );
3336 meth.attr("file", serde_json::json!("b.py"));
3337 s.insert_entity(&meth, &["b.py".into()]).unwrap();
3338 s.insert_relationship(
3339 &Relationship::new(
3340 "rel:imp",
3341 file_a.clone(),
3342 scc_core::predicates::IMPORTS,
3343 file_b,
3344 Provenance::Extracted,
3345 ),
3346 "a.py",
3347 )
3348 .unwrap();
3349 s.insert_relationship(
3350 &Relationship::new(
3351 "rel:call",
3352 scc_core::symbol_id(&repo, "a.py", "handle"),
3353 scc_core::predicates::CALLS,
3354 scc_core::symbol_id(&repo, "b.py", "Order.process"),
3355 Provenance::Extracted,
3356 ),
3357 "a.py",
3358 )
3359 .unwrap();
3360 let deps = s.paths_depending_on("b.py").unwrap();
3361 assert_eq!(deps, vec!["a.py".to_string()]);
3362 assert!(s.paths_depending_on("a.py").unwrap().is_empty());
3363 s.insert_relationship(
3364 &Relationship::new(
3365 "rel:tested",
3366 scc_core::symbol_id(&repo, "c.py", "test_it"),
3367 scc_core::predicates::TESTED_BY,
3368 scc_core::symbol_id(&repo, "b.py", "Order.process"),
3369 Provenance::Extracted,
3370 ),
3371 "c.py",
3372 )
3373 .unwrap();
3374 let deps = s.paths_depending_on("b.py").unwrap();
3375 assert_eq!(
3376 deps,
3377 vec!["a.py".to_string()],
3378 "TESTED_BY must not cascade: {deps:?}"
3379 );
3380 }
3381
3382 #[test]
3383 fn replace_components_drops_vanished_component_entities() {
3385 let (s, _d) = tmp_store();
3386 let merged = Entity::new(
3387 "repo://r/component/root-services",
3388 scc_core::kinds::COMPONENT,
3389 "root+services",
3390 );
3391 let root = Entity::new(
3392 "repo://r/component/root",
3393 scc_core::kinds::COMPONENT,
3394 "root",
3395 );
3396 s.replace_components(&[merged, root.clone()]).unwrap();
3397 assert_eq!(s.entities_by_kind(scc_core::kinds::COMPONENT).unwrap().len(), 2);
3398 s.replace_components(&[root]).unwrap();
3399 let left = s.entities_by_kind(scc_core::kinds::COMPONENT).unwrap();
3400 assert_eq!(left.len(), 1, "{left:?}");
3401 assert_eq!(left[0].name, "root");
3402 assert!(s.get_entity("repo://r/component/root-services").unwrap().is_none());
3403 }
3404
3405#[test]
3412fn purge_path_recomputes_shared_concept_provenance() {
3414 let (s, _d) = tmp_store();
3415 let repo = &s.repo_id;
3416 let expr = "z.object({ name: z.string() })";
3417 let concept = scc_core::entity_id(repo, scc_core::kinds::SCHEMA, expr);
3418 s.insert_entity(
3420 &Entity::new(concept.clone(), scc_core::kinds::SCHEMA, expr),
3421 &["a.ts".into(), "b.ts".into()],
3422 )
3423 .unwrap();
3424 for (path, owner) in [("a.ts", "makeA"), ("b.ts", "makeB")] {
3425 let occ = scc_core::occurrence_id(repo, expr, path, owner, 3);
3426 s.insert_entity(
3427 Entity::new(occ.clone(), scc_core::kinds::OCCURRENCE, format!("{expr}@{path}@{owner}@3"))
3428 .attr("concept", serde_json::json!(concept))
3429 .attr("path", serde_json::json!(path))
3430 .attr("owner", serde_json::json!(owner))
3431 .attr("line", serde_json::json!(3)),
3432 &[path.to_string()],
3433 )
3434 .unwrap();
3435 s.insert_relationship(
3436 &Relationship::new(
3437 format!("rel:occ:{path}"),
3438 occ.clone(),
3439 scc_core::predicates::OCCURS,
3440 concept.clone(),
3441 Provenance::Extracted,
3442 ),
3443 path,
3444 )
3445 .unwrap();
3446 }
3447 s.purge_path("a.ts").unwrap();
3449 let concept_ent = s
3450 .get_entity(&concept)
3451 .unwrap()
3452 .expect("concept survives a single-file purge");
3453 assert_eq!(concept_ent.kind, scc_core::kinds::SCHEMA);
3454 assert_eq!(s.entity_sources(&concept).unwrap(), vec!["b.ts"]);
3455 let occs = s.concept_occurrences(&concept).unwrap();
3456 assert_eq!(occs.len(), 1, "derived count drops to 1: {occs:?}");
3457 assert_eq!(
3458 occs[0].attributes.get("path").and_then(|v| v.as_str()),
3459 Some("b.ts")
3460 );
3461 s.purge_path("b.ts").unwrap();
3463 assert!(s.get_entity(&concept).unwrap().is_none(), "concept gone");
3464 assert!(s.all_relationships().unwrap().is_empty(), "no dangling edges");
3465 s.insert_entity(
3467 &Entity::new("repo://r/store/db", "store", "db"),
3468 &["a.ts".into()],
3469 )
3470 .unwrap();
3471 s.purge_path("a.ts").unwrap();
3472 assert!(s.get_entity("repo://r/store/db").unwrap().is_none());
3473 }
3474
3475 #[test]
3476 fn cache_revision_scoped() {
3478 let (s, _d) = tmp_store();
3479 s.cache_put("k", "pack-v1", "rev1").unwrap();
3480 assert_eq!(s.cache_get("k", "rev1").unwrap(), Some("pack-v1".into()));
3481 assert_eq!(s.cache_get("k", "rev2").unwrap(), None);
3482 }
3483
3484 #[test]
3485 fn fts_escapes_punctuation() {
3487 let (s, _d) = tmp_store();
3488 let mut e = Entity::new("repo://r/route/api", "route", "GET /api/v1/items");
3489 e.attr("path", serde_json::json!("/api/v1/items"));
3490 s.insert_entity(&e, &["app.py".into()]).unwrap();
3491 let hits = s.search_entities("GET /api/v1/items", 10).unwrap();
3492 assert_eq!(hits.len(), 1);
3493 }
3494}
3495
3496#[cfg(test)]
3497mod like_tests {
3499 use super::*;
3500 use crate::tests::tmp_store;
3501
3502 #[test]
3503 fn entities_like_matches_attributes() {
3505 let (s, _d) = tmp_store();
3506 let mut e = Entity::new("repo://r/symbol/a.py/f", "symbol", "transcribe");
3507 e.attr("docstring", serde_json::json!("External ASR client with retry and fallback."));
3508 s.insert_entity(&e, &["a.py".into()]).unwrap();
3509 let hits = s.search_entities_like("retry", 6).unwrap();
3510 assert_eq!(hits.len(), 1, "must match docstring attributes");
3511 let hits2 = s.search_symbols_like("retry", 6).unwrap();
3512 assert_eq!(hits2.len(), 0);
3513 }
3514
3515 #[test]
3516 fn symbols_like_matches_docstring() {
3518 let (s, _d) = tmp_store();
3519 s.insert_symbol("a.py", "transcribe", "function", None, 1, 2, true,
3520 Some("External ASR client with retry and fallback.")).unwrap();
3521 let hits = s.search_symbols_like("retry", 6).unwrap();
3522 assert_eq!(hits.len(), 1, "must match symbol docstrings");
3523 assert_eq!(hits[0].0, "transcribe");
3524 }
3525}