1mod access_lock;
7mod backup_restore;
8mod file_permissions;
9mod schema_contract;
10
11use std::collections::BTreeSet;
12use std::fs::{self, OpenOptions};
13use std::io::Write;
14use std::path::{Path, PathBuf};
15use std::time::{Duration, SystemTime, UNIX_EPOCH};
16
17use access_lock::StoreAccessLock;
18use backup_restore::{backup_connection, restore_database};
19use code_system_graph_model::{
20 ArtifactFingerprint, CheckoutId, Community, CommunityAlgorithm, CommunityConfig, CommunityId, CommunityMetrics, CommunitySnapshot, Edge, EdgeId, EdgeKind, EpistemicStatus, Evidence, EvidenceId, ExtractorRun, LinkDecision, LinkStatus, NativePath, Node, NodeId, NodeKind, RepoFreshness, RepoFreshnessState, RepoId, RepositoryRecord, StoredExtractorBatch, WorkspaceId, WorkspaceRecord, contains_unsafe_metadata_characters, stable_id
21};
22pub use file_permissions::set_owner_only_file;
23use file_permissions::{
24 SQLITE_ARTIFACT_SUFFIXES, artifact_path, prepare_database_file, restrict_store_permissions
25};
26use rusqlite::{Connection, OpenFlags, OptionalExtension, params};
27use schema_contract::validate_exact_schema;
28use sysinfo::{Pid, ProcessesToUpdate, System};
29use thiserror::Error;
30
31const INITIAL_SCHEMA: &str = include_str!("../migrations/0001_initial.sql");
32const LATEST_SCHEMA_VERSION: i64 = 1;
33
34#[must_use]
36pub const fn latest_schema_version() -> i64 {
37 LATEST_SCHEMA_VERSION
38}
39
40#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct StoreDiagnostics {
43 pub journal_mode: String,
45 pub foreign_keys_enabled: bool,
47 pub fts5_index_available: bool,
49}
50const COMMUNITY_ALGORITHM_JSON_MAX_BYTES: usize = 256;
51const COMMUNITY_CONFIG_JSON_MAX_BYTES: usize = 65_536;
52const COMMUNITY_METRICS_JSON_MAX_BYTES: usize = 65_536;
53const COMMUNITY_EXPLANATION_JSON_MAX_BYTES: usize = 1_048_576;
54const NODE_SEARCH_QUERY_MAX_BYTES: usize = 1_024;
55const MANUAL_LINK_ID_MAX_BYTES: usize = 2_048;
56const MANUAL_LINK_KIND_MAX_BYTES: usize = 256;
57const MANUAL_LINK_REASON_MAX_BYTES: usize = 4_096;
58const MANUAL_LINK_DECISION_JSON_MAX_BYTES: usize = 262_144;
59const PROVIDER_COMPONENT_MAX_BYTES: usize = 256;
60const PROVIDER_CAPABILITIES_JSON_MAX_BYTES: usize = 65_536;
61const PROVIDER_CAPABILITY_COUNT_MAX: usize = 256;
62const QUERY_CACHE_FINGERPRINT_MAX_BYTES: usize = 256;
63const QUERY_CACHE_RESULT_MAX_BYTES: usize = 1_048_576;
64
65pub struct SqliteStore {
67 connection: Connection,
68 _access_lock: Option<StoreAccessLock>,
69}
70
71#[derive(Debug)]
73pub struct StoreLock {
74 path: PathBuf,
75 content: String,
76}
77
78#[derive(Debug, Clone, PartialEq, Eq)]
79struct LockMetadata {
80 pid: u32,
81 process_started_at: u64,
82 token: String,
83}
84
85struct StoredWorkspaceIdentity {
86 id: String,
87 manifest_hash: String,
88 config_path: Option<NativePath>,
89}
90
91struct StoredRepositoryRow {
92 alias: String,
93 repo_id: String,
94 checkout_id: String,
95 path_encoding: String,
96 path_bytes: Vec<u8>,
97 path_display: String,
98 common_encoding: Option<String>,
99 common_bytes: Option<Vec<u8>>,
100 common_display: Option<String>,
101 normalized_remote: Option<String>,
102 head_commit: Option<String>,
103 is_linked_worktree: bool,
104 working_tree_dirty: bool,
105}
106
107#[derive(Debug, Clone, PartialEq, Eq)]
109pub struct StoredSnapshotSummary {
110 pub snapshot_id: String,
112 pub node_count: usize,
114 pub edge_count: usize,
116 pub evidence_count: usize,
118}
119
120#[derive(Debug, Clone, PartialEq)]
122pub struct StoredNodeSearchHit {
123 pub node: Node,
125 pub fts_rank: f64,
127}
128
129#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131pub enum ManualLinkDisposition {
132 Active,
134 Suppression,
136}
137
138impl ManualLinkDisposition {
139 fn as_str(self) -> &'static str {
140 match self {
141 Self::Active => "active",
142 Self::Suppression => "suppression",
143 }
144 }
145
146 fn from_stored(value: &str) -> Option<Self> {
147 match value {
148 "active" => Some(Self::Active),
149 "suppression" => Some(Self::Suppression),
150 _ => None,
151 }
152 }
153}
154
155#[derive(Debug, Clone, PartialEq)]
157pub struct ManualLinkRecord {
158 pub id: String,
160 pub snapshot_id: String,
162 pub source_node_id: NodeId,
164 pub target_node_id: NodeId,
166 pub kind: String,
168 pub disposition: ManualLinkDisposition,
170 pub reason: String,
172 pub decision: LinkDecision,
174 pub config_version: u32,
176}
177
178#[derive(Debug, Clone, PartialEq, Eq)]
180pub struct ProviderCapabilityRecord {
181 pub workspace_name: String,
183 pub repo_id: RepoId,
185 pub provider: String,
187 pub provider_version: String,
189 pub capabilities: Vec<String>,
191 pub observed_at_unix_ms: u64,
193}
194
195#[derive(Debug, Clone, PartialEq, Eq)]
197pub struct QueryCacheRecord {
198 pub workspace_name: String,
200 pub snapshot_id: String,
202 pub input_fingerprint: String,
204 pub result_summary_json: Vec<u8>,
206 pub stored_at_unix_ms: u64,
208 pub expires_at_unix_ms: Option<u64>,
210}
211
212#[derive(Debug, Clone, Copy)]
214pub struct SnapshotBatch<'a> {
215 pub workspace: &'a WorkspaceRecord,
217 pub snapshot_id: &'a str,
219 pub nodes: &'a [Node],
221 pub edges: &'a [Edge],
223 pub evidence: &'a [Evidence],
225 pub fingerprints: &'a [ArtifactFingerprint],
227 pub extractor_batches: &'a [StoredExtractorBatch],
229 pub extractor_runs: &'a [ExtractorRun],
231 pub manual_links: &'a [ManualLinkRecord],
233 pub community_snapshot: Option<&'a CommunitySnapshot>,
235}
236
237#[derive(Debug, Clone, PartialEq, Eq)]
239pub struct RestoreReport {
240 pub source_path: PathBuf,
242 pub safety_backup_path: Option<PathBuf>,
244 pub schema_version: i64,
246}
247
248#[derive(Debug, Clone, PartialEq, Eq)]
250pub struct WorkspaceRegistrySummary {
251 pub id: WorkspaceId,
253 pub name: String,
255 pub manifest_hash: String,
257 pub config_path: Option<NativePath>,
259 pub repository_count: usize,
261}
262
263#[derive(Debug, Error)]
265pub enum StoreError {
266 #[error("SQLite operation failed: {0}")]
268 Sqlite(#[from] rusqlite::Error),
269 #[error("stored domain value is invalid: {0}")]
271 Serialization(#[from] serde_json::Error),
272 #[error("workspace `{0}` has no current snapshot")]
274 CurrentSnapshotMissing(String),
275 #[error("workspace registry `{0}` is missing required identity data")]
277 RegistryIncomplete(String),
278 #[error("filesystem operation failed for `{path}`: {source}")]
280 Io {
281 path: PathBuf,
283 source: std::io::Error,
285 },
286 #[error("database does not match the current schema")]
288 InvalidSchema,
289 #[error("store writer lock is already held at `{0}`")]
291 LockHeld(PathBuf),
292 #[error("stored integer `{field}` is outside the supported range: {value}")]
294 IntegerOutOfRange {
295 field: &'static str,
297 value: i128,
299 },
300 #[error("invalid backup `{path}`: {reason}")]
302 InvalidBackup {
303 path: PathBuf,
305 reason: String,
307 },
308 #[error("operation failed ({operation}); cleanup also failed ({cleanup})")]
310 OperationCleanupFailed {
311 operation: Box<StoreError>,
313 cleanup: Box<StoreError>,
315 },
316 #[error("invalid node search query: {0}")]
318 InvalidSearchQuery(String),
319 #[error("graph snapshot is invalid: {0}")]
321 InvalidGraphSnapshot(String),
322 #[error("graph snapshot `{0}` does not exist")]
324 SnapshotMissing(String),
325 #[error("graph snapshot `{0}` has no community analysis")]
327 CommunitySnapshotMissing(String),
328 #[error(
330 "community snapshot `{community_snapshot_id}` does not match graph snapshot \
331 `{batch_snapshot_id}`"
332 )]
333 CommunitySnapshotMismatch {
334 batch_snapshot_id: String,
336 community_snapshot_id: String,
338 },
339 #[error("community `{community_id}` is duplicated in snapshot `{snapshot_id}`")]
341 DuplicateCommunityId {
342 snapshot_id: String,
344 community_id: String,
346 },
347 #[error(
349 "node `{node_id}` is duplicated in community `{community_id}` for snapshot `{snapshot_id}`"
350 )]
351 DuplicateCommunityMembership {
352 snapshot_id: String,
354 community_id: String,
356 node_id: String,
358 },
359 #[error(
361 "community `{community_id}` references missing node `{node_id}` in snapshot `{snapshot_id}`"
362 )]
363 CommunityMembershipNodeMissing {
364 snapshot_id: String,
366 community_id: String,
368 node_id: String,
370 },
371 #[error("community JSON field `{field}` is {actual_bytes} bytes; maximum is {max_bytes}")]
373 CommunityJsonTooLarge {
374 field: &'static str,
376 actual_bytes: usize,
378 max_bytes: usize,
380 },
381 #[error("stored {entity} is malformed: {reason}")]
383 MalformedStoredData {
384 entity: String,
386 reason: String,
388 },
389 #[error("invalid persistence record: {0}")]
391 InvalidPersistenceRecord(String),
392}
393
394impl StoreLock {
395 pub fn acquire(database_path: &Path, stale_after: Duration) -> Result<Self, StoreError> {
406 let path = lock_path(database_path);
407 let now = SystemTime::now()
408 .duration_since(UNIX_EPOCH)
409 .map_err(|source| StoreError::Io {
410 path: path.clone(),
411 source: std::io::Error::other(source),
412 })?;
413 let token = stable_id(
414 "lock",
415 &format!(
416 "{}:{}:{}",
417 std::process::id(),
418 now.as_secs(),
419 now.subsec_nanos()
420 ),
421 );
422 let content = LockMetadata {
423 pid: std::process::id(),
424 process_started_at: process_start_time(std::process::id()).unwrap_or_default(),
425 token,
426 }
427 .encode();
428 for attempt in 0..2 {
429 match create_lock_file(&path) {
430 Ok(mut file) => {
431 file.write_all(content.as_bytes())
432 .and_then(|()| file.sync_all())
433 .map_err(|source| StoreError::Io {
434 path: path.clone(),
435 source,
436 })?;
437 return Ok(Self { path, content });
438 }
439 Err(source) if source.kind() == std::io::ErrorKind::AlreadyExists => {
440 if attempt == 0 && lock_is_stale(&path, stale_after)? {
441 fs::remove_file(&path).map_err(|source| StoreError::Io {
442 path: path.clone(),
443 source,
444 })?;
445 continue;
446 }
447 return Err(StoreError::LockHeld(path));
448 }
449 Err(source) => {
450 return Err(StoreError::Io {
451 path: path.clone(),
452 source,
453 });
454 }
455 }
456 }
457 Err(StoreError::LockHeld(path))
458 }
459}
460
461impl Drop for StoreLock {
462 fn drop(&mut self) {
463 let owned = fs::read_to_string(&self.path).is_ok_and(|content| content == self.content);
464 if owned {
465 let _result = fs::remove_file(&self.path);
466 }
467 }
468}
469
470impl SqliteStore {
471 #[must_use]
473 pub const fn supports_inplace_restore() -> bool {
474 cfg!(any(target_os = "linux", target_os = "macos", windows))
475 }
476
477 pub fn open(path: impl AsRef<Path>) -> Result<Self, StoreError> {
483 let path = path.as_ref();
484 let access_lock = StoreAccessLock::shared(path)?;
485 let connection = open_exact(access_lock.database_path())?;
486 Ok(Self {
487 connection,
488 _access_lock: Some(access_lock),
489 })
490 }
491
492 pub fn backup_file(database_path: &Path, destination: &Path) -> Result<(), StoreError> {
500 let access_lock = StoreAccessLock::shared(database_path)?;
501 backup_restore::backup_file(access_lock.database_path(), destination)
502 }
503
504 pub fn restore_from(
514 database_path: &Path,
515 backup_path: &Path,
516 ) -> Result<RestoreReport, StoreError> {
517 if database_path.exists() && !Self::supports_inplace_restore() {
518 return Err(StoreError::Io {
519 path: database_path.to_path_buf(),
520 source: std::io::Error::new(
521 std::io::ErrorKind::Unsupported,
522 "safe in-place restore is unsupported on this platform; \
523 back up the database, remove it, then restore or reinitialize",
524 ),
525 });
526 }
527 let access_lock = StoreAccessLock::exclusive(database_path)?;
528 restore_database(
529 access_lock.database_path(),
530 backup_path,
531 || {},
532 |_| {},
533 |_| Ok(()),
534 || Ok(()),
535 )
536 }
537
538 pub fn open_read_only(path: impl AsRef<Path>) -> Result<Self, StoreError> {
546 let path = path.as_ref();
547 let access_lock = StoreAccessLock::shared(path)?;
548 let connection = open_read_only_connection(access_lock.database_path(), |connection| {
549 connection.pragma_update(None, "foreign_keys", true)?;
550 connection.busy_timeout(Duration::from_secs(5))?;
551 validate_exact_schema(connection)
552 })?;
553 Ok(Self {
554 connection,
555 _access_lock: Some(access_lock),
556 })
557 }
558
559 pub fn in_memory() -> Result<Self, StoreError> {
565 Self::from_connection(Connection::open_in_memory()?)
566 }
567
568 fn from_connection(mut connection: Connection) -> Result<Self, StoreError> {
569 configure_connection(&connection)?;
570 if database_is_empty(&connection)? {
571 initialize_empty_schema(&mut connection)?;
572 }
573 validate_exact_schema(&connection)?;
574 Ok(Self {
575 connection,
576 _access_lock: None,
577 })
578 }
579
580 pub fn schema_version(&self) -> Result<i64, StoreError> {
586 schema_version(&self.connection)
587 }
588
589 pub fn database_instance_id(&self) -> Result<String, StoreError> {
595 database_instance_id(&self.connection)
596 }
597
598 pub fn save_workspace_registry(
604 &mut self,
605 workspace: &WorkspaceRecord,
606 ) -> Result<(), StoreError> {
607 let transaction = self.connection.transaction()?;
608 upsert_registry(&transaction, workspace)?;
609 transaction.commit()?;
610 Ok(())
611 }
612
613 pub fn workspace_exists(&self, workspace: &str) -> Result<bool, StoreError> {
619 Ok(self.connection.query_row(
620 "SELECT EXISTS(SELECT 1 FROM workspaces WHERE name = ?1)",
621 [workspace],
622 |row| row.get(0),
623 )?)
624 }
625
626 pub fn remove_workspace(&mut self, workspace: &str) -> Result<bool, StoreError> {
632 let transaction = self.connection.transaction()?;
633 let removed = transaction.execute("DELETE FROM workspaces WHERE name = ?1", [workspace])?;
634 transaction.execute(
635 "DELETE FROM repository_checkouts
636 WHERE NOT EXISTS (
637 SELECT 1 FROM workspace_repositories wr
638 WHERE wr.checkout_id = repository_checkouts.id
639 )",
640 [],
641 )?;
642 transaction.execute(
643 "DELETE FROM repositories
644 WHERE NOT EXISTS (
645 SELECT 1 FROM workspace_repositories wr
646 WHERE wr.repo_id = repositories.id
647 )",
648 [],
649 )?;
650 transaction.commit()?;
651 Ok(removed > 0)
652 }
653
654 pub fn list_workspaces(&self) -> Result<Vec<WorkspaceRegistrySummary>, StoreError> {
660 let mut statement = self.connection.prepare(
661 "SELECT
662 w.id, w.name, w.manifest_hash, w.config_path_encoding, w.config_path,
663 w.config_path_display, COUNT(wr.alias)
664 FROM workspaces w
665 LEFT JOIN workspace_repositories wr ON wr.workspace_name = w.name
666 WHERE w.id IS NOT NULL
667 GROUP BY
668 w.id, w.name, w.manifest_hash, w.config_path_encoding, w.config_path,
669 w.config_path_display
670 ORDER BY w.name",
671 )?;
672 let rows = statement
673 .query_map([], |row| {
674 Ok((
675 row.get::<_, String>(0)?,
676 row.get::<_, String>(1)?,
677 row.get::<_, String>(2)?,
678 row.get::<_, Option<String>>(3)?,
679 row.get::<_, Option<Vec<u8>>>(4)?,
680 row.get::<_, Option<String>>(5)?,
681 row.get::<_, i64>(6)?,
682 ))
683 })?
684 .collect::<Result<Vec<_>, _>>()?;
685 rows.into_iter()
686 .map(
687 |(id, name, manifest_hash, encoding, bytes, display, repository_count)| {
688 let config_path = match (encoding, bytes, display) {
689 (Some(encoding), Some(bytes), Some(display)) => Some(NativePath {
690 encoding: serde_json::from_str(&encoding)?,
691 bytes,
692 display,
693 }),
694 _ => None,
695 };
696 Ok(WorkspaceRegistrySummary {
697 id: WorkspaceId::new(id),
698 name,
699 manifest_hash,
700 config_path,
701 repository_count: count_to_usize(
702 "workspace_repositories",
703 repository_count,
704 )?,
705 })
706 },
707 )
708 .collect()
709 }
710
711 pub fn load_workspace_registry(&self, workspace: &str) -> Result<WorkspaceRecord, StoreError> {
718 let identity = load_workspace_identity(&self.connection, workspace)?;
719 let repositories = load_workspace_repositories(&self.connection, workspace)?;
720 Ok(WorkspaceRecord {
721 id: WorkspaceId::new(identity.id),
722 name: workspace.to_owned(),
723 manifest_hash: identity.manifest_hash,
724 config_path: identity.config_path,
725 repositories,
726 })
727 }
728
729 pub fn backup_to(&self, destination: &Path) -> Result<(), StoreError> {
735 backup_connection(&self.connection, destination)
736 }
737
738 pub fn load_current_freshness(
744 &self,
745 workspace: &str,
746 ) -> Result<Vec<RepoFreshness>, StoreError> {
747 let snapshot_id = self.current_snapshot_id(workspace)?;
748 let mut statement = self.connection.prepare(
749 "SELECT repo_id, checkout_id, head_commit, manifest_hash, state, reason
750 FROM repository_snapshot_freshness
751 WHERE snapshot_id = ?1
752 ORDER BY repo_id, checkout_id",
753 )?;
754 let rows = statement
755 .query_map([snapshot_id], |row| {
756 Ok((
757 row.get::<_, String>(0)?,
758 row.get::<_, String>(1)?,
759 row.get::<_, Option<String>>(2)?,
760 row.get::<_, String>(3)?,
761 row.get::<_, String>(4)?,
762 row.get::<_, Option<String>>(5)?,
763 ))
764 })?
765 .collect::<Result<Vec<_>, _>>()?;
766 rows.into_iter()
767 .map(
768 |(repo_id, checkout_id, head_commit, manifest_hash, state, reason)| {
769 Ok(RepoFreshness {
770 repo_id: RepoId::new(repo_id),
771 checkout_id: CheckoutId::new(checkout_id),
772 head_commit,
773 manifest_hash,
774 state: serde_json::from_str::<RepoFreshnessState>(&state)?,
775 reason,
776 })
777 },
778 )
779 .collect()
780 }
781
782 pub fn load_current_artifact_fingerprints(
788 &self,
789 workspace: &str,
790 ) -> Result<Vec<ArtifactFingerprint>, StoreError> {
791 let snapshot_id = self.current_snapshot_id(workspace)?;
792 let mut statement = self.connection.prepare(
793 "SELECT
794 repo_id, checkout_id, path_encoding, relative_path, path_display,
795 extractor, content_hash, size_bytes
796 FROM artifact_fingerprints
797 WHERE snapshot_id = ?1
798 ORDER BY repo_id, checkout_id, path_encoding, relative_path, extractor",
799 )?;
800 let rows = statement
801 .query_map([snapshot_id], |row| {
802 Ok((
803 row.get::<_, String>(0)?,
804 row.get::<_, String>(1)?,
805 row.get::<_, String>(2)?,
806 row.get::<_, Vec<u8>>(3)?,
807 row.get::<_, String>(4)?,
808 row.get::<_, String>(5)?,
809 row.get::<_, String>(6)?,
810 row.get::<_, i64>(7)?,
811 ))
812 })?
813 .collect::<Result<Vec<_>, _>>()?;
814 rows.into_iter()
815 .map(
816 |(
817 repo_id,
818 checkout_id,
819 encoding,
820 bytes,
821 display,
822 extractor,
823 content_hash,
824 stored_size_bytes,
825 )| {
826 Ok(ArtifactFingerprint {
827 repo_id: RepoId::new(repo_id),
828 checkout_id: CheckoutId::new(checkout_id),
829 path: NativePath {
830 encoding: serde_json::from_str(&encoding)?,
831 bytes,
832 display,
833 },
834 extractor,
835 content_hash,
836 size_bytes: u64::try_from(stored_size_bytes).map_err(|_| {
837 StoreError::IntegerOutOfRange {
838 field: "artifact_fingerprints.size_bytes",
839 value: i128::from(stored_size_bytes),
840 }
841 })?,
842 })
843 },
844 )
845 .collect()
846 }
847
848 pub fn load_current_extractor_batches(
854 &self,
855 workspace: &str,
856 ) -> Result<Vec<StoredExtractorBatch>, StoreError> {
857 self.load_current_extractor_batches_with_limit(workspace, i64::MAX as u64)
858 }
859
860 pub fn load_current_extractor_batches_with_limit(
869 &self,
870 workspace: &str,
871 maximum_payload_bytes: u64,
872 ) -> Result<Vec<StoredExtractorBatch>, StoreError> {
873 let snapshot_id = self.current_snapshot_id(workspace)?;
874 let maximum_payload_bytes = i64::try_from(maximum_payload_bytes).unwrap_or(i64::MAX);
875 let mut statement = self.connection.prepare(
876 "SELECT
877 repo_id, checkout_id, path_encoding, relative_path, path_display,
878 extractor, content_hash, size_bytes, extractor_version, budget_fingerprint,
879 source_was_lossy, output_count, payload
880 FROM extractor_batches
881 WHERE snapshot_id = ?1 AND length(payload) <= ?2
882 ORDER BY repo_id, checkout_id, path_encoding, relative_path, extractor",
883 )?;
884 let rows = statement
885 .query_map(params![snapshot_id, maximum_payload_bytes], |row| {
886 Ok((
887 row.get::<_, String>(0)?,
888 row.get::<_, String>(1)?,
889 row.get::<_, String>(2)?,
890 row.get::<_, Vec<u8>>(3)?,
891 row.get::<_, String>(4)?,
892 row.get::<_, String>(5)?,
893 row.get::<_, String>(6)?,
894 row.get::<_, i64>(7)?,
895 row.get::<_, String>(8)?,
896 row.get::<_, String>(9)?,
897 row.get::<_, bool>(10)?,
898 row.get::<_, i64>(11)?,
899 row.get::<_, Vec<u8>>(12)?,
900 ))
901 })?
902 .collect::<Result<Vec<_>, _>>()?;
903 rows.into_iter()
904 .map(
905 |(
906 repo_id,
907 checkout_id,
908 encoding,
909 bytes,
910 display,
911 extractor,
912 content_hash,
913 stored_size_bytes,
914 extractor_version,
915 budget_fingerprint,
916 source_was_lossy,
917 stored_output_count,
918 payload,
919 )| {
920 Ok(StoredExtractorBatch {
921 source: ArtifactFingerprint {
922 repo_id: RepoId::new(repo_id),
923 checkout_id: CheckoutId::new(checkout_id),
924 path: NativePath {
925 encoding: serde_json::from_str(&encoding)?,
926 bytes,
927 display,
928 },
929 extractor,
930 content_hash,
931 size_bytes: stored_metric_to_u64(
932 "extractor_batches.size_bytes",
933 stored_size_bytes,
934 )?,
935 },
936 extractor_version,
937 budget_fingerprint,
938 source_was_lossy,
939 output_count: stored_metric_to_u64(
940 "extractor_batches.output_count",
941 stored_output_count,
942 )?,
943 payload,
944 })
945 },
946 )
947 .collect()
948 }
949
950 pub fn load_current_extractor_runs(
956 &self,
957 workspace: &str,
958 ) -> Result<Vec<ExtractorRun>, StoreError> {
959 let snapshot_id = self.current_snapshot_id(workspace)?;
960 let mut statement = self.connection.prepare(
961 "SELECT
962 id, repo_id, checkout_id, extractor, extractor_version, status,
963 discovered_files, parsed_files, skipped_files, elapsed_ms
964 FROM extractor_runs
965 WHERE snapshot_id = ?1
966 ORDER BY repo_id, checkout_id, extractor, id",
967 )?;
968 let rows = statement
969 .query_map([&snapshot_id], |row| {
970 Ok((
971 row.get::<_, String>(0)?,
972 row.get::<_, String>(1)?,
973 row.get::<_, String>(2)?,
974 row.get::<_, String>(3)?,
975 row.get::<_, String>(4)?,
976 row.get::<_, String>(5)?,
977 row.get::<_, i64>(6)?,
978 row.get::<_, i64>(7)?,
979 row.get::<_, i64>(8)?,
980 row.get::<_, i64>(9)?,
981 ))
982 })?
983 .collect::<Result<Vec<_>, _>>()?;
984 rows.into_iter()
985 .map(
986 |(
987 id,
988 repo_id,
989 checkout_id,
990 extractor,
991 extractor_version,
992 status,
993 discovered_files,
994 parsed_files,
995 skipped_files,
996 elapsed_ms,
997 )| {
998 Ok(ExtractorRun {
999 id,
1000 snapshot_id: snapshot_id.clone(),
1001 repo_id: RepoId::new(repo_id),
1002 checkout_id: CheckoutId::new(checkout_id),
1003 extractor,
1004 extractor_version,
1005 status: serde_json::from_str(&status)?,
1006 discovered_files: stored_metric_to_u64(
1007 "extractor_runs.discovered_files",
1008 discovered_files,
1009 )?,
1010 parsed_files: stored_metric_to_u64(
1011 "extractor_runs.parsed_files",
1012 parsed_files,
1013 )?,
1014 skipped_files: stored_metric_to_u64(
1015 "extractor_runs.skipped_files",
1016 skipped_files,
1017 )?,
1018 elapsed_ms: stored_metric_to_u64("extractor_runs.elapsed_ms", elapsed_ms)?,
1019 })
1020 },
1021 )
1022 .collect()
1023 }
1024
1025 pub fn persist_manual_links(
1035 &mut self,
1036 snapshot_id: &str,
1037 records: &[ManualLinkRecord],
1038 ) -> Result<(), StoreError> {
1039 validate_manual_links(snapshot_id, records, None)?;
1040 self.require_snapshot(snapshot_id)?;
1041 let transaction = self.connection.transaction()?;
1042 for record in records {
1043 for (field, node_id) in [
1044 ("source", record.source_node_id.as_str()),
1045 ("target", record.target_node_id.as_str()),
1046 ] {
1047 let exists = transaction.query_row(
1048 "SELECT EXISTS(
1049 SELECT 1 FROM nodes WHERE snapshot_id = ?1 AND id = ?2
1050 )",
1051 params![snapshot_id, node_id],
1052 |row| row.get::<_, bool>(0),
1053 )?;
1054 if !exists {
1055 return Err(StoreError::InvalidPersistenceRecord(format!(
1056 "manual link `{}` {field} node `{node_id}` is absent from snapshot \
1057 `{snapshot_id}`",
1058 record.id
1059 )));
1060 }
1061 }
1062 }
1063 transaction.execute(
1064 "DELETE FROM manual_links WHERE snapshot_id = ?1",
1065 [snapshot_id],
1066 )?;
1067 insert_manual_links(&transaction, records)?;
1068 transaction.commit()?;
1069 Ok(())
1070 }
1071
1072 pub fn load_manual_links(
1079 &self,
1080 snapshot_id: &str,
1081 ) -> Result<Vec<ManualLinkRecord>, StoreError> {
1082 self.require_snapshot(snapshot_id)?;
1083 let mut statement = self.connection.prepare(
1084 "SELECT id, source_node_id, target_node_id, kind, disposition, reason, decision_json,
1085 config_version
1086 FROM manual_links
1087 WHERE snapshot_id = ?1
1088 ORDER BY id",
1089 )?;
1090 let rows = statement
1091 .query_map([snapshot_id], |row| {
1092 Ok((
1093 row.get::<_, String>(0)?,
1094 row.get::<_, String>(1)?,
1095 row.get::<_, String>(2)?,
1096 row.get::<_, String>(3)?,
1097 row.get::<_, String>(4)?,
1098 row.get::<_, String>(5)?,
1099 row.get::<_, Vec<u8>>(6)?,
1100 row.get::<_, i64>(7)?,
1101 ))
1102 })?
1103 .collect::<Result<Vec<_>, _>>()?;
1104 let records = rows
1105 .into_iter()
1106 .map(
1107 |(id, source, target, kind, disposition, reason, decision_json, config_version)| {
1108 let disposition =
1109 ManualLinkDisposition::from_stored(&disposition).ok_or_else(|| {
1110 malformed_stored_data(
1111 format!("manual link `{id}` in snapshot `{snapshot_id}`"),
1112 format!("unknown disposition `{disposition}`"),
1113 )
1114 })?;
1115 let config_version = u32::try_from(config_version).map_err(|_| {
1116 StoreError::IntegerOutOfRange {
1117 field: "manual_links.config_version",
1118 value: i128::from(config_version),
1119 }
1120 })?;
1121 let decision = serde_json::from_slice(&decision_json).map_err(|error| {
1122 malformed_stored_data(
1123 format!("manual link `{id}` decision in snapshot `{snapshot_id}`"),
1124 error.to_string(),
1125 )
1126 })?;
1127 Ok(ManualLinkRecord {
1128 id,
1129 snapshot_id: snapshot_id.to_owned(),
1130 source_node_id: NodeId::new(source),
1131 target_node_id: NodeId::new(target),
1132 kind,
1133 disposition,
1134 reason,
1135 decision,
1136 config_version,
1137 })
1138 },
1139 )
1140 .collect::<Result<Vec<_>, StoreError>>()?;
1141 validate_manual_links(snapshot_id, &records, None).map_err(|error| {
1142 malformed_stored_data(
1143 format!("manual links in snapshot `{snapshot_id}`"),
1144 error.to_string(),
1145 )
1146 })?;
1147 Ok(records)
1148 }
1149
1150 pub fn upsert_provider_capabilities(
1160 &mut self,
1161 record: &ProviderCapabilityRecord,
1162 ) -> Result<(), StoreError> {
1163 validate_provider_capability_record(record)?;
1164 let registered = self.connection.query_row(
1165 "SELECT EXISTS(
1166 SELECT 1 FROM workspace_repositories
1167 WHERE workspace_name = ?1 AND repo_id = ?2
1168 )",
1169 params![record.workspace_name, record.repo_id.as_str()],
1170 |row| row.get::<_, bool>(0),
1171 )?;
1172 if !registered {
1173 return Err(StoreError::InvalidPersistenceRecord(format!(
1174 "repository `{}` is not registered in workspace `{}`",
1175 record.repo_id.as_str(),
1176 record.workspace_name
1177 )));
1178 }
1179 let capabilities_json = serde_json::to_string(&record.capabilities)?;
1180 self.connection.execute(
1181 "INSERT INTO provider_capabilities(
1182 workspace_name, repo_id, provider, provider_version, capabilities_json,
1183 observed_at_unix_ms
1184 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
1185 ON CONFLICT(workspace_name, repo_id, provider, provider_version) DO UPDATE SET
1186 capabilities_json = excluded.capabilities_json,
1187 observed_at_unix_ms = excluded.observed_at_unix_ms",
1188 params![
1189 record.workspace_name,
1190 record.repo_id.as_str(),
1191 record.provider,
1192 record.provider_version,
1193 capabilities_json,
1194 metric_to_i64(
1195 "provider_capabilities.observed_at_unix_ms",
1196 record.observed_at_unix_ms
1197 )?,
1198 ],
1199 )?;
1200 Ok(())
1201 }
1202
1203 pub fn load_provider_capabilities(
1210 &self,
1211 workspace: &str,
1212 repo_id: &RepoId,
1213 provider: &str,
1214 provider_version: &str,
1215 ) -> Result<Option<ProviderCapabilityRecord>, StoreError> {
1216 validate_safe_metadata("workspace name", workspace, 1, MANUAL_LINK_ID_MAX_BYTES)?;
1217 validate_safe_metadata(
1218 "repository identifier",
1219 repo_id.as_str(),
1220 1,
1221 MANUAL_LINK_ID_MAX_BYTES,
1222 )?;
1223 validate_safe_metadata("provider name", provider, 1, PROVIDER_COMPONENT_MAX_BYTES)?;
1224 validate_safe_metadata(
1225 "provider version",
1226 provider_version,
1227 1,
1228 PROVIDER_COMPONENT_MAX_BYTES,
1229 )?;
1230 let stored = self
1231 .connection
1232 .query_row(
1233 "SELECT capabilities_json, observed_at_unix_ms
1234 FROM provider_capabilities
1235 WHERE workspace_name = ?1
1236 AND repo_id = ?2
1237 AND provider = ?3
1238 AND provider_version = ?4",
1239 params![workspace, repo_id.as_str(), provider, provider_version],
1240 |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)),
1241 )
1242 .optional()?;
1243 let Some((capabilities_json, observed_at_unix_ms)) = stored else {
1244 return Ok(None);
1245 };
1246 let capabilities = serde_json::from_str(&capabilities_json).map_err(|error| {
1247 malformed_stored_data(
1248 format!(
1249 "provider capabilities `{workspace}/{}/{provider}/{provider_version}`",
1250 repo_id.as_str()
1251 ),
1252 error.to_string(),
1253 )
1254 })?;
1255 let record = ProviderCapabilityRecord {
1256 workspace_name: workspace.to_owned(),
1257 repo_id: repo_id.clone(),
1258 provider: provider.to_owned(),
1259 provider_version: provider_version.to_owned(),
1260 capabilities,
1261 observed_at_unix_ms: stored_metric_to_u64(
1262 "provider_capabilities.observed_at_unix_ms",
1263 observed_at_unix_ms,
1264 )?,
1265 };
1266 validate_provider_capability_record(&record).map_err(|error| {
1267 malformed_stored_data("provider capability record", error.to_string())
1268 })?;
1269 Ok(Some(record))
1270 }
1271
1272 pub fn put_query_cache(&mut self, record: &QueryCacheRecord) -> Result<(), StoreError> {
1279 validate_query_cache_record(record)?;
1280 let snapshot_exists = self.connection.query_row(
1281 "SELECT EXISTS(
1282 SELECT 1 FROM repo_snapshots
1283 WHERE id = ?1 AND workspace_name = ?2
1284 )",
1285 params![record.snapshot_id, record.workspace_name],
1286 |row| row.get::<_, bool>(0),
1287 )?;
1288 if !snapshot_exists {
1289 return Err(StoreError::InvalidPersistenceRecord(format!(
1290 "query cache snapshot `{}` is absent from workspace `{}`",
1291 record.snapshot_id, record.workspace_name
1292 )));
1293 }
1294 self.connection.execute(
1295 "INSERT INTO query_cache(
1296 workspace_name, snapshot_id, input_fingerprint, result_summary_json,
1297 stored_at_unix_ms, expires_at_unix_ms
1298 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
1299 ON CONFLICT(workspace_name, snapshot_id, input_fingerprint) DO UPDATE SET
1300 result_summary_json = excluded.result_summary_json,
1301 stored_at_unix_ms = excluded.stored_at_unix_ms,
1302 expires_at_unix_ms = excluded.expires_at_unix_ms",
1303 params![
1304 record.workspace_name,
1305 record.snapshot_id,
1306 record.input_fingerprint,
1307 record.result_summary_json,
1308 metric_to_i64("query_cache.stored_at_unix_ms", record.stored_at_unix_ms)?,
1309 record
1310 .expires_at_unix_ms
1311 .map(|value| metric_to_i64("query_cache.expires_at_unix_ms", value))
1312 .transpose()?,
1313 ],
1314 )?;
1315 Ok(())
1316 }
1317
1318 pub fn load_query_cache(
1324 &self,
1325 workspace: &str,
1326 snapshot_id: &str,
1327 input_fingerprint: &str,
1328 now_unix_ms: u64,
1329 ) -> Result<Option<QueryCacheRecord>, StoreError> {
1330 validate_safe_metadata("workspace name", workspace, 1, MANUAL_LINK_ID_MAX_BYTES)?;
1331 validate_safe_metadata(
1332 "snapshot identifier",
1333 snapshot_id,
1334 1,
1335 MANUAL_LINK_ID_MAX_BYTES,
1336 )?;
1337 validate_safe_metadata(
1338 "query cache input fingerprint",
1339 input_fingerprint,
1340 1,
1341 QUERY_CACHE_FINGERPRINT_MAX_BYTES,
1342 )?;
1343 let stored = self
1344 .connection
1345 .query_row(
1346 "SELECT result_summary_json, stored_at_unix_ms, expires_at_unix_ms
1347 FROM query_cache
1348 WHERE workspace_name = ?1
1349 AND snapshot_id = ?2
1350 AND input_fingerprint = ?3
1351 AND (expires_at_unix_ms IS NULL OR expires_at_unix_ms >= ?4)",
1352 params![
1353 workspace,
1354 snapshot_id,
1355 input_fingerprint,
1356 metric_to_i64("query_cache.now_unix_ms", now_unix_ms)?
1357 ],
1358 |row| {
1359 Ok((
1360 row.get::<_, Vec<u8>>(0)?,
1361 row.get::<_, i64>(1)?,
1362 row.get::<_, Option<i64>>(2)?,
1363 ))
1364 },
1365 )
1366 .optional()?;
1367 let Some((result_summary_json, stored_at_unix_ms, expires_at_unix_ms)) = stored else {
1368 return Ok(None);
1369 };
1370 let record = QueryCacheRecord {
1371 workspace_name: workspace.to_owned(),
1372 snapshot_id: snapshot_id.to_owned(),
1373 input_fingerprint: input_fingerprint.to_owned(),
1374 result_summary_json,
1375 stored_at_unix_ms: stored_metric_to_u64(
1376 "query_cache.stored_at_unix_ms",
1377 stored_at_unix_ms,
1378 )?,
1379 expires_at_unix_ms: expires_at_unix_ms
1380 .map(|value| stored_metric_to_u64("query_cache.expires_at_unix_ms", value))
1381 .transpose()?,
1382 };
1383 validate_query_cache_record(&record)
1384 .map_err(|error| malformed_stored_data("query cache record", error.to_string()))?;
1385 Ok(Some(record))
1386 }
1387
1388 pub fn clear_query_cache(&mut self, workspace: &str) -> Result<usize, StoreError> {
1394 Ok(self.connection.execute(
1395 "DELETE FROM query_cache WHERE workspace_name = ?1",
1396 [workspace],
1397 )?)
1398 }
1399
1400 pub fn current_snapshot_summary(
1406 &self,
1407 workspace: &str,
1408 ) -> Result<StoredSnapshotSummary, StoreError> {
1409 let snapshot_id = self.current_snapshot_id(workspace)?;
1410 let (node_count, edge_count, evidence_count) = self.connection.query_row(
1411 "SELECT
1412 (SELECT COUNT(*) FROM nodes WHERE snapshot_id = ?1),
1413 (SELECT COUNT(*) FROM edges WHERE snapshot_id = ?1),
1414 (SELECT COUNT(*) FROM evidence WHERE snapshot_id = ?1)",
1415 [&snapshot_id],
1416 |row| {
1417 Ok((
1418 row.get::<_, i64>(0)?,
1419 row.get::<_, i64>(1)?,
1420 row.get::<_, i64>(2)?,
1421 ))
1422 },
1423 )?;
1424 Ok(StoredSnapshotSummary {
1425 snapshot_id,
1426 node_count: count_to_usize("nodes", node_count)?,
1427 edge_count: count_to_usize("edges", edge_count)?,
1428 evidence_count: count_to_usize("evidence", evidence_count)?,
1429 })
1430 }
1431
1432 pub fn publish_snapshot(&mut self, batch: SnapshotBatch<'_>) -> Result<(), StoreError> {
1442 self.publish_snapshot_with_progress(batch, |_| {})
1443 }
1444
1445 pub fn publish_snapshot_with_progress<F>(
1451 &mut self,
1452 batch: SnapshotBatch<'_>,
1453 mut progress: F,
1454 ) -> Result<(), StoreError>
1455 where
1456 F: FnMut(u64),
1457 {
1458 let SnapshotBatch {
1459 workspace,
1460 snapshot_id,
1461 nodes,
1462 edges,
1463 evidence,
1464 fingerprints,
1465 extractor_batches,
1466 extractor_runs,
1467 manual_links,
1468 community_snapshot,
1469 } = batch;
1470 validate_graph_snapshot(nodes, edges, evidence)?;
1471 validate_artifact_fingerprints(fingerprints)?;
1472 validate_manual_links(snapshot_id, manual_links, Some(nodes))?;
1473 validate_community_snapshot(snapshot_id, nodes, community_snapshot)?;
1474 let transaction = self.connection.transaction()?;
1475 upsert_registry(&transaction, workspace)?;
1476 transaction.execute(
1477 "DELETE FROM query_cache WHERE workspace_name = ?1",
1478 [&workspace.name],
1479 )?;
1480 transaction.execute(
1481 "UPDATE repo_snapshots SET is_current = 0 WHERE workspace_name = ?1",
1482 [&workspace.name],
1483 )?;
1484 transaction.execute("DELETE FROM repo_snapshots WHERE id = ?1", [snapshot_id])?;
1485 transaction.execute(
1486 "INSERT INTO repo_snapshots(id, workspace_name, is_current) VALUES (?1, ?2, 0)",
1487 params![snapshot_id, workspace.name],
1488 )?;
1489
1490 insert_graph(
1491 &transaction,
1492 snapshot_id,
1493 nodes,
1494 edges,
1495 evidence,
1496 &mut progress,
1497 )?;
1498 insert_manual_links(&transaction, manual_links)?;
1499 progress(u64::try_from(manual_links.len()).unwrap_or(u64::MAX));
1500 insert_incremental_state(
1501 &transaction,
1502 snapshot_id,
1503 fingerprints,
1504 extractor_runs,
1505 &mut progress,
1506 )?;
1507 insert_extractor_batches(&transaction, snapshot_id, extractor_batches, &mut progress)?;
1508 insert_freshness(&transaction, snapshot_id, workspace)?;
1509 if let Some(community_snapshot) = community_snapshot {
1510 insert_community_snapshot(&transaction, community_snapshot, &mut progress)?;
1511 }
1512 transaction.execute(
1513 "UPDATE repo_snapshots SET is_current = 1 WHERE id = ?1",
1514 [snapshot_id],
1515 )?;
1516 transaction.commit()?;
1517 Ok(())
1518 }
1519
1520 pub fn load_current_graph(
1526 &self,
1527 workspace: &str,
1528 ) -> Result<(Vec<Node>, Vec<Edge>), StoreError> {
1529 let snapshot_id = self.current_snapshot_id(workspace)?;
1530 self.load_graph_snapshot(&snapshot_id)
1531 }
1532
1533 pub fn load_graph_snapshot(
1542 &self,
1543 snapshot_id: &str,
1544 ) -> Result<(Vec<Node>, Vec<Edge>), StoreError> {
1545 self.require_snapshot(snapshot_id)?;
1546 let mut node_statement = self.connection.prepare(
1547 "SELECT id, kind, repo_id, stable_key, label
1548 FROM nodes WHERE snapshot_id = ?1 ORDER BY id",
1549 )?;
1550 let node_rows = node_statement
1551 .query_map([&snapshot_id], |row| {
1552 Ok((
1553 row.get::<_, String>(0)?,
1554 row.get::<_, String>(1)?,
1555 row.get::<_, Option<String>>(2)?,
1556 row.get::<_, String>(3)?,
1557 row.get::<_, String>(4)?,
1558 ))
1559 })?
1560 .collect::<Result<Vec<_>, _>>()?;
1561 let nodes = node_rows
1562 .into_iter()
1563 .map(|(id, kind, repo_id, stable_key, label)| {
1564 Ok(Node {
1565 id: NodeId::new(id),
1566 kind: serde_json::from_str::<NodeKind>(&kind)?,
1567 repo_id: repo_id.map(RepoId::new),
1568 stable_key,
1569 label,
1570 })
1571 })
1572 .collect::<Result<Vec<_>, StoreError>>()?;
1573
1574 let mut edge_statement = self.connection.prepare(
1575 "SELECT id, source_node_id, target_node_id, kind, confidence, epistemic_status
1576 FROM edges WHERE snapshot_id = ?1 ORDER BY id",
1577 )?;
1578 let edge_rows = edge_statement
1579 .query_map([&snapshot_id], |row| {
1580 Ok((
1581 row.get::<_, String>(0)?,
1582 row.get::<_, String>(1)?,
1583 row.get::<_, String>(2)?,
1584 row.get::<_, String>(3)?,
1585 row.get::<_, f32>(4)?,
1586 row.get::<_, String>(5)?,
1587 ))
1588 })?
1589 .collect::<Result<Vec<_>, _>>()?;
1590 let mut edges = Vec::with_capacity(edge_rows.len());
1591 for (id, source, target, kind, confidence, status) in edge_rows {
1592 let mut evidence_statement = self.connection.prepare(
1593 "SELECT evidence_id FROM edge_evidence
1594 WHERE snapshot_id = ?1 AND edge_id = ?2 ORDER BY evidence_id",
1595 )?;
1596 let evidence = evidence_statement
1597 .query_map(params![snapshot_id, id], |row| row.get::<_, String>(0))?
1598 .map(|result| result.map(code_system_graph_model::EvidenceId::new))
1599 .collect::<Result<Vec<_>, _>>()?;
1600 edges.push(Edge {
1601 id: EdgeId::new(id),
1602 source: NodeId::new(source),
1603 target: NodeId::new(target),
1604 kind: serde_json::from_str::<EdgeKind>(&kind)?,
1605 confidence,
1606 status: serde_json::from_str::<EpistemicStatus>(&status)?,
1607 evidence,
1608 });
1609 }
1610 Ok((nodes, edges))
1611 }
1612
1613 pub fn load_current_evidence(&self, workspace: &str) -> Result<Vec<Evidence>, StoreError> {
1619 let snapshot_id = self.current_snapshot_id(workspace)?;
1620 self.load_evidence_snapshot(&snapshot_id)
1621 }
1622
1623 pub fn load_evidence_snapshot(&self, snapshot_id: &str) -> Result<Vec<Evidence>, StoreError> {
1633 self.require_snapshot(snapshot_id)?;
1634 let mut statement = self.connection.prepare(
1635 "SELECT id, repo_id, file_path, start_line, end_line, extractor, extractor_version,
1636 provenance, confidence, observed_at_commit, content_hash
1637 FROM evidence WHERE snapshot_id = ?1 ORDER BY id",
1638 )?;
1639 let rows = statement
1640 .query_map([snapshot_id], |row| {
1641 Ok((
1642 row.get::<_, String>(0)?,
1643 row.get::<_, Option<String>>(1)?,
1644 row.get::<_, Option<String>>(2)?,
1645 row.get::<_, Option<u32>>(3)?,
1646 row.get::<_, Option<u32>>(4)?,
1647 row.get::<_, String>(5)?,
1648 row.get::<_, String>(6)?,
1649 row.get::<_, String>(7)?,
1650 row.get::<_, f32>(8)?,
1651 row.get::<_, Option<String>>(9)?,
1652 row.get::<_, Option<String>>(10)?,
1653 ))
1654 })?
1655 .collect::<Result<Vec<_>, _>>()?;
1656 rows.into_iter()
1657 .map(
1658 |(
1659 id,
1660 repo_id,
1661 file_path,
1662 start_line,
1663 end_line,
1664 extractor,
1665 extractor_version,
1666 provenance,
1667 confidence,
1668 observed_at_commit,
1669 content_hash,
1670 )| {
1671 let provenance = serde_json::from_str(&provenance).map_err(|error| {
1672 malformed_stored_data(format!("evidence `{id}`"), error.to_string())
1673 })?;
1674 Ok(Evidence {
1675 id: EvidenceId::new(id),
1676 repo_id: repo_id.map(RepoId::new),
1677 file_path,
1678 start_line,
1679 end_line,
1680 extractor,
1681 extractor_version,
1682 provenance,
1683 confidence,
1684 observed_at_commit,
1685 content_hash,
1686 note: None,
1687 })
1688 },
1689 )
1690 .collect()
1691 }
1692
1693 pub fn search_current_nodes(
1702 &self,
1703 workspace: &str,
1704 query: &str,
1705 limit: usize,
1706 ) -> Result<Vec<Node>, StoreError> {
1707 if !(1..=100).contains(&limit) {
1708 return Err(StoreError::InvalidSearchQuery(
1709 "limit must be between 1 and 100".to_owned(),
1710 ));
1711 }
1712 self.search_current_nodes_ranked(workspace, query, limit)
1713 .map(|hits| hits.into_iter().map(|hit| hit.node).collect())
1714 }
1715
1716 pub fn search_current_nodes_ranked(
1727 &self,
1728 workspace: &str,
1729 query: &str,
1730 limit: usize,
1731 ) -> Result<Vec<StoredNodeSearchHit>, StoreError> {
1732 let query = query.trim();
1733 if query.is_empty() {
1734 return Err(StoreError::InvalidSearchQuery(
1735 "query must not be empty".to_owned(),
1736 ));
1737 }
1738 if query.len() > NODE_SEARCH_QUERY_MAX_BYTES {
1739 return Err(StoreError::InvalidSearchQuery(format!(
1740 "query must not exceed {NODE_SEARCH_QUERY_MAX_BYTES} UTF-8 bytes"
1741 )));
1742 }
1743 if !(1..=500).contains(&limit) {
1744 return Err(StoreError::InvalidSearchQuery(
1745 "limit must be between 1 and 500".to_owned(),
1746 ));
1747 }
1748 let phrase = format!("\"{}\"", query.replace('"', "\"\""));
1749 let limit = i64::try_from(limit).map_err(|_| StoreError::IntegerOutOfRange {
1750 field: "search.limit",
1751 value: i128::try_from(limit).unwrap_or(i128::MAX),
1752 })?;
1753 let mut statement = self.connection.prepare(
1754 "SELECT n.id, n.kind, n.repo_id, n.stable_key, n.label, bm25(nodes_fts)
1755 FROM nodes_fts
1756 JOIN repo_snapshots snapshot ON snapshot.id = nodes_fts.snapshot_id
1757 JOIN nodes n
1758 ON n.snapshot_id = nodes_fts.snapshot_id AND n.id = nodes_fts.node_id
1759 WHERE snapshot.workspace_name = ?1
1760 AND snapshot.is_current = 1
1761 AND nodes_fts MATCH ?2
1762 ORDER BY bm25(nodes_fts), n.id
1763 LIMIT ?3",
1764 )?;
1765 let rows = statement
1766 .query_map(params![workspace, phrase, limit], |row| {
1767 Ok((
1768 row.get::<_, String>(0)?,
1769 row.get::<_, String>(1)?,
1770 row.get::<_, Option<String>>(2)?,
1771 row.get::<_, String>(3)?,
1772 row.get::<_, String>(4)?,
1773 row.get::<_, f64>(5)?,
1774 ))
1775 })?
1776 .collect::<Result<Vec<_>, _>>()?;
1777 rows.into_iter()
1778 .map(|(id, kind, repo_id, stable_key, label, fts_rank)| {
1779 if !fts_rank.is_finite() {
1780 return Err(malformed_stored_data(
1781 format!("FTS hit `{id}`"),
1782 "rank is not finite",
1783 ));
1784 }
1785 let kind = serde_json::from_str(&kind).map_err(|error| {
1786 malformed_stored_data(format!("node `{id}`"), error.to_string())
1787 })?;
1788 Ok(StoredNodeSearchHit {
1789 node: Node {
1790 id: NodeId::new(id),
1791 kind,
1792 repo_id: repo_id.map(RepoId::new),
1793 stable_key,
1794 label,
1795 },
1796 fts_rank,
1797 })
1798 })
1799 .collect()
1800 }
1801
1802 pub fn load_current_community_snapshot(
1809 &self,
1810 workspace: &str,
1811 ) -> Result<CommunitySnapshot, StoreError> {
1812 let snapshot_id = self.current_snapshot_id(workspace)?;
1813 self.load_community_snapshot(&snapshot_id)
1814 }
1815
1816 pub fn load_community_snapshot(
1824 &self,
1825 snapshot_id: &str,
1826 ) -> Result<CommunitySnapshot, StoreError> {
1827 self.require_snapshot(snapshot_id)?;
1828 load_community_snapshot(&self.connection, snapshot_id)
1829 }
1830
1831 pub fn load_workspace_community_snapshot(
1838 &self,
1839 workspace: &str,
1840 snapshot_id: &str,
1841 ) -> Result<CommunitySnapshot, StoreError> {
1842 let belongs = self
1843 .connection
1844 .query_row(
1845 "SELECT 1 FROM repo_snapshots WHERE id = ?1 AND workspace_name = ?2",
1846 params![snapshot_id, workspace],
1847 |_| Ok(()),
1848 )
1849 .optional()?;
1850 belongs.ok_or_else(|| StoreError::SnapshotMissing(snapshot_id.to_owned()))?;
1851 load_community_snapshot(&self.connection, snapshot_id)
1852 }
1853
1854 fn current_snapshot_id(&self, workspace: &str) -> Result<String, StoreError> {
1855 self.connection
1856 .query_row(
1857 "SELECT id FROM repo_snapshots
1858 WHERE workspace_name = ?1 AND is_current = 1",
1859 [workspace],
1860 |row| row.get::<_, String>(0),
1861 )
1862 .optional()?
1863 .ok_or_else(|| StoreError::CurrentSnapshotMissing(workspace.to_owned()))
1864 }
1865
1866 fn require_snapshot(&self, snapshot_id: &str) -> Result<(), StoreError> {
1867 let exists = self
1868 .connection
1869 .query_row(
1870 "SELECT 1 FROM repo_snapshots WHERE id = ?1",
1871 [snapshot_id],
1872 |_| Ok(()),
1873 )
1874 .optional()?;
1875 exists.ok_or_else(|| StoreError::SnapshotMissing(snapshot_id.to_owned()))
1876 }
1877
1878 pub fn integrity_check(&self) -> Result<bool, StoreError> {
1884 let result = self
1885 .connection
1886 .query_row("PRAGMA quick_check", [], |row| row.get::<_, String>(0))?;
1887 if result != "ok" || self.schema_version()? != LATEST_SCHEMA_VERSION {
1888 return Ok(false);
1889 }
1890 let foreign_key_violation = self
1891 .connection
1892 .query_row(
1893 "SELECT 1 FROM pragma_foreign_key_check LIMIT 1",
1894 [],
1895 |row| row.get::<_, i64>(0),
1896 )
1897 .optional()?;
1898 Ok(foreign_key_violation.is_none())
1899 }
1900
1901 pub fn diagnostics(&self) -> Result<StoreDiagnostics, StoreError> {
1907 let journal_mode = self
1908 .connection
1909 .query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))?;
1910 let foreign_keys_enabled = self
1911 .connection
1912 .query_row("PRAGMA foreign_keys", [], |row| row.get::<_, i64>(0))?
1913 == 1;
1914 let fts5_index_available = self
1915 .connection
1916 .query_row(
1917 "SELECT 1 FROM sqlite_schema
1918 WHERE type = 'table' AND name = 'nodes_fts'",
1919 [],
1920 |row| row.get::<_, i64>(0),
1921 )
1922 .optional()?
1923 .is_some();
1924 Ok(StoreDiagnostics {
1925 journal_mode,
1926 foreign_keys_enabled,
1927 fts5_index_available,
1928 })
1929 }
1930}
1931
1932fn load_workspace_identity(
1933 connection: &Connection,
1934 workspace: &str,
1935) -> Result<StoredWorkspaceIdentity, StoreError> {
1936 let (id, manifest_hash, encoding, bytes, display) = connection
1937 .query_row(
1938 "SELECT
1939 id, manifest_hash, config_path_encoding, config_path, config_path_display
1940 FROM workspaces WHERE name = ?1",
1941 [workspace],
1942 |row| {
1943 Ok((
1944 row.get::<_, Option<String>>(0)?,
1945 row.get::<_, String>(1)?,
1946 row.get::<_, Option<String>>(2)?,
1947 row.get::<_, Option<Vec<u8>>>(3)?,
1948 row.get::<_, Option<String>>(4)?,
1949 ))
1950 },
1951 )
1952 .optional()?
1953 .ok_or_else(|| StoreError::RegistryIncomplete(workspace.to_owned()))?;
1954 Ok(StoredWorkspaceIdentity {
1955 id: id.ok_or_else(|| StoreError::RegistryIncomplete(workspace.to_owned()))?,
1956 manifest_hash,
1957 config_path: decode_optional_path(encoding, bytes, display)?,
1958 })
1959}
1960
1961fn load_workspace_repositories(
1962 connection: &Connection,
1963 workspace: &str,
1964) -> Result<Vec<RepositoryRecord>, StoreError> {
1965 let mut statement = connection.prepare(
1966 "SELECT
1967 wr.alias, r.id, c.id, c.path_encoding, c.canonical_path, c.path_display,
1968 r.git_common_path_encoding, r.git_common_path, r.git_common_path_display,
1969 r.normalized_remote, c.head_commit, c.is_linked_worktree, c.working_tree_dirty
1970 FROM workspace_repositories wr
1971 JOIN repositories r ON r.id = wr.repo_id
1972 JOIN repository_checkouts c ON c.id = wr.checkout_id
1973 WHERE wr.workspace_name = ?1
1974 ORDER BY wr.alias",
1975 )?;
1976 let rows = statement
1977 .query_map([workspace], |row| {
1978 Ok(StoredRepositoryRow {
1979 alias: row.get(0)?,
1980 repo_id: row.get(1)?,
1981 checkout_id: row.get(2)?,
1982 path_encoding: row.get(3)?,
1983 path_bytes: row.get(4)?,
1984 path_display: row.get(5)?,
1985 common_encoding: row.get(6)?,
1986 common_bytes: row.get(7)?,
1987 common_display: row.get(8)?,
1988 normalized_remote: row.get(9)?,
1989 head_commit: row.get(10)?,
1990 is_linked_worktree: row.get::<_, i64>(11)? != 0,
1991 working_tree_dirty: row.get::<_, i64>(12)? != 0,
1992 })
1993 })?
1994 .collect::<Result<Vec<_>, _>>()?;
1995 rows.into_iter().map(repository_from_row).collect()
1996}
1997
1998fn repository_from_row(row: StoredRepositoryRow) -> Result<RepositoryRecord, StoreError> {
1999 Ok(RepositoryRecord {
2000 id: RepoId::new(row.repo_id),
2001 checkout_id: CheckoutId::new(row.checkout_id),
2002 alias: row.alias,
2003 canonical_path: NativePath {
2004 encoding: serde_json::from_str(&row.path_encoding)?,
2005 bytes: row.path_bytes,
2006 display: row.path_display,
2007 },
2008 git_common_dir: decode_optional_path(
2009 row.common_encoding,
2010 row.common_bytes,
2011 row.common_display,
2012 )?,
2013 normalized_remote: row.normalized_remote,
2014 head_commit: row.head_commit,
2015 is_linked_worktree: row.is_linked_worktree,
2016 working_tree_dirty: row.working_tree_dirty,
2017 })
2018}
2019
2020fn decode_optional_path(
2021 encoding: Option<String>,
2022 bytes: Option<Vec<u8>>,
2023 display: Option<String>,
2024) -> Result<Option<NativePath>, StoreError> {
2025 match (encoding, bytes, display) {
2026 (Some(encoding), Some(bytes), Some(display)) => Ok(Some(NativePath {
2027 encoding: serde_json::from_str(&encoding)?,
2028 bytes,
2029 display,
2030 })),
2031 _ => Ok(None),
2032 }
2033}
2034
2035fn open_exact(path: &Path) -> Result<Connection, StoreError> {
2036 prepare_database_file(path)?;
2037 let mut connection = Connection::open_with_flags(
2038 path,
2039 OpenFlags::SQLITE_OPEN_READ_WRITE
2040 | OpenFlags::SQLITE_OPEN_CREATE
2041 | OpenFlags::SQLITE_OPEN_NO_MUTEX
2042 | OpenFlags::SQLITE_OPEN_NOFOLLOW,
2043 )?;
2044 configure_connection(&connection)?;
2045 if database_is_empty(&connection)? {
2046 initialize_empty_schema(&mut connection)?;
2047 }
2048 restrict_store_permissions(path)?;
2049 validate_exact_schema(&connection)?;
2050 Ok(connection)
2051}
2052
2053fn open_read_only_connection(
2054 path: &Path,
2055 configure: impl Fn(&Connection) -> Result<(), StoreError>,
2056) -> Result<Connection, StoreError> {
2057 let path = fs::canonicalize(path).map_err(|source| StoreError::Io {
2058 path: path.to_path_buf(),
2059 source,
2060 })?;
2061 let flags = OpenFlags::SQLITE_OPEN_READ_ONLY
2062 | OpenFlags::SQLITE_OPEN_NO_MUTEX
2063 | OpenFlags::SQLITE_OPEN_NOFOLLOW;
2064 let connection = Connection::open_with_flags(&path, flags)?;
2065 match configure(&connection) {
2066 Ok(()) => Ok(connection),
2067 Err(error) if is_read_only_directory_error(&error) => {
2068 drop(connection);
2069 if immutable_fallback_has_sidecars(&path)? {
2070 return Err(error);
2071 }
2072 let uri = immutable_database_uri(&path)?;
2073 let connection = Connection::open_with_flags(uri, flags | OpenFlags::SQLITE_OPEN_URI)?;
2074 configure(&connection)?;
2075 Ok(connection)
2076 }
2077 Err(error) => Err(error),
2078 }
2079}
2080
2081fn is_read_only_directory_error(error: &StoreError) -> bool {
2082 matches!(
2083 error,
2084 StoreError::Sqlite(rusqlite::Error::SqliteFailure(details, _))
2085 if details.extended_code == rusqlite::ffi::SQLITE_READONLY_DIRECTORY
2086 )
2087}
2088
2089fn immutable_fallback_has_sidecars(path: &Path) -> Result<bool, StoreError> {
2090 for suffix in &SQLITE_ARTIFACT_SUFFIXES[1..] {
2091 let sidecar = artifact_path(path, suffix);
2092 match fs::symlink_metadata(&sidecar) {
2093 Ok(_) => return Ok(true),
2094 Err(source) if source.kind() == std::io::ErrorKind::NotFound => {}
2095 Err(source) => {
2096 return Err(StoreError::Io {
2097 path: sidecar,
2098 source,
2099 });
2100 }
2101 }
2102 }
2103 Ok(false)
2104}
2105
2106#[expect(
2107 clippy::unnecessary_wraps,
2108 reason = "non-Unix database paths can be non-Unicode"
2109)]
2110fn immutable_database_uri(path: &Path) -> Result<String, StoreError> {
2111 const HEX: &[u8; 16] = b"0123456789ABCDEF";
2112
2113 #[cfg(unix)]
2114 let bytes = {
2115 use std::os::unix::ffi::OsStrExt;
2116
2117 path.as_os_str().as_bytes()
2118 };
2119 #[cfg(not(unix))]
2120 let bytes = path
2121 .to_str()
2122 .ok_or_else(|| StoreError::Io {
2123 path: path.to_path_buf(),
2124 source: std::io::Error::new(
2125 std::io::ErrorKind::InvalidInput,
2126 "database path is not valid Unicode",
2127 ),
2128 })?
2129 .as_bytes();
2130
2131 let mut uri = String::with_capacity(bytes.len() + 24);
2132 uri.push_str("file:");
2133 for &byte in bytes {
2134 if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~' | b'/' | b':') {
2135 uri.push(char::from(byte));
2136 } else {
2137 uri.push('%');
2138 uri.push(char::from(HEX[usize::from(byte >> 4)]));
2139 uri.push(char::from(HEX[usize::from(byte & 0x0f)]));
2140 }
2141 }
2142 uri.push_str("?immutable=1");
2143 Ok(uri)
2144}
2145
2146fn configure_connection(connection: &Connection) -> Result<(), StoreError> {
2147 connection.pragma_update(None, "foreign_keys", true)?;
2148 connection.pragma_update(None, "journal_mode", "WAL")?;
2149 connection.busy_timeout(Duration::from_secs(5))?;
2150 Ok(())
2151}
2152
2153fn database_is_empty(connection: &Connection) -> Result<bool, StoreError> {
2154 let tables = connection.query_row(
2155 "SELECT COUNT(*) FROM sqlite_master
2156 WHERE type = 'table' AND name NOT LIKE 'sqlite_%'",
2157 [],
2158 |row| row.get::<_, i64>(0),
2159 )?;
2160 Ok(tables == 0)
2161}
2162
2163fn validate_artifact_fingerprints(fingerprints: &[ArtifactFingerprint]) -> Result<(), StoreError> {
2164 const MAX_PATH_DISPLAY_BYTES: usize = 4 * 1024;
2165
2166 for fingerprint in fingerprints {
2167 validate_safe_metadata(
2168 "artifact path display",
2169 &fingerprint.path.display,
2170 1,
2171 MAX_PATH_DISPLAY_BYTES,
2172 )?;
2173 validate_safe_metadata("extractor", &fingerprint.extractor, 1, 256)?;
2174 validate_safe_metadata("artifact content hash", &fingerprint.content_hash, 1, 128)?;
2175 }
2176 Ok(())
2177}
2178
2179fn validate_graph_snapshot(
2180 nodes: &[Node],
2181 edges: &[Edge],
2182 evidence: &[Evidence],
2183) -> Result<(), StoreError> {
2184 const MAX_METADATA_BYTES: usize = 64 * 1024;
2185
2186 let validate_metadata = |field: &str, value: &str| {
2187 if value.len() > MAX_METADATA_BYTES {
2188 return Err(StoreError::InvalidGraphSnapshot(format!(
2189 "{field} exceeds {MAX_METADATA_BYTES} bytes"
2190 )));
2191 }
2192 if contains_unsafe_metadata_characters(value) {
2193 return Err(StoreError::InvalidGraphSnapshot(format!(
2194 "{field} contains unsafe control or bidirectional characters"
2195 )));
2196 }
2197 Ok(())
2198 };
2199
2200 let mut node_ids = BTreeSet::new();
2201 for node in nodes {
2202 if !node_ids.insert(node.id.as_str()) {
2203 return Err(StoreError::InvalidGraphSnapshot(format!(
2204 "duplicate node identifier `{}`",
2205 node.id.as_str()
2206 )));
2207 }
2208 validate_metadata("node identifier", node.id.as_str())?;
2209 validate_metadata("node stable key", &node.stable_key)?;
2210 validate_metadata("node label", &node.label)?;
2211 }
2212
2213 let mut evidence_ids = BTreeSet::new();
2214 for item in evidence {
2215 if !evidence_ids.insert(item.id.as_str()) {
2216 return Err(StoreError::InvalidGraphSnapshot(format!(
2217 "duplicate evidence identifier `{}`",
2218 item.id.as_str()
2219 )));
2220 }
2221 validate_metadata("evidence identifier", item.id.as_str())?;
2222 validate_metadata("evidence extractor", &item.extractor)?;
2223 validate_metadata("evidence extractor version", &item.extractor_version)?;
2224 for (field, value) in [
2225 ("evidence file path", item.file_path.as_deref()),
2226 (
2227 "evidence observed commit",
2228 item.observed_at_commit.as_deref(),
2229 ),
2230 ("evidence content hash", item.content_hash.as_deref()),
2231 ("evidence note", item.note.as_deref()),
2232 ] {
2233 if let Some(value) = value {
2234 validate_metadata(field, value)?;
2235 }
2236 }
2237 if !item.confidence.is_finite() || !(0.0..=1.0).contains(&item.confidence) {
2238 return Err(StoreError::InvalidGraphSnapshot(
2239 "evidence confidence must be finite and between zero and one".to_owned(),
2240 ));
2241 }
2242 if matches!((item.start_line, item.end_line), (Some(start), Some(end)) if start > end) {
2243 return Err(StoreError::InvalidGraphSnapshot(
2244 "evidence line range is reversed".to_owned(),
2245 ));
2246 }
2247 }
2248
2249 let mut edge_ids = BTreeSet::new();
2250 for edge in edges {
2251 if !edge_ids.insert(edge.id.as_str()) {
2252 return Err(StoreError::InvalidGraphSnapshot(format!(
2253 "duplicate edge identifier `{}`",
2254 edge.id.as_str()
2255 )));
2256 }
2257 validate_metadata("edge identifier", edge.id.as_str())?;
2258 if !node_ids.contains(edge.source.as_str()) || !node_ids.contains(edge.target.as_str()) {
2259 return Err(StoreError::InvalidGraphSnapshot(format!(
2260 "edge `{}` references a missing node",
2261 edge.id.as_str()
2262 )));
2263 }
2264 if !edge.confidence.is_finite() || !(0.0..=1.0).contains(&edge.confidence) {
2265 return Err(StoreError::InvalidGraphSnapshot(format!(
2266 "edge `{}` has invalid confidence",
2267 edge.id.as_str()
2268 )));
2269 }
2270 let mut edge_evidence = BTreeSet::new();
2271 for evidence_id in &edge.evidence {
2272 if !edge_evidence.insert(evidence_id.as_str()) {
2273 return Err(StoreError::InvalidGraphSnapshot(format!(
2274 "edge `{}` repeats evidence `{}`",
2275 edge.id.as_str(),
2276 evidence_id.as_str()
2277 )));
2278 }
2279 if !evidence_ids.contains(evidence_id.as_str()) {
2280 return Err(StoreError::InvalidGraphSnapshot(format!(
2281 "edge `{}` references missing evidence",
2282 edge.id.as_str()
2283 )));
2284 }
2285 }
2286 }
2287
2288 Ok(())
2289}
2290
2291fn validate_community_snapshot(
2292 snapshot_id: &str,
2293 nodes: &[Node],
2294 community_snapshot: Option<&CommunitySnapshot>,
2295) -> Result<(), StoreError> {
2296 let Some(community_snapshot) = community_snapshot else {
2297 return Ok(());
2298 };
2299 if community_snapshot.snapshot_id != snapshot_id {
2300 return Err(StoreError::CommunitySnapshotMismatch {
2301 batch_snapshot_id: snapshot_id.to_owned(),
2302 community_snapshot_id: community_snapshot.snapshot_id.clone(),
2303 });
2304 }
2305 let node_ids = nodes
2306 .iter()
2307 .map(|node| node.id.as_str())
2308 .collect::<BTreeSet<_>>();
2309 let mut community_ids = BTreeSet::new();
2310 for community in &community_snapshot.communities {
2311 if !community_ids.insert(community.id.as_str()) {
2312 return Err(StoreError::DuplicateCommunityId {
2313 snapshot_id: snapshot_id.to_owned(),
2314 community_id: community.id.as_str().to_owned(),
2315 });
2316 }
2317 let mut member_ids = BTreeSet::new();
2318 for member in &community.members {
2319 if !member_ids.insert(member.as_str()) {
2320 return Err(StoreError::DuplicateCommunityMembership {
2321 snapshot_id: snapshot_id.to_owned(),
2322 community_id: community.id.as_str().to_owned(),
2323 node_id: member.as_str().to_owned(),
2324 });
2325 }
2326 if !node_ids.contains(member.as_str()) {
2327 return Err(StoreError::CommunityMembershipNodeMissing {
2328 snapshot_id: snapshot_id.to_owned(),
2329 community_id: community.id.as_str().to_owned(),
2330 node_id: member.as_str().to_owned(),
2331 });
2332 }
2333 }
2334 }
2335 Ok(())
2336}
2337
2338fn insert_community_snapshot<F>(
2339 transaction: &rusqlite::Transaction<'_>,
2340 snapshot: &CommunitySnapshot,
2341 progress: &mut F,
2342) -> Result<(), StoreError>
2343where
2344 F: FnMut(u64),
2345{
2346 let algorithm = serde_json::to_string(&snapshot.config.algorithm)?;
2347 ensure_community_json_bound(
2348 "community_snapshots.algorithm",
2349 &algorithm,
2350 COMMUNITY_ALGORITHM_JSON_MAX_BYTES,
2351 )?;
2352 let config_json = serde_json::to_string(&snapshot.config)?;
2353 ensure_community_json_bound(
2354 "community_snapshots.config_json",
2355 &config_json,
2356 COMMUNITY_CONFIG_JSON_MAX_BYTES,
2357 )?;
2358 transaction.execute(
2359 "INSERT INTO community_snapshots(snapshot_id, engine_version, algorithm, config_json)
2360 VALUES (?1, ?2, ?3, ?4)",
2361 params![
2362 snapshot.snapshot_id,
2363 snapshot.engine_version,
2364 algorithm,
2365 config_json
2366 ],
2367 )?;
2368 progress(1);
2369 for community in &snapshot.communities {
2370 insert_community(transaction, &snapshot.snapshot_id, community)?;
2371 progress(1);
2372 }
2373 Ok(())
2374}
2375
2376fn insert_community(
2377 transaction: &rusqlite::Transaction<'_>,
2378 snapshot_id: &str,
2379 community: &Community,
2380) -> Result<(), StoreError> {
2381 let metrics_json = serde_json::to_string(&community.metrics)?;
2382 ensure_community_json_bound(
2383 "communities.metrics_json",
2384 &metrics_json,
2385 COMMUNITY_METRICS_JSON_MAX_BYTES,
2386 )?;
2387 let explanation_json = serde_json::to_string(&serde_json::json!({
2388 "central_nodes": &community.central_nodes,
2389 "repositories": &community.repositories,
2390 "services": &community.services,
2391 "inbound_contracts": &community.inbound_contracts,
2392 "outbound_contracts": &community.outbound_contracts,
2393 "label_evidence": &community.label_evidence,
2394 "limitations": &community.limitations,
2395 }))?;
2396 ensure_community_json_bound(
2397 "communities.explanation_json",
2398 &explanation_json,
2399 COMMUNITY_EXPLANATION_JSON_MAX_BYTES,
2400 )?;
2401 transaction.execute(
2402 "INSERT INTO communities(snapshot_id, id, label, metrics_json, explanation_json)
2403 VALUES (?1, ?2, ?3, ?4, ?5)",
2404 params![
2405 snapshot_id,
2406 community.id.as_str(),
2407 community.label,
2408 metrics_json,
2409 explanation_json
2410 ],
2411 )?;
2412 for (member_order, member) in community.members.iter().enumerate() {
2413 let member_order =
2414 i64::try_from(member_order).map_err(|_| StoreError::IntegerOutOfRange {
2415 field: "community_memberships.member_order",
2416 value: i128::try_from(member_order).unwrap_or(i128::MAX),
2417 })?;
2418 transaction.execute(
2419 "INSERT INTO community_memberships(
2420 snapshot_id, community_id, node_id, member_order
2421 ) VALUES (?1, ?2, ?3, ?4)",
2422 params![
2423 snapshot_id,
2424 community.id.as_str(),
2425 member.as_str(),
2426 member_order
2427 ],
2428 )?;
2429 }
2430 Ok(())
2431}
2432
2433fn ensure_community_json_bound(
2434 field: &'static str,
2435 json: &str,
2436 max_bytes: usize,
2437) -> Result<(), StoreError> {
2438 let actual_bytes = json.len();
2439 if actual_bytes > max_bytes {
2440 return Err(StoreError::CommunityJsonTooLarge {
2441 field,
2442 actual_bytes,
2443 max_bytes,
2444 });
2445 }
2446 Ok(())
2447}
2448
2449fn load_community_snapshot(
2450 connection: &Connection,
2451 snapshot_id: &str,
2452) -> Result<CommunitySnapshot, StoreError> {
2453 let header = connection
2454 .query_row(
2455 "SELECT engine_version, algorithm, config_json
2456 FROM community_snapshots WHERE snapshot_id = ?1",
2457 [snapshot_id],
2458 |row| {
2459 Ok((
2460 row.get::<_, String>(0)?,
2461 row.get::<_, String>(1)?,
2462 row.get::<_, String>(2)?,
2463 ))
2464 },
2465 )
2466 .optional()?
2467 .ok_or_else(|| StoreError::CommunitySnapshotMissing(snapshot_id.to_owned()))?;
2468 let (engine_version, algorithm_json, config_json) = header;
2469 ensure_community_json_bound(
2470 "community_snapshots.algorithm",
2471 &algorithm_json,
2472 COMMUNITY_ALGORITHM_JSON_MAX_BYTES,
2473 )?;
2474 ensure_community_json_bound(
2475 "community_snapshots.config_json",
2476 &config_json,
2477 COMMUNITY_CONFIG_JSON_MAX_BYTES,
2478 )?;
2479 let algorithm =
2480 serde_json::from_str::<CommunityAlgorithm>(&algorithm_json).map_err(|error| {
2481 malformed_stored_data(
2482 format!("community snapshot `{snapshot_id}` algorithm"),
2483 error.to_string(),
2484 )
2485 })?;
2486 let config = serde_json::from_str::<CommunityConfig>(&config_json).map_err(|error| {
2487 malformed_stored_data(
2488 format!("community snapshot `{snapshot_id}` config"),
2489 error.to_string(),
2490 )
2491 })?;
2492 if config.algorithm != algorithm {
2493 return Err(malformed_stored_data(
2494 format!("community snapshot `{snapshot_id}`"),
2495 "algorithm does not match config",
2496 ));
2497 }
2498
2499 let mut statement = connection.prepare(
2500 "SELECT id, label, metrics_json, explanation_json
2501 FROM communities WHERE snapshot_id = ?1 ORDER BY id",
2502 )?;
2503 let rows = statement
2504 .query_map([snapshot_id], |row| {
2505 Ok((
2506 row.get::<_, String>(0)?,
2507 row.get::<_, String>(1)?,
2508 row.get::<_, String>(2)?,
2509 row.get::<_, String>(3)?,
2510 ))
2511 })?
2512 .collect::<Result<Vec<_>, _>>()?;
2513 let mut communities = Vec::with_capacity(rows.len());
2514 for (id, label, metrics_json, explanation_json) in rows {
2515 communities.push(load_community(
2516 connection,
2517 snapshot_id,
2518 id,
2519 label,
2520 &metrics_json,
2521 &explanation_json,
2522 )?);
2523 }
2524 Ok(CommunitySnapshot {
2525 snapshot_id: snapshot_id.to_owned(),
2526 engine_version,
2527 config,
2528 communities,
2529 })
2530}
2531
2532fn load_community(
2533 connection: &Connection,
2534 snapshot_id: &str,
2535 id: String,
2536 label: String,
2537 metrics_json: &str,
2538 explanation_json: &str,
2539) -> Result<Community, StoreError> {
2540 ensure_community_json_bound(
2541 "communities.metrics_json",
2542 metrics_json,
2543 COMMUNITY_METRICS_JSON_MAX_BYTES,
2544 )?;
2545 ensure_community_json_bound(
2546 "communities.explanation_json",
2547 explanation_json,
2548 COMMUNITY_EXPLANATION_JSON_MAX_BYTES,
2549 )?;
2550 let entity = format!("community `{id}` in snapshot `{snapshot_id}`");
2551 let metrics = serde_json::from_str::<CommunityMetrics>(metrics_json)
2552 .map_err(|error| malformed_stored_data(&entity, error.to_string()))?;
2553 let mut details = serde_json::from_str::<serde_json::Value>(explanation_json)
2554 .map_err(|error| malformed_stored_data(&entity, error.to_string()))?;
2555 let details = details
2556 .as_object_mut()
2557 .ok_or_else(|| malformed_stored_data(&entity, "explanation JSON must be an object"))?;
2558 let central_nodes =
2559 serde_json::from_value(remove_json_field(details, "central_nodes", &entity)?)
2560 .map_err(|error| malformed_stored_data(&entity, error.to_string()))?;
2561 let repositories = serde_json::from_value(remove_json_field(details, "repositories", &entity)?)
2562 .map_err(|error| malformed_stored_data(&entity, error.to_string()))?;
2563 let services = serde_json::from_value(remove_json_field(details, "services", &entity)?)
2564 .map_err(|error| malformed_stored_data(&entity, error.to_string()))?;
2565 let inbound_contracts =
2566 serde_json::from_value(remove_json_field(details, "inbound_contracts", &entity)?)
2567 .map_err(|error| malformed_stored_data(&entity, error.to_string()))?;
2568 let outbound_contracts =
2569 serde_json::from_value(remove_json_field(details, "outbound_contracts", &entity)?)
2570 .map_err(|error| malformed_stored_data(&entity, error.to_string()))?;
2571 let label_evidence =
2572 serde_json::from_value(remove_json_field(details, "label_evidence", &entity)?)
2573 .map_err(|error| malformed_stored_data(&entity, error.to_string()))?;
2574 let limitations = serde_json::from_value(remove_json_field(details, "limitations", &entity)?)
2575 .map_err(|error| malformed_stored_data(&entity, error.to_string()))?;
2576 if !details.is_empty() {
2577 return Err(malformed_stored_data(
2578 &entity,
2579 "explanation JSON contains unknown fields",
2580 ));
2581 }
2582
2583 let members = load_community_members(connection, snapshot_id, &id, &entity)?;
2584 if metrics.size != members.len() {
2585 return Err(malformed_stored_data(
2586 &entity,
2587 "metrics size does not match normalized membership count",
2588 ));
2589 }
2590 Ok(Community {
2591 id: CommunityId::new(id),
2592 label,
2593 members,
2594 central_nodes,
2595 repositories,
2596 services,
2597 inbound_contracts,
2598 outbound_contracts,
2599 metrics,
2600 label_evidence,
2601 limitations,
2602 })
2603}
2604
2605fn load_community_members(
2606 connection: &Connection,
2607 snapshot_id: &str,
2608 community_id: &str,
2609 entity: &str,
2610) -> Result<Vec<NodeId>, StoreError> {
2611 let mut statement = connection.prepare(
2612 "SELECT node_id, member_order
2613 FROM community_memberships
2614 WHERE snapshot_id = ?1 AND community_id = ?2
2615 ORDER BY member_order, node_id",
2616 )?;
2617 let rows = statement
2618 .query_map(params![snapshot_id, community_id], |row| {
2619 Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
2620 })?
2621 .collect::<Result<Vec<_>, _>>()?;
2622 let mut members = Vec::with_capacity(rows.len());
2623 for (expected_order, (node_id, stored_order)) in rows.into_iter().enumerate() {
2624 let expected_order =
2625 i64::try_from(expected_order).map_err(|_| StoreError::IntegerOutOfRange {
2626 field: "community_memberships.member_order",
2627 value: i128::try_from(expected_order).unwrap_or(i128::MAX),
2628 })?;
2629 if stored_order != expected_order {
2630 return Err(malformed_stored_data(
2631 entity,
2632 "membership order is not contiguous",
2633 ));
2634 }
2635 let node_exists = connection
2636 .query_row(
2637 "SELECT 1 FROM nodes WHERE snapshot_id = ?1 AND id = ?2",
2638 params![snapshot_id, node_id],
2639 |_| Ok(()),
2640 )
2641 .optional()?
2642 .is_some();
2643 if !node_exists {
2644 return Err(malformed_stored_data(
2645 entity,
2646 format!("membership references missing node `{node_id}`"),
2647 ));
2648 }
2649 members.push(NodeId::new(node_id));
2650 }
2651 Ok(members)
2652}
2653
2654fn remove_json_field(
2655 object: &mut serde_json::Map<String, serde_json::Value>,
2656 field: &str,
2657 entity: &str,
2658) -> Result<serde_json::Value, StoreError> {
2659 object
2660 .remove(field)
2661 .ok_or_else(|| malformed_stored_data(entity, format!("missing JSON field `{field}`")))
2662}
2663
2664fn malformed_stored_data(entity: impl Into<String>, reason: impl Into<String>) -> StoreError {
2665 StoreError::MalformedStoredData {
2666 entity: entity.into(),
2667 reason: reason.into(),
2668 }
2669}
2670
2671fn validate_safe_metadata(
2672 field: &str,
2673 value: &str,
2674 min_bytes: usize,
2675 max_bytes: usize,
2676) -> Result<(), StoreError> {
2677 if !(min_bytes..=max_bytes).contains(&value.len()) || value.trim().is_empty() {
2678 return Err(StoreError::InvalidPersistenceRecord(format!(
2679 "{field} must contain between {min_bytes} and {max_bytes} non-blank UTF-8 bytes"
2680 )));
2681 }
2682 if contains_unsafe_metadata_characters(value) {
2683 return Err(StoreError::InvalidPersistenceRecord(format!(
2684 "{field} contains unsafe control or bidirectional characters"
2685 )));
2686 }
2687 Ok(())
2688}
2689
2690fn validate_manual_links(
2691 snapshot_id: &str,
2692 records: &[ManualLinkRecord],
2693 nodes: Option<&[Node]>,
2694) -> Result<(), StoreError> {
2695 validate_safe_metadata(
2696 "snapshot identifier",
2697 snapshot_id,
2698 1,
2699 MANUAL_LINK_ID_MAX_BYTES,
2700 )?;
2701 let node_ids = nodes.map(|nodes| {
2702 nodes
2703 .iter()
2704 .map(|node| node.id.as_str())
2705 .collect::<BTreeSet<_>>()
2706 });
2707 let mut record_ids = BTreeSet::new();
2708 let mut declarations = BTreeSet::new();
2709 for record in records {
2710 if record.snapshot_id != snapshot_id {
2711 return Err(StoreError::InvalidPersistenceRecord(format!(
2712 "manual link `{}` belongs to snapshot `{}`, not `{snapshot_id}`",
2713 record.id, record.snapshot_id
2714 )));
2715 }
2716 validate_safe_metadata(
2717 "manual link identifier",
2718 &record.id,
2719 1,
2720 MANUAL_LINK_ID_MAX_BYTES,
2721 )?;
2722 validate_safe_metadata(
2723 "manual link source node",
2724 record.source_node_id.as_str(),
2725 1,
2726 MANUAL_LINK_ID_MAX_BYTES,
2727 )?;
2728 validate_safe_metadata(
2729 "manual link target node",
2730 record.target_node_id.as_str(),
2731 1,
2732 MANUAL_LINK_ID_MAX_BYTES,
2733 )?;
2734 validate_safe_metadata(
2735 "manual link kind",
2736 &record.kind,
2737 1,
2738 MANUAL_LINK_KIND_MAX_BYTES,
2739 )?;
2740 validate_safe_metadata(
2741 "manual link reason",
2742 &record.reason,
2743 1,
2744 MANUAL_LINK_REASON_MAX_BYTES,
2745 )?;
2746 validate_manual_link_decision(record)?;
2747 if !(1..=2_147_483_647).contains(&record.config_version) {
2748 return Err(StoreError::InvalidPersistenceRecord(format!(
2749 "manual link `{}` config version is outside 1..=2147483647",
2750 record.id
2751 )));
2752 }
2753 if !record_ids.insert(record.id.as_str()) {
2754 return Err(StoreError::InvalidPersistenceRecord(format!(
2755 "manual link identifier `{}` is duplicated in snapshot `{snapshot_id}`",
2756 record.id
2757 )));
2758 }
2759 if !declarations.insert((
2760 record.source_node_id.as_str(),
2761 record.target_node_id.as_str(),
2762 record.kind.as_str(),
2763 )) {
2764 return Err(StoreError::InvalidPersistenceRecord(format!(
2765 "manual link endpoints and kind are duplicated in snapshot `{snapshot_id}`"
2766 )));
2767 }
2768 if node_ids.as_ref().is_some_and(|node_ids| {
2769 !node_ids.contains(record.source_node_id.as_str())
2770 || !node_ids.contains(record.target_node_id.as_str())
2771 }) {
2772 return Err(StoreError::InvalidPersistenceRecord(format!(
2773 "manual link `{}` references a node absent from snapshot `{snapshot_id}`",
2774 record.id
2775 )));
2776 }
2777 }
2778 Ok(())
2779}
2780
2781fn validate_manual_link_decision(record: &ManualLinkRecord) -> Result<(), StoreError> {
2782 let decision_json = serde_json::to_vec(&record.decision)?;
2783 if decision_json.len() > MANUAL_LINK_DECISION_JSON_MAX_BYTES {
2784 return Err(StoreError::InvalidPersistenceRecord(format!(
2785 "manual link `{}` decision exceeds {MANUAL_LINK_DECISION_JSON_MAX_BYTES} bytes",
2786 record.id
2787 )));
2788 }
2789 let relation =
2790 serde_json::from_value::<EdgeKind>(serde_json::Value::String(record.kind.clone()))
2791 .map_err(|error| {
2792 StoreError::InvalidPersistenceRecord(format!(
2793 "manual link `{}` kind is invalid: {error}",
2794 record.id
2795 ))
2796 })?;
2797 let expected_status = match record.disposition {
2798 ManualLinkDisposition::Active => LinkStatus::Confirmed,
2799 ManualLinkDisposition::Suppression => LinkStatus::Suppressed,
2800 };
2801 if record.decision.source != record.source_node_id
2802 || record.decision.target != record.target_node_id
2803 || record.decision.relation != relation
2804 || record.decision.status != expected_status
2805 || record.decision.reasons.first() != Some(&record.reason)
2806 {
2807 return Err(StoreError::InvalidPersistenceRecord(format!(
2808 "manual link `{}` decision does not match its persisted declaration",
2809 record.id
2810 )));
2811 }
2812 Ok(())
2813}
2814
2815fn validate_provider_capability_record(
2816 record: &ProviderCapabilityRecord,
2817) -> Result<(), StoreError> {
2818 validate_safe_metadata(
2819 "workspace name",
2820 &record.workspace_name,
2821 1,
2822 MANUAL_LINK_ID_MAX_BYTES,
2823 )?;
2824 validate_safe_metadata(
2825 "repository identifier",
2826 record.repo_id.as_str(),
2827 1,
2828 MANUAL_LINK_ID_MAX_BYTES,
2829 )?;
2830 validate_safe_metadata(
2831 "provider name",
2832 &record.provider,
2833 1,
2834 PROVIDER_COMPONENT_MAX_BYTES,
2835 )?;
2836 validate_safe_metadata(
2837 "provider version",
2838 &record.provider_version,
2839 1,
2840 PROVIDER_COMPONENT_MAX_BYTES,
2841 )?;
2842 if record.capabilities.len() > PROVIDER_CAPABILITY_COUNT_MAX {
2843 return Err(StoreError::InvalidPersistenceRecord(format!(
2844 "provider capability count exceeds {PROVIDER_CAPABILITY_COUNT_MAX}"
2845 )));
2846 }
2847 let mut capability_names = BTreeSet::new();
2848 for capability in &record.capabilities {
2849 validate_safe_metadata(
2850 "provider capability name",
2851 capability,
2852 1,
2853 PROVIDER_COMPONENT_MAX_BYTES,
2854 )?;
2855 if !capability_names.insert(capability.as_str()) {
2856 return Err(StoreError::InvalidPersistenceRecord(format!(
2857 "provider capability `{capability}` is duplicated"
2858 )));
2859 }
2860 }
2861 let capabilities_json = serde_json::to_string(&record.capabilities)?;
2862 if capabilities_json.len() > PROVIDER_CAPABILITIES_JSON_MAX_BYTES {
2863 return Err(StoreError::InvalidPersistenceRecord(format!(
2864 "provider capability JSON exceeds {PROVIDER_CAPABILITIES_JSON_MAX_BYTES} bytes"
2865 )));
2866 }
2867 metric_to_i64(
2868 "provider_capabilities.observed_at_unix_ms",
2869 record.observed_at_unix_ms,
2870 )?;
2871 Ok(())
2872}
2873
2874fn validate_query_cache_record(record: &QueryCacheRecord) -> Result<(), StoreError> {
2875 validate_safe_metadata(
2876 "workspace name",
2877 &record.workspace_name,
2878 1,
2879 MANUAL_LINK_ID_MAX_BYTES,
2880 )?;
2881 validate_safe_metadata(
2882 "snapshot identifier",
2883 &record.snapshot_id,
2884 1,
2885 MANUAL_LINK_ID_MAX_BYTES,
2886 )?;
2887 validate_safe_metadata(
2888 "query cache input fingerprint",
2889 &record.input_fingerprint,
2890 1,
2891 QUERY_CACHE_FINGERPRINT_MAX_BYTES,
2892 )?;
2893 if record.result_summary_json.is_empty()
2894 || record.result_summary_json.len() > QUERY_CACHE_RESULT_MAX_BYTES
2895 || serde_json::from_slice::<serde_json::Value>(&record.result_summary_json).is_err()
2896 {
2897 return Err(StoreError::InvalidPersistenceRecord(format!(
2898 "query cache result must be valid JSON between 1 and {QUERY_CACHE_RESULT_MAX_BYTES} bytes"
2899 )));
2900 }
2901 if record
2902 .expires_at_unix_ms
2903 .is_some_and(|expires| expires < record.stored_at_unix_ms)
2904 {
2905 return Err(StoreError::InvalidPersistenceRecord(
2906 "query cache expiry precedes storage timestamp".to_owned(),
2907 ));
2908 }
2909 Ok(())
2910}
2911
2912fn insert_manual_links(
2913 transaction: &rusqlite::Transaction<'_>,
2914 records: &[ManualLinkRecord],
2915) -> Result<(), StoreError> {
2916 for record in records {
2917 transaction.execute(
2918 "INSERT INTO manual_links(
2919 snapshot_id, id, source_node_id, target_node_id, kind, disposition, reason,
2920 decision_json, config_version
2921 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
2922 params![
2923 record.snapshot_id,
2924 record.id,
2925 record.source_node_id.as_str(),
2926 record.target_node_id.as_str(),
2927 record.kind,
2928 record.disposition.as_str(),
2929 record.reason,
2930 serde_json::to_vec(&record.decision)?,
2931 i64::from(record.config_version),
2932 ],
2933 )?;
2934 }
2935 Ok(())
2936}
2937
2938fn insert_graph<F>(
2939 transaction: &rusqlite::Transaction<'_>,
2940 snapshot_id: &str,
2941 nodes: &[Node],
2942 edges: &[Edge],
2943 evidence: &[Evidence],
2944 progress: &mut F,
2945) -> Result<(), StoreError>
2946where
2947 F: FnMut(u64),
2948{
2949 for node in nodes {
2950 transaction.execute(
2951 "INSERT INTO nodes(
2952 snapshot_id, id, kind, repo_id, stable_key, label
2953 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
2954 params![
2955 snapshot_id,
2956 node.id.as_str(),
2957 serde_json::to_string(&node.kind)?,
2958 node.repo_id.as_ref().map(RepoId::as_str),
2959 node.stable_key,
2960 node.label,
2961 ],
2962 )?;
2963 progress(1);
2964 transaction.execute(
2965 "INSERT INTO nodes_fts(snapshot_id, node_id, label, stable_key)
2966 VALUES (?1, ?2, ?3, ?4)",
2967 params![snapshot_id, node.id.as_str(), node.label, node.stable_key],
2968 )?;
2969 progress(1);
2970 }
2971 for item in evidence {
2972 transaction.execute(
2973 "INSERT INTO evidence(
2974 snapshot_id, id, repo_id, file_path, start_line, end_line, extractor,
2975 extractor_version, provenance, confidence, observed_at_commit, content_hash
2976 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
2977 params![
2978 snapshot_id,
2979 item.id.as_str(),
2980 item.repo_id.as_ref().map(RepoId::as_str),
2981 item.file_path,
2982 item.start_line,
2983 item.end_line,
2984 item.extractor,
2985 item.extractor_version,
2986 serde_json::to_string(&item.provenance)?,
2987 item.confidence,
2988 item.observed_at_commit,
2989 item.content_hash,
2990 ],
2991 )?;
2992 }
2993 for edge in edges {
2994 transaction.execute(
2995 "INSERT INTO edges(
2996 snapshot_id, id, source_node_id, target_node_id, kind, confidence,
2997 epistemic_status
2998 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
2999 params![
3000 snapshot_id,
3001 edge.id.as_str(),
3002 edge.source.as_str(),
3003 edge.target.as_str(),
3004 serde_json::to_string(&edge.kind)?,
3005 edge.confidence,
3006 serde_json::to_string(&edge.status)?,
3007 ],
3008 )?;
3009 progress(1);
3010 for evidence_id in &edge.evidence {
3011 transaction.execute(
3012 "INSERT INTO edge_evidence(snapshot_id, edge_id, evidence_id)
3013 VALUES (?1, ?2, ?3)",
3014 params![snapshot_id, edge.id.as_str(), evidence_id.as_str()],
3015 )?;
3016 progress(1);
3017 }
3018 }
3019 Ok(())
3020}
3021
3022fn insert_extractor_batches<F>(
3023 transaction: &rusqlite::Transaction<'_>,
3024 snapshot_id: &str,
3025 batches: &[StoredExtractorBatch],
3026 progress: &mut F,
3027) -> Result<(), StoreError>
3028where
3029 F: FnMut(u64),
3030{
3031 for batch in batches {
3032 transaction.execute(
3033 "INSERT INTO extractor_batches(
3034 snapshot_id, repo_id, checkout_id, path_encoding, relative_path, path_display,
3035 extractor, content_hash, size_bytes, extractor_version, budget_fingerprint,
3036 source_was_lossy, output_count, payload
3037 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
3038 params![
3039 snapshot_id,
3040 batch.source.repo_id.as_str(),
3041 batch.source.checkout_id.as_str(),
3042 serde_json::to_string(&batch.source.path.encoding)?,
3043 batch.source.path.bytes,
3044 batch.source.path.display,
3045 batch.source.extractor,
3046 batch.source.content_hash,
3047 metric_to_i64("extractor_batches.size_bytes", batch.source.size_bytes)?,
3048 batch.extractor_version,
3049 batch.budget_fingerprint,
3050 batch.source_was_lossy,
3051 metric_to_i64("extractor_batches.output_count", batch.output_count)?,
3052 batch.payload,
3053 ],
3054 )?;
3055 progress(1);
3056 }
3057 Ok(())
3058}
3059
3060fn insert_incremental_state<F>(
3061 transaction: &rusqlite::Transaction<'_>,
3062 snapshot_id: &str,
3063 fingerprints: &[ArtifactFingerprint],
3064 extractor_runs: &[ExtractorRun],
3065 progress: &mut F,
3066) -> Result<(), StoreError>
3067where
3068 F: FnMut(u64),
3069{
3070 for fingerprint in fingerprints {
3071 let size_bytes = metric_to_i64("artifact_fingerprints.size_bytes", fingerprint.size_bytes)?;
3072 transaction.execute(
3073 "INSERT INTO artifact_fingerprints(
3074 snapshot_id, repo_id, checkout_id, path_encoding, relative_path,
3075 path_display, extractor, content_hash, size_bytes
3076 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
3077 params![
3078 snapshot_id,
3079 fingerprint.repo_id.as_str(),
3080 fingerprint.checkout_id.as_str(),
3081 serde_json::to_string(&fingerprint.path.encoding)?,
3082 fingerprint.path.bytes,
3083 fingerprint.path.display,
3084 fingerprint.extractor,
3085 fingerprint.content_hash,
3086 size_bytes,
3087 ],
3088 )?;
3089 progress(1);
3090 }
3091 for run in extractor_runs {
3092 insert_extractor_run(transaction, snapshot_id, run, fingerprints)?;
3093 progress(1);
3094 }
3095 Ok(())
3096}
3097
3098fn insert_extractor_run(
3099 transaction: &rusqlite::Transaction<'_>,
3100 snapshot_id: &str,
3101 run: &ExtractorRun,
3102 fingerprints: &[ArtifactFingerprint],
3103) -> Result<(), StoreError> {
3104 transaction.execute(
3105 "INSERT INTO extractor_runs(
3106 id, snapshot_id, repo_id, extractor, extractor_version, status,
3107 discovered_files, parsed_files, skipped_files, elapsed_ms, checkout_id
3108 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
3109 params![
3110 run.id,
3111 snapshot_id,
3112 run.repo_id.as_str(),
3113 run.extractor,
3114 run.extractor_version,
3115 serde_json::to_string(&run.status)?,
3116 metric_to_i64("extractor_runs.discovered_files", run.discovered_files)?,
3117 metric_to_i64("extractor_runs.parsed_files", run.parsed_files)?,
3118 metric_to_i64("extractor_runs.skipped_files", run.skipped_files)?,
3119 metric_to_i64("extractor_runs.elapsed_ms", run.elapsed_ms)?,
3120 run.checkout_id.as_str(),
3121 ],
3122 )?;
3123 for fingerprint in fingerprints.iter().filter(|fingerprint| {
3124 fingerprint.repo_id == run.repo_id
3125 && fingerprint.checkout_id == run.checkout_id
3126 && fingerprint.extractor == run.extractor
3127 }) {
3128 transaction.execute(
3129 "INSERT INTO extractor_run_inputs(
3130 run_id, repo_id, checkout_id, path_encoding, relative_path,
3131 extractor, content_hash
3132 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
3133 params![
3134 run.id,
3135 fingerprint.repo_id.as_str(),
3136 fingerprint.checkout_id.as_str(),
3137 serde_json::to_string(&fingerprint.path.encoding)?,
3138 fingerprint.path.bytes,
3139 fingerprint.extractor,
3140 fingerprint.content_hash,
3141 ],
3142 )?;
3143 }
3144 Ok(())
3145}
3146
3147fn insert_freshness(
3148 transaction: &rusqlite::Transaction<'_>,
3149 snapshot_id: &str,
3150 workspace: &WorkspaceRecord,
3151) -> Result<(), StoreError> {
3152 for repository in &workspace.repositories {
3153 let freshness_state = if repository.working_tree_dirty {
3154 RepoFreshnessState::WorkingTreeChanged
3155 } else {
3156 RepoFreshnessState::Fresh
3157 };
3158 transaction.execute(
3159 "INSERT INTO repository_snapshot_freshness(
3160 snapshot_id, repo_id, checkout_id, head_commit, manifest_hash, state, reason
3161 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL)",
3162 params![
3163 snapshot_id,
3164 repository.id.as_str(),
3165 repository.checkout_id.as_str(),
3166 repository.head_commit,
3167 workspace.manifest_hash,
3168 serde_json::to_string(&freshness_state)?,
3169 ],
3170 )?;
3171 }
3172 transaction.execute(
3173 "DELETE FROM provider_capabilities
3174 WHERE workspace_name = ?1
3175 AND NOT EXISTS (
3176 SELECT 1 FROM workspace_repositories
3177 WHERE workspace_name = ?1
3178 AND repo_id = provider_capabilities.repo_id
3179 )",
3180 [&workspace.name],
3181 )?;
3182 Ok(())
3183}
3184
3185fn metric_to_i64(field: &'static str, value: u64) -> Result<i64, StoreError> {
3186 i64::try_from(value).map_err(|_| StoreError::IntegerOutOfRange {
3187 field,
3188 value: i128::from(value),
3189 })
3190}
3191
3192fn stored_metric_to_u64(field: &'static str, value: i64) -> Result<u64, StoreError> {
3193 u64::try_from(value).map_err(|_| StoreError::IntegerOutOfRange {
3194 field,
3195 value: i128::from(value),
3196 })
3197}
3198
3199fn count_to_usize(field: &'static str, value: i64) -> Result<usize, StoreError> {
3200 usize::try_from(value).map_err(|_| StoreError::IntegerOutOfRange {
3201 field,
3202 value: i128::from(value),
3203 })
3204}
3205
3206fn initialize_empty_schema(connection: &mut Connection) -> Result<(), StoreError> {
3207 let transaction = connection.transaction()?;
3208 transaction.execute_batch(INITIAL_SCHEMA)?;
3209 transaction.execute(
3210 "INSERT INTO schema_metadata(version, instance_id)
3211 VALUES (?1, lower(hex(randomblob(32))))",
3212 [LATEST_SCHEMA_VERSION],
3213 )?;
3214 transaction.commit()?;
3215 Ok(())
3216}
3217
3218fn schema_version(connection: &Connection) -> Result<i64, StoreError> {
3219 Ok(connection.query_row(
3220 "SELECT COALESCE(MAX(version), 0) FROM schema_metadata",
3221 [],
3222 |row| row.get(0),
3223 )?)
3224}
3225
3226fn database_instance_id(connection: &Connection) -> Result<String, StoreError> {
3227 Ok(connection.query_row(
3228 "SELECT instance_id FROM schema_metadata WHERE version = ?1",
3229 [LATEST_SCHEMA_VERSION],
3230 |row| row.get(0),
3231 )?)
3232}
3233
3234fn upsert_registry(
3235 transaction: &rusqlite::Transaction<'_>,
3236 workspace: &WorkspaceRecord,
3237) -> Result<(), StoreError> {
3238 let config_path_encoding = workspace
3239 .config_path
3240 .as_ref()
3241 .map(|path| serde_json::to_string(&path.encoding))
3242 .transpose()?;
3243 transaction.execute(
3244 "INSERT INTO workspaces(
3245 name, manifest_hash, id, config_path_encoding, config_path, config_path_display
3246 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
3247 ON CONFLICT(name) DO UPDATE SET
3248 manifest_hash = excluded.manifest_hash,
3249 id = excluded.id,
3250 config_path_encoding = excluded.config_path_encoding,
3251 config_path = excluded.config_path,
3252 config_path_display = excluded.config_path_display",
3253 params![
3254 workspace.name,
3255 workspace.manifest_hash,
3256 workspace.id.as_str(),
3257 config_path_encoding,
3258 workspace
3259 .config_path
3260 .as_ref()
3261 .map(|path| path.bytes.as_slice()),
3262 workspace
3263 .config_path
3264 .as_ref()
3265 .map(|path| path.display.as_str()),
3266 ],
3267 )?;
3268 transaction.execute(
3269 "DELETE FROM workspace_repositories WHERE workspace_name = ?1",
3270 [&workspace.name],
3271 )?;
3272 for repository in &workspace.repositories {
3273 let common_encoding = repository
3274 .git_common_dir
3275 .as_ref()
3276 .map(|path| serde_json::to_string(&path.encoding))
3277 .transpose()?;
3278 transaction.execute(
3279 "INSERT INTO repositories(
3280 id, normalized_remote, git_common_path_encoding, git_common_path,
3281 git_common_path_display
3282 ) VALUES (?1, ?2, ?3, ?4, ?5)
3283 ON CONFLICT(id) DO UPDATE SET
3284 normalized_remote = excluded.normalized_remote,
3285 git_common_path_encoding = excluded.git_common_path_encoding,
3286 git_common_path = excluded.git_common_path,
3287 git_common_path_display = excluded.git_common_path_display",
3288 params![
3289 repository.id.as_str(),
3290 repository.normalized_remote,
3291 common_encoding,
3292 repository
3293 .git_common_dir
3294 .as_ref()
3295 .map(|path| path.bytes.as_slice()),
3296 repository
3297 .git_common_dir
3298 .as_ref()
3299 .map(|path| path.display.as_str()),
3300 ],
3301 )?;
3302 transaction.execute(
3303 "INSERT INTO repository_checkouts(
3304 id, repo_id, path_encoding, canonical_path, path_display, head_commit,
3305 is_linked_worktree, working_tree_dirty
3306 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
3307 ON CONFLICT(id) DO UPDATE SET
3308 repo_id = excluded.repo_id,
3309 path_encoding = excluded.path_encoding,
3310 canonical_path = excluded.canonical_path,
3311 path_display = excluded.path_display,
3312 head_commit = excluded.head_commit,
3313 is_linked_worktree = excluded.is_linked_worktree,
3314 working_tree_dirty = excluded.working_tree_dirty",
3315 params![
3316 repository.checkout_id.as_str(),
3317 repository.id.as_str(),
3318 serde_json::to_string(&repository.canonical_path.encoding)?,
3319 repository.canonical_path.bytes,
3320 repository.canonical_path.display,
3321 repository.head_commit,
3322 i64::from(repository.is_linked_worktree),
3323 i64::from(repository.working_tree_dirty),
3324 ],
3325 )?;
3326 transaction.execute(
3327 "INSERT INTO workspace_repositories(workspace_name, alias, repo_id, checkout_id)
3328 VALUES (?1, ?2, ?3, ?4)",
3329 params![
3330 workspace.name,
3331 repository.alias,
3332 repository.id.as_str(),
3333 repository.checkout_id.as_str(),
3334 ],
3335 )?;
3336 }
3337 Ok(())
3338}
3339
3340fn lock_path(database_path: &Path) -> PathBuf {
3341 let mut path = database_path.to_path_buf();
3342 let extension = database_path.extension().map_or_else(
3343 || "lock".to_owned(),
3344 |value| format!("{}.lock", value.to_string_lossy()),
3345 );
3346 path.set_extension(extension);
3347 path
3348}
3349
3350#[cfg(unix)]
3351fn create_lock_file(path: &Path) -> std::io::Result<std::fs::File> {
3352 use std::os::unix::fs::OpenOptionsExt;
3353
3354 OpenOptions::new()
3355 .write(true)
3356 .create_new(true)
3357 .mode(0o600)
3358 .open(path)
3359}
3360
3361#[cfg(not(unix))]
3362fn create_lock_file(path: &Path) -> std::io::Result<std::fs::File> {
3363 OpenOptions::new().write(true).create_new(true).open(path)
3364}
3365
3366impl LockMetadata {
3367 fn encode(&self) -> String {
3368 format!(
3369 "version=1\npid={}\nprocess_started_at={}\ntoken={}\n",
3370 self.pid, self.process_started_at, self.token
3371 )
3372 }
3373
3374 fn decode(content: &str) -> Option<Self> {
3375 let mut version = None;
3376 let mut pid = None;
3377 let mut process_started_at = None;
3378 let mut token = None;
3379 for line in content.lines() {
3380 let (key, value) = line.split_once('=')?;
3381 match key {
3382 "version" => version = Some(value),
3383 "pid" => pid = value.parse().ok(),
3384 "process_started_at" => process_started_at = value.parse().ok(),
3385 "token" => token = Some(value.to_owned()),
3386 _ => return None,
3387 }
3388 }
3389 (version == Some("1") && token.as_ref().is_some_and(|value| !value.is_empty())).then_some(
3390 Self {
3391 pid: pid?,
3392 process_started_at: process_started_at?,
3393 token: token?,
3394 },
3395 )
3396 }
3397}
3398
3399fn process_start_time(pid: u32) -> Option<u64> {
3400 let pid = Pid::from_u32(pid);
3401 let mut system = System::new();
3402 system.refresh_processes(ProcessesToUpdate::Some(&[pid]), true);
3403 system.process(pid).map(sysinfo::Process::start_time)
3404}
3405
3406fn lock_owner_is_alive(metadata: &LockMetadata) -> bool {
3407 process_start_time(metadata.pid).is_some_and(|started_at| {
3408 metadata.process_started_at == 0 || metadata.process_started_at == started_at
3409 })
3410}
3411
3412fn lock_is_stale(path: &Path, stale_after: Duration) -> Result<bool, StoreError> {
3413 let modified = fs::metadata(path)
3414 .and_then(|metadata| metadata.modified())
3415 .map_err(|source| StoreError::Io {
3416 path: path.to_path_buf(),
3417 source,
3418 })?;
3419 let old_enough = SystemTime::now()
3420 .duration_since(modified)
3421 .is_ok_and(|age| age >= stale_after);
3422 if !old_enough {
3423 return Ok(false);
3424 }
3425 let content = fs::read_to_string(path).map_err(|source| StoreError::Io {
3426 path: path.to_path_buf(),
3427 source,
3428 })?;
3429 Ok(LockMetadata::decode(&content).is_none_or(|metadata| !lock_owner_is_alive(&metadata)))
3430}
3431
3432#[cfg(test)]
3433mod tests {
3434 use std::fs;
3435 use std::process::Command;
3436 use std::time::Duration;
3437
3438 use code_system_graph_model::{
3439 ArtifactFingerprint, CheckoutId, Community, CommunityAlgorithm, CommunityConfig, CommunityId, CommunityMetrics, CommunityScope, CommunitySnapshot, Edge, EdgeId, EdgeKind, EpistemicStatus, Evidence, EvidenceId, ExtractorRun, ExtractorRunStatus, NativePath, NativePathEncoding, Node, NodeId, NodeKind, Provenance, RepoFreshnessState, RepoId, RepositoryRecord, StoredExtractorBatch, WorkspaceId, WorkspaceRecord
3440 };
3441 use rusqlite::params;
3442
3443 use super::{
3444 INITIAL_SCHEMA, ManualLinkDisposition, ManualLinkRecord, ProviderCapabilityRecord, QueryCacheRecord, SnapshotBatch, SqliteStore, StoreError, StoreLock, lock_path
3445 };
3446
3447 const LOCK_HELPER_ENV: &str = "CODE_SYSTEM_GRAPH_LOCK_HELPER";
3448 const LOCK_DATABASE_ENV: &str = "CODE_SYSTEM_GRAPH_LOCK_DATABASE";
3449 const LOCK_READY_ENV: &str = "CODE_SYSTEM_GRAPH_LOCK_READY";
3450
3451 fn fixture() -> (Vec<Node>, Vec<Edge>, Vec<Evidence>) {
3452 let evidence = Evidence {
3453 id: EvidenceId::new("evidence:1"),
3454 repo_id: Some(RepoId::new("repo:web")),
3455 file_path: Some("src/client.ts".to_owned()),
3456 start_line: Some(7),
3457 end_line: Some(9),
3458 extractor: "test".to_owned(),
3459 extractor_version: "1.0.0".to_owned(),
3460 provenance: Provenance::Extracted,
3461 confidence: 1.0,
3462 observed_at_commit: Some("0123456789abcdef".to_owned()),
3463 content_hash: Some("hash".to_owned()),
3464 note: None,
3465 };
3466 let nodes = vec![
3467 Node {
3468 id: NodeId::new("node:web"),
3469 kind: NodeKind::HttpOperation,
3470 repo_id: Some(RepoId::new("repo:web")),
3471 stable_key: "http:web".to_owned(),
3472 label: "POST /orders".to_owned(),
3473 },
3474 Node {
3475 id: NodeId::new("node:api"),
3476 kind: NodeKind::HttpOperation,
3477 repo_id: Some(RepoId::new("repo:api")),
3478 stable_key: "http:api".to_owned(),
3479 label: "POST /orders".to_owned(),
3480 },
3481 ];
3482 let edges = vec![Edge {
3483 id: EdgeId::new("edge:1"),
3484 source: NodeId::new("node:web"),
3485 target: NodeId::new("node:api"),
3486 kind: EdgeKind::CallsRemote,
3487 confidence: 1.0,
3488 status: EpistemicStatus::Confirmed,
3489 evidence: vec![evidence.id.clone()],
3490 }];
3491 (nodes, edges, vec![evidence])
3492 }
3493
3494 fn workspace() -> WorkspaceRecord {
3495 WorkspaceRecord {
3496 id: WorkspaceId::new("workspace:commerce"),
3497 name: "commerce".to_owned(),
3498 manifest_hash: "manifest-hash".to_owned(),
3499 config_path: Some(NativePath {
3500 encoding: NativePathEncoding::Utf8,
3501 bytes: b"/workspace/code-system-graph.yaml".to_vec(),
3502 display: "/workspace/code-system-graph.yaml".to_owned(),
3503 }),
3504 repositories: vec![RepositoryRecord {
3505 id: RepoId::new("repo:web"),
3506 checkout_id: CheckoutId::new("checkout:web"),
3507 alias: "web".to_owned(),
3508 canonical_path: NativePath {
3509 encoding: NativePathEncoding::Utf8,
3510 bytes: b"/workspace/web".to_vec(),
3511 display: "/workspace/web".to_owned(),
3512 },
3513 git_common_dir: None,
3514 normalized_remote: None,
3515 head_commit: Some("abc123".to_owned()),
3516 is_linked_worktree: false,
3517 working_tree_dirty: false,
3518 }],
3519 }
3520 }
3521
3522 fn incremental_fixture() -> (ArtifactFingerprint, ExtractorRun) {
3523 let fingerprint = ArtifactFingerprint {
3524 repo_id: RepoId::new("repo:web"),
3525 checkout_id: CheckoutId::new("checkout:web"),
3526 path: NativePath {
3527 encoding: NativePathEncoding::Utf8,
3528 bytes: b"openapi.yaml".to_vec(),
3529 display: "openapi.yaml".to_owned(),
3530 },
3531 extractor: "code-system-graph.http.openapi".to_owned(),
3532 content_hash: "content:1".to_owned(),
3533 size_bytes: 42,
3534 };
3535 let run = ExtractorRun {
3536 id: "run:1".to_owned(),
3537 snapshot_id: "snapshot:1".to_owned(),
3538 repo_id: fingerprint.repo_id.clone(),
3539 checkout_id: fingerprint.checkout_id.clone(),
3540 extractor: fingerprint.extractor.clone(),
3541 extractor_version: "0.1.0".to_owned(),
3542 status: ExtractorRunStatus::Success,
3543 discovered_files: 1,
3544 parsed_files: 1,
3545 skipped_files: 0,
3546 elapsed_ms: 2,
3547 };
3548 (fingerprint, run)
3549 }
3550
3551 fn manual_link_fixture(snapshot_id: &str) -> Vec<ManualLinkRecord> {
3552 vec![
3553 ManualLinkRecord {
3554 id: "manual-link:active".to_owned(),
3555 snapshot_id: snapshot_id.to_owned(),
3556 source_node_id: NodeId::new("node:web"),
3557 target_node_id: NodeId::new("node:api"),
3558 kind: "calls_remote".to_owned(),
3559 disposition: ManualLinkDisposition::Active,
3560 reason: "Declared by workspace configuration".to_owned(),
3561 decision: manual_link_decision(
3562 "node:web",
3563 "node:api",
3564 "confirmed",
3565 "Declared by workspace configuration",
3566 ),
3567 config_version: 1,
3568 },
3569 ManualLinkRecord {
3570 id: "manual-link:suppression".to_owned(),
3571 snapshot_id: snapshot_id.to_owned(),
3572 source_node_id: NodeId::new("node:api"),
3573 target_node_id: NodeId::new("node:web"),
3574 kind: "calls_remote".to_owned(),
3575 disposition: ManualLinkDisposition::Suppression,
3576 reason: "Known false positive".to_owned(),
3577 decision: manual_link_decision(
3578 "node:api",
3579 "node:web",
3580 "suppressed",
3581 "Known false positive",
3582 ),
3583 config_version: 1,
3584 },
3585 ]
3586 }
3587
3588 fn manual_link_decision(
3589 source: &str,
3590 target: &str,
3591 status: &str,
3592 reason: &str,
3593 ) -> code_system_graph_model::LinkDecision {
3594 serde_json::from_value(serde_json::json!({
3595 "source": source,
3596 "target": target,
3597 "relation": "calls_remote",
3598 "matcher": "manual_exact",
3599 "matcher_version": "1.0.0",
3600 "score": 1.0,
3601 "confidence": 1.0,
3602 "reasons": [reason],
3603 "rejected_alternatives": [],
3604 "evidence": [{
3605 "id": format!("evidence:{source}:{target}"),
3606 "provenance": "manual"
3607 }],
3608 "status": status
3609 }))
3610 .unwrap_or_else(|error| panic!("manual decision fixture must decode: {error}"))
3611 }
3612
3613 fn community_fixture(snapshot_id: &str) -> CommunitySnapshot {
3614 CommunitySnapshot {
3615 snapshot_id: snapshot_id.to_owned(),
3616 engine_version: "1.0.0".to_owned(),
3617 config: CommunityConfig {
3618 algorithm: CommunityAlgorithm::Louvain,
3619 scope: CommunityScope::Federated,
3620 seed: 7,
3621 resolution: 1.0,
3622 minimum_confidence: 0.5,
3623 edge_weights: Vec::new(),
3624 max_iterations: 20,
3625 },
3626 communities: vec![Community {
3627 id: CommunityId::new("community:orders"),
3628 label: "orders".to_owned(),
3629 members: vec![NodeId::new("node:api"), NodeId::new("node:web")],
3630 central_nodes: vec![NodeId::new("node:api")],
3631 repositories: vec![RepoId::new("repo:web")],
3632 services: Vec::new(),
3633 inbound_contracts: vec![NodeId::new("node:api")],
3634 outbound_contracts: vec![NodeId::new("node:web")],
3635 metrics: CommunityMetrics {
3636 size: 2,
3637 density: 0.5,
3638 cohesion: 1.0,
3639 coupling: 0.0,
3640 cross_community_edges: 0,
3641 },
3642 label_evidence: Vec::new(),
3643 limitations: vec!["fixture coverage".to_owned()],
3644 }],
3645 }
3646 }
3647
3648 #[test]
3649 fn publish_snapshot_should_round_trip_current_graph() {
3650 let mut store = match SqliteStore::in_memory() {
3651 Ok(store) => store,
3652 Err(error) => panic!("test store must initialize: {error}"),
3653 };
3654 let (nodes, edges, evidence) = fixture();
3655 let workspace = workspace();
3656 let mut published_rows = 0_u64;
3657 let result = store
3658 .publish_snapshot_with_progress(
3659 SnapshotBatch {
3660 workspace: &workspace,
3661 snapshot_id: "snapshot:1",
3662 nodes: &nodes,
3663 edges: &edges,
3664 evidence: &evidence,
3665 fingerprints: &[],
3666 extractor_batches: &[],
3667 extractor_runs: &[],
3668 manual_links: &[],
3669 community_snapshot: None,
3670 },
3671 |rows| {
3672 published_rows = published_rows
3673 .checked_add(rows)
3674 .expect("bounded fixture progress");
3675 },
3676 )
3677 .and_then(|()| {
3678 Ok((
3679 store.load_current_graph("commerce")?,
3680 store.load_current_evidence("commerce")?,
3681 ))
3682 });
3683 let counts = result.map(|((stored_nodes, stored_edges), stored_evidence)| {
3684 (
3685 stored_nodes.len(),
3686 stored_edges.len(),
3687 stored_evidence[0].start_line,
3688 stored_evidence[0].end_line,
3689 stored_evidence[0].extractor_version.clone(),
3690 stored_evidence[0].observed_at_commit.clone(),
3691 )
3692 });
3693
3694 assert!(matches!(
3695 counts,
3696 Ok((
3697 2,
3698 1,
3699 Some(7),
3700 Some(9),
3701 version,
3702 Some(commit)
3703 )) if version == "1.0.0" && commit == "0123456789abcdef"
3704 ));
3705 assert!(published_rows >= 4);
3706 }
3707
3708 #[test]
3709 fn publish_snapshot_should_reject_graph_poisoning_before_transaction() {
3710 let mut store = match SqliteStore::in_memory() {
3711 Ok(store) => store,
3712 Err(error) => panic!("test store must initialize: {error}"),
3713 };
3714 let (nodes, mut edges, evidence) = fixture();
3715 edges[0].target = NodeId::new("node:missing");
3716 let workspace = workspace();
3717
3718 let result = store.publish_snapshot(SnapshotBatch {
3719 workspace: &workspace,
3720 snapshot_id: "snapshot:poisoned",
3721 nodes: &nodes,
3722 edges: &edges,
3723 evidence: &evidence,
3724 fingerprints: &[],
3725 extractor_batches: &[],
3726 extractor_runs: &[],
3727 manual_links: &[],
3728 community_snapshot: None,
3729 });
3730
3731 assert!(matches!(result, Err(StoreError::InvalidGraphSnapshot(_))));
3732 }
3733
3734 #[test]
3735 fn interrupted_publication_should_preserve_previous_snapshot() {
3736 let mut store = SqliteStore::in_memory().expect("test store");
3737 let workspace = workspace();
3738 let (nodes, edges, evidence) = fixture();
3739 store
3740 .publish_snapshot(SnapshotBatch {
3741 workspace: &workspace,
3742 snapshot_id: "snapshot:previous",
3743 nodes: &nodes,
3744 edges: &edges,
3745 evidence: &evidence,
3746 fingerprints: &[],
3747 extractor_batches: &[],
3748 extractor_runs: &[],
3749 manual_links: &[],
3750 community_snapshot: None,
3751 })
3752 .expect("initial publication");
3753 let mut rows = 0_u64;
3754 let interrupted = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
3755 let _ = store.publish_snapshot_with_progress(
3756 SnapshotBatch {
3757 workspace: &workspace,
3758 snapshot_id: "snapshot:interrupted",
3759 nodes: &nodes,
3760 edges: &edges,
3761 evidence: &evidence,
3762 fingerprints: &[],
3763 extractor_batches: &[],
3764 extractor_runs: &[],
3765 manual_links: &[],
3766 community_snapshot: None,
3767 },
3768 |completed| {
3769 rows = rows.saturating_add(completed);
3770 assert!(rows < 2, "injected publication interruption");
3771 },
3772 );
3773 }));
3774
3775 assert!(interrupted.is_err());
3776 assert_eq!(
3777 store
3778 .current_snapshot_summary("commerce")
3779 .expect("previous snapshot remains")
3780 .snapshot_id,
3781 "snapshot:previous"
3782 );
3783 }
3784
3785 #[test]
3786 fn publish_snapshot_should_reject_bidi_labels() {
3787 let mut store = match SqliteStore::in_memory() {
3788 Ok(store) => store,
3789 Err(error) => panic!("test store must initialize: {error}"),
3790 };
3791 let (mut nodes, edges, evidence) = fixture();
3792 nodes[0].label = "safe\u{202e}txt.exe".to_owned();
3793 let workspace = workspace();
3794
3795 let result = store.publish_snapshot(SnapshotBatch {
3796 workspace: &workspace,
3797 snapshot_id: "snapshot:bidi",
3798 nodes: &nodes,
3799 edges: &edges,
3800 evidence: &evidence,
3801 fingerprints: &[],
3802 extractor_batches: &[],
3803 extractor_runs: &[],
3804 manual_links: &[],
3805 community_snapshot: None,
3806 });
3807
3808 assert!(matches!(result, Err(StoreError::InvalidGraphSnapshot(_))));
3809 }
3810
3811 #[test]
3812 fn publish_snapshot_should_reject_control_chars_in_path_display() {
3813 let mut store = match SqliteStore::in_memory() {
3814 Ok(store) => store,
3815 Err(error) => panic!("test store must initialize: {error}"),
3816 };
3817 let workspace = workspace();
3818 let (nodes, edges, evidence) = fixture();
3819 let fingerprint = ArtifactFingerprint {
3820 repo_id: RepoId::new("repo:web"),
3821 checkout_id: CheckoutId::new("checkout:web"),
3822 path: NativePath {
3823 encoding: NativePathEncoding::Utf8,
3824 bytes: b"src/bad\x1bname.ts".to_vec(),
3825 display: "src/bad\x1bname.ts".to_owned(),
3826 },
3827 extractor: "test".to_owned(),
3828 content_hash: "hash".to_owned(),
3829 size_bytes: 1,
3830 };
3831
3832 let result = store.publish_snapshot(SnapshotBatch {
3833 workspace: &workspace,
3834 snapshot_id: "snapshot:control-path",
3835 nodes: &nodes,
3836 edges: &edges,
3837 evidence: &evidence,
3838 fingerprints: &[fingerprint],
3839 extractor_batches: &[],
3840 extractor_runs: &[],
3841 manual_links: &[],
3842 community_snapshot: None,
3843 });
3844
3845 assert!(matches!(
3846 result,
3847 Err(StoreError::InvalidPersistenceRecord(_))
3848 ));
3849 }
3850
3851 #[test]
3852 fn publish_snapshot_should_atomically_round_trip_graph_and_communities() {
3853 let mut store = match SqliteStore::in_memory() {
3854 Ok(store) => store,
3855 Err(error) => panic!("test store must initialize: {error}"),
3856 };
3857 let workspace = workspace();
3858 let (nodes, edges, evidence) = fixture();
3859 let expected = community_fixture("snapshot:community");
3860 let result = store
3861 .publish_snapshot(SnapshotBatch {
3862 workspace: &workspace,
3863 snapshot_id: "snapshot:community",
3864 nodes: &nodes,
3865 edges: &edges,
3866 evidence: &evidence,
3867 fingerprints: &[],
3868 extractor_batches: &[],
3869 extractor_runs: &[],
3870 manual_links: &[],
3871 community_snapshot: Some(&expected),
3872 })
3873 .and_then(|()| {
3874 Ok((
3875 store.load_current_graph("commerce")?,
3876 store.load_current_community_snapshot("commerce")?,
3877 ))
3878 });
3879
3880 assert!(matches!(
3881 result,
3882 Ok(((stored_nodes, stored_edges), stored_communities))
3883 if stored_nodes.len() == 2
3884 && stored_edges.len() == 1
3885 && stored_communities == expected
3886 ));
3887 }
3888
3889 #[test]
3890 fn invalid_community_membership_should_preserve_current_snapshot() {
3891 let mut store = match SqliteStore::in_memory() {
3892 Ok(store) => store,
3893 Err(error) => panic!("test store must initialize: {error}"),
3894 };
3895 let workspace = workspace();
3896 let (nodes, edges, evidence) = fixture();
3897 let first = store.publish_snapshot(SnapshotBatch {
3898 workspace: &workspace,
3899 snapshot_id: "snapshot:valid",
3900 nodes: &nodes,
3901 edges: &edges,
3902 evidence: &evidence,
3903 fingerprints: &[],
3904 extractor_batches: &[],
3905 extractor_runs: &[],
3906 manual_links: &[],
3907 community_snapshot: None,
3908 });
3909 assert!(first.is_ok(), "valid fixture failed: {first:?}");
3910 let mut invalid = community_fixture("snapshot:invalid");
3911 invalid.communities[0]
3912 .members
3913 .push(NodeId::new("node:missing"));
3914 invalid.communities[0].metrics.size = 3;
3915 let failure = store.publish_snapshot(SnapshotBatch {
3916 workspace: &workspace,
3917 snapshot_id: "snapshot:invalid",
3918 nodes: &nodes,
3919 edges: &edges,
3920 evidence: &evidence,
3921 fingerprints: &[],
3922 extractor_batches: &[],
3923 extractor_runs: &[],
3924 manual_links: &[],
3925 community_snapshot: Some(&invalid),
3926 });
3927 let current = store.current_snapshot_summary("commerce");
3928
3929 assert!(matches!(
3930 (failure, current),
3931 (
3932 Err(StoreError::CommunityMembershipNodeMissing {
3933 snapshot_id,
3934 community_id,
3935 node_id,
3936 }),
3937 Ok(summary),
3938 ) if snapshot_id == "snapshot:invalid"
3939 && community_id == "community:orders"
3940 && node_id == "node:missing"
3941 && summary.snapshot_id == "snapshot:valid"
3942 ));
3943 }
3944
3945 #[test]
3946 fn duplicate_community_ids_should_return_domain_error() {
3947 let mut store = match SqliteStore::in_memory() {
3948 Ok(store) => store,
3949 Err(error) => panic!("test store must initialize: {error}"),
3950 };
3951 let workspace = workspace();
3952 let (nodes, edges, evidence) = fixture();
3953 let mut communities = community_fixture("snapshot:duplicate");
3954 communities
3955 .communities
3956 .push(communities.communities[0].clone());
3957
3958 let result = store.publish_snapshot(SnapshotBatch {
3959 workspace: &workspace,
3960 snapshot_id: "snapshot:duplicate",
3961 nodes: &nodes,
3962 edges: &edges,
3963 evidence: &evidence,
3964 fingerprints: &[],
3965 extractor_batches: &[],
3966 extractor_runs: &[],
3967 manual_links: &[],
3968 community_snapshot: Some(&communities),
3969 });
3970
3971 assert!(matches!(
3972 result,
3973 Err(StoreError::DuplicateCommunityId {
3974 snapshot_id,
3975 community_id,
3976 }) if snapshot_id == "snapshot:duplicate"
3977 && community_id == "community:orders"
3978 ));
3979 }
3980
3981 #[test]
3982 fn historical_graph_and_community_snapshots_should_remain_loadable() {
3983 let mut store = match SqliteStore::in_memory() {
3984 Ok(store) => store,
3985 Err(error) => panic!("test store must initialize: {error}"),
3986 };
3987 let workspace = workspace();
3988 let (nodes, edges, evidence) = fixture();
3989 let historical_communities = community_fixture("snapshot:historical");
3990 let first = store.publish_snapshot(SnapshotBatch {
3991 workspace: &workspace,
3992 snapshot_id: "snapshot:historical",
3993 nodes: &nodes,
3994 edges: &edges,
3995 evidence: &evidence,
3996 fingerprints: &[],
3997 extractor_batches: &[],
3998 extractor_runs: &[],
3999 manual_links: &[],
4000 community_snapshot: Some(&historical_communities),
4001 });
4002 assert!(first.is_ok(), "historical fixture failed: {first:?}");
4003 let second = store.publish_snapshot(SnapshotBatch {
4004 workspace: &workspace,
4005 snapshot_id: "snapshot:current",
4006 nodes: &nodes,
4007 edges: &edges,
4008 evidence: &evidence,
4009 fingerprints: &[],
4010 extractor_batches: &[],
4011 extractor_runs: &[],
4012 manual_links: &[],
4013 community_snapshot: None,
4014 });
4015 assert!(second.is_ok(), "current fixture failed: {second:?}");
4016 let result = store
4017 .load_graph_snapshot("snapshot:historical")
4018 .and_then(|graph| Ok((graph, store.load_community_snapshot("snapshot:historical")?)));
4019
4020 assert!(matches!(
4021 result,
4022 Ok(((stored_nodes, stored_edges), stored_communities))
4023 if stored_nodes.len() == 2
4024 && stored_edges.len() == 1
4025 && stored_communities == historical_communities
4026 ));
4027 }
4028
4029 #[test]
4030 fn load_community_snapshot_should_reject_inconsistent_stored_metrics() {
4031 let mut store = match SqliteStore::in_memory() {
4032 Ok(store) => store,
4033 Err(error) => panic!("test store must initialize: {error}"),
4034 };
4035 let workspace = workspace();
4036 let (nodes, edges, evidence) = fixture();
4037 let communities = community_fixture("snapshot:malformed");
4038 let setup = store
4039 .publish_snapshot(SnapshotBatch {
4040 workspace: &workspace,
4041 snapshot_id: "snapshot:malformed",
4042 nodes: &nodes,
4043 edges: &edges,
4044 evidence: &evidence,
4045 fingerprints: &[],
4046 extractor_batches: &[],
4047 extractor_runs: &[],
4048 manual_links: &[],
4049 community_snapshot: Some(&communities),
4050 })
4051 .and_then(|()| {
4052 store.connection.execute(
4053 "UPDATE communities SET metrics_json = ?1
4054 WHERE snapshot_id = ?2 AND id = ?3",
4055 rusqlite::params![r#"{"size":1,"density":0.5,"cohesion":1.0,"coupling":0.0,"cross_community_edges":0}"#, "snapshot:malformed", "community:orders"],
4056 )?;
4057 Ok(())
4058 });
4059 assert!(setup.is_ok(), "malformed fixture failed: {setup:?}");
4060
4061 let result = store.load_community_snapshot("snapshot:malformed");
4062
4063 assert!(matches!(
4064 result,
4065 Err(StoreError::MalformedStoredData { reason, .. })
4066 if reason == "metrics size does not match normalized membership count"
4067 ));
4068 }
4069
4070 #[test]
4071 fn integrity_check_should_pass_after_initial_schema_creation() {
4072 let result = SqliteStore::in_memory().and_then(|store| store.integrity_check());
4073
4074 assert!(matches!(result, Ok(true)));
4075 }
4076
4077 #[test]
4078 fn integrity_check_should_detect_foreign_key_corruption() {
4079 let store = match SqliteStore::in_memory() {
4080 Ok(store) => store,
4081 Err(error) => panic!("test store must initialize: {error}"),
4082 };
4083 let setup = store.connection.execute_batch(
4084 "PRAGMA foreign_keys = OFF;
4085 INSERT INTO workspaces(name, manifest_hash, id)
4086 VALUES ('corrupt', 'hash', 'workspace:corrupt');
4087 INSERT INTO repo_snapshots(id, workspace_name, is_current)
4088 VALUES ('snapshot:corrupt', 'corrupt', 1);
4089 INSERT INTO edges(
4090 snapshot_id, id, source_node_id, target_node_id, kind, confidence,
4091 epistemic_status
4092 ) VALUES (
4093 'snapshot:corrupt', 'edge:corrupt', 'missing:a', 'missing:b',
4094 '\"calls_remote\"', 1.0, '\"confirmed\"'
4095 );
4096 PRAGMA foreign_keys = ON;",
4097 );
4098 assert!(setup.is_ok(), "corruption fixture failed: {setup:?}");
4099 let result = store.integrity_check();
4100
4101 assert!(matches!(result, Ok(false)));
4102 }
4103
4104 #[test]
4105 fn fresh_database_should_apply_initial_schema() {
4106 let result = SqliteStore::in_memory().and_then(|store| store.schema_version());
4107
4108 assert!(matches!(result, Ok(1)));
4109 }
4110
4111 #[test]
4112 fn initial_schema_should_include_all_persistence_domains()
4113 -> Result<(), Box<dyn std::error::Error>> {
4114 let store = SqliteStore::in_memory()?;
4115 let table_count = store.connection.query_row(
4116 "SELECT COUNT(*) FROM sqlite_schema
4117 WHERE type = 'table'
4118 AND name IN (
4119 'workspaces',
4120 'repositories',
4121 'repo_snapshots',
4122 'nodes',
4123 'evidence',
4124 'edges',
4125 'artifact_fingerprints',
4126 'extractor_batches',
4127 'community_snapshots',
4128 'manual_links',
4129 'provider_capabilities',
4130 'query_cache'
4131 )",
4132 [],
4133 |row| row.get::<_, i64>(0),
4134 )?;
4135
4136 assert_eq!((store.schema_version()?, table_count), (1, 12));
4137 Ok(())
4138 }
4139
4140 #[test]
4141 fn exact_initial_schema_validation_should_be_repeatable()
4142 -> Result<(), Box<dyn std::error::Error>> {
4143 let mut connection = rusqlite::Connection::open_in_memory()?;
4144 super::initialize_empty_schema(&mut connection)?;
4145 super::validate_exact_schema(&connection)?;
4146 super::validate_exact_schema(&connection)?;
4147
4148 assert_eq!(super::schema_version(&connection)?, 1);
4149 Ok(())
4150 }
4151
4152 #[test]
4153 fn current_schema_should_enforce_tables_indexes_triggers_and_foreign_keys()
4154 -> Result<(), Box<dyn std::error::Error>> {
4155 let store = SqliteStore::in_memory()?;
4156 let strict_tables = store.connection.query_row(
4157 "SELECT COUNT(*), COALESCE(SUM(strict), 0)
4158 FROM pragma_table_list
4159 WHERE name IN ('manual_links', 'provider_capabilities', 'query_cache')",
4160 [],
4161 |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)),
4162 )?;
4163 let indexes = store.connection.query_row(
4164 "SELECT COUNT(*) FROM sqlite_schema
4165 WHERE type = 'index'
4166 AND name IN (
4167 'repo_snapshots_id_workspace_idx',
4168 'manual_links_source_idx',
4169 'manual_links_target_idx',
4170 'manual_links_disposition_idx',
4171 'provider_capabilities_repo_provider_idx',
4172 'query_cache_snapshot_idx',
4173 'query_cache_workspace_expiry_idx',
4174 'query_cache_workspace_stored_idx'
4175 )",
4176 [],
4177 |row| row.get::<_, i64>(0),
4178 )?;
4179 let triggers = store.connection.query_row(
4180 "SELECT COUNT(*) FROM sqlite_schema
4181 WHERE type = 'trigger'
4182 AND name IN (
4183 'provider_capabilities_require_registration',
4184 'provider_capabilities_update_require_registration',
4185 'query_cache_bound_workspace_entries'
4186 )",
4187 [],
4188 |row| row.get::<_, i64>(0),
4189 )?;
4190 let foreign_keys = ["manual_links", "provider_capabilities", "query_cache"]
4191 .into_iter()
4192 .try_fold(0_i64, |count, table| {
4193 store
4194 .connection
4195 .query_row(
4196 &format!("SELECT COUNT(*) FROM pragma_foreign_key_list('{table}')"),
4197 [],
4198 |row| row.get::<_, i64>(0),
4199 )
4200 .map(|table_count| count + table_count)
4201 })?;
4202
4203 assert_eq!(
4204 (strict_tables, indexes, triggers, foreign_keys),
4205 ((3, 3), 8, 3, 9)
4206 );
4207 Ok(())
4208 }
4209
4210 #[test]
4211 fn exact_schema_should_reject_unknown_version() {
4212 let connection = match rusqlite::Connection::open_in_memory() {
4213 Ok(connection) => connection,
4214 Err(error) => panic!("test connection must initialize: {error}"),
4215 };
4216 let setup = connection.execute_batch(
4217 "CREATE TABLE schema_metadata (
4218 version INTEGER PRIMARY KEY,
4219 applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
4220 );
4221 INSERT INTO schema_metadata(version) VALUES (999);",
4222 );
4223 assert!(setup.is_ok(), "newer schema fixture failed: {setup:?}");
4224 let result = SqliteStore::from_connection(connection);
4225
4226 assert!(matches!(result, Err(StoreError::InvalidSchema)));
4227 }
4228
4229 #[test]
4230 fn exact_schema_should_reject_missing_schema_objects() -> Result<(), Box<dyn std::error::Error>>
4231 {
4232 let store = SqliteStore::in_memory()?;
4233 store
4234 .connection
4235 .execute_batch("DROP TRIGGER query_cache_bound_workspace_entries")?;
4236
4237 assert!(matches!(
4238 super::validate_exact_schema(&store.connection),
4239 Err(StoreError::InvalidSchema)
4240 ));
4241 Ok(())
4242 }
4243
4244 #[test]
4245 fn database_instance_identity_should_be_opaque_and_stable()
4246 -> Result<(), Box<dyn std::error::Error>> {
4247 let store = SqliteStore::in_memory()?;
4248 let first = store.database_instance_id()?;
4249 let second = store.database_instance_id()?;
4250
4251 assert_eq!(first, second);
4252 assert_eq!(first.len(), 64);
4253 assert!(first.bytes().all(|byte| byte.is_ascii_hexdigit()));
4254 Ok(())
4255 }
4256
4257 #[test]
4258 fn database_with_incomplete_current_schema_should_be_rejected()
4259 -> Result<(), Box<dyn std::error::Error>> {
4260 let temporary = tempfile::tempdir()?;
4261 let database = temporary.path().join("invalid.db");
4262 let connection = rusqlite::Connection::open(&database)?;
4263 connection.execute_batch(INITIAL_SCHEMA)?;
4264 connection.execute_batch(
4265 "ALTER TABLE extractor_batches DROP COLUMN budget_fingerprint;
4266 ALTER TABLE extractor_batches DROP COLUMN source_was_lossy;",
4267 )?;
4268 drop(connection);
4269
4270 let result = SqliteStore::open(&database);
4271
4272 assert!(matches!(result, Err(StoreError::InvalidSchema)));
4273 Ok(())
4274 }
4275
4276 #[test]
4277 fn read_only_open_should_reject_non_exact_schema() -> Result<(), Box<dyn std::error::Error>> {
4278 let temporary = tempfile::tempdir()?;
4279 let database = temporary.path().join("uninitialized.db");
4280 let connection = rusqlite::Connection::open(&database)?;
4281 connection.execute_batch(
4282 "CREATE TABLE schema_metadata (
4283 version INTEGER PRIMARY KEY,
4284 applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
4285 );",
4286 )?;
4287 drop(connection);
4288
4289 let result = SqliteStore::open_read_only(&database);
4290
4291 assert!(matches!(result, Err(StoreError::InvalidSchema)));
4292 Ok(())
4293 }
4294
4295 #[test]
4296 fn workspace_registry_should_round_trip_native_paths() {
4297 let mut store = match SqliteStore::in_memory() {
4298 Ok(store) => store,
4299 Err(error) => panic!("test store must initialize: {error}"),
4300 };
4301 let expected = workspace();
4302 let result = store
4303 .save_workspace_registry(&expected)
4304 .and_then(|()| store.load_workspace_registry("commerce"));
4305
4306 assert!(matches!(result, Ok(actual) if actual == expected));
4307 }
4308
4309 #[test]
4310 fn workspace_registry_should_list_repository_counts() {
4311 let mut store = match SqliteStore::in_memory() {
4312 Ok(store) => store,
4313 Err(error) => panic!("test store must initialize: {error}"),
4314 };
4315 let result = store
4316 .save_workspace_registry(&workspace())
4317 .and_then(|()| store.list_workspaces());
4318
4319 assert!(matches!(
4320 result,
4321 Ok(workspaces)
4322 if workspaces.len() == 1
4323 && workspaces[0].name == "commerce"
4324 && workspaces[0].repository_count == 1
4325 ));
4326 }
4327
4328 #[test]
4329 fn workspace_registry_should_remove_and_collect_orphans() {
4330 let mut store = match SqliteStore::in_memory() {
4331 Ok(store) => store,
4332 Err(error) => panic!("test store must initialize: {error}"),
4333 };
4334 let result = store
4335 .save_workspace_registry(&workspace())
4336 .and_then(|()| store.remove_workspace("commerce"))
4337 .and_then(|removed| {
4338 let repositories =
4339 store
4340 .connection
4341 .query_row("SELECT COUNT(*) FROM repositories", [], |row| {
4342 row.get::<_, i64>(0)
4343 })?;
4344 Ok((removed, store.workspace_exists("commerce")?, repositories))
4345 });
4346
4347 assert!(matches!(result, Ok((true, false, 0))));
4348 }
4349
4350 #[test]
4351 fn failed_snapshot_should_preserve_previous_current_snapshot() {
4352 let mut store = match SqliteStore::in_memory() {
4353 Ok(store) => store,
4354 Err(error) => panic!("test store must initialize: {error}"),
4355 };
4356 let workspace = workspace();
4357 let (nodes, edges, evidence) = fixture();
4358 let first = store.publish_snapshot(SnapshotBatch {
4359 workspace: &workspace,
4360 snapshot_id: "snapshot:valid",
4361 nodes: &nodes,
4362 edges: &edges,
4363 evidence: &evidence,
4364 fingerprints: &[],
4365 extractor_batches: &[],
4366 extractor_runs: &[],
4367 manual_links: &[],
4368 community_snapshot: None,
4369 });
4370 assert!(first.is_ok(), "valid fixture failed: {first:?}");
4371 let mut invalid_edges = edges;
4372 invalid_edges[0].evidence = vec![EvidenceId::new("evidence:missing")];
4373 let failed = store.publish_snapshot(SnapshotBatch {
4374 workspace: &workspace,
4375 snapshot_id: "snapshot:invalid",
4376 nodes: &nodes,
4377 edges: &invalid_edges,
4378 evidence: &evidence,
4379 fingerprints: &[],
4380 extractor_batches: &[],
4381 extractor_runs: &[],
4382 manual_links: &[],
4383 community_snapshot: None,
4384 });
4385 let result = failed
4386 .and_then(|()| store.load_current_graph("commerce"))
4387 .map(|_| false)
4388 .or_else(|_| {
4389 store
4390 .load_current_graph("commerce")
4391 .map(|(_, current_edges)| current_edges[0].id.as_str() == "edge:1")
4392 });
4393
4394 assert!(matches!(result, Ok(true)));
4395 }
4396
4397 #[test]
4398 fn open_should_initialize_schema_only_once() -> Result<(), Box<dyn std::error::Error>> {
4399 let temporary = tempfile::tempdir()?;
4400 let database = temporary.path().join("store.db");
4401
4402 let initial = SqliteStore::open(&database)?;
4403 assert_eq!(initial.schema_version()?, 1);
4404 drop(initial);
4405 let repeated = SqliteStore::open(&database)?;
4406 assert_eq!(repeated.schema_version()?, 1);
4407 Ok(())
4408 }
4409
4410 #[test]
4411 fn wal_should_allow_readers_during_snapshot_publication()
4412 -> Result<(), Box<dyn std::error::Error>> {
4413 let temporary = tempfile::tempdir()?;
4414 let database = temporary.path().join("concurrent.db");
4415 let workspace = workspace();
4416 let (nodes, edges, evidence) = fixture();
4417 let mut writer = SqliteStore::open(&database)?;
4418 writer.publish_snapshot(SnapshotBatch {
4419 workspace: &workspace,
4420 snapshot_id: "snapshot:0",
4421 nodes: &nodes,
4422 edges: &edges,
4423 evidence: &evidence,
4424 fingerprints: &[],
4425 extractor_batches: &[],
4426 extractor_runs: &[],
4427 manual_links: &[],
4428 community_snapshot: None,
4429 })?;
4430 let reader_paths = [database.clone(), database];
4431 let readers = reader_paths.map(|path| {
4432 std::thread::spawn(move || -> Result<(), String> {
4433 for _iteration in 0..25 {
4434 SqliteStore::open_read_only(&path)
4435 .and_then(|store| store.load_current_graph("commerce"))
4436 .map_err(|error| error.to_string())?;
4437 }
4438 Ok(())
4439 })
4440 });
4441 for sequence in 1..=10 {
4442 writer.publish_snapshot(SnapshotBatch {
4443 workspace: &workspace,
4444 snapshot_id: &format!("snapshot:{sequence}"),
4445 nodes: &nodes,
4446 edges: &edges,
4447 evidence: &evidence,
4448 fingerprints: &[],
4449 extractor_batches: &[],
4450 extractor_runs: &[],
4451 manual_links: &[],
4452 community_snapshot: None,
4453 })?;
4454 }
4455 let reader_results = readers.map(|reader| {
4456 reader
4457 .join()
4458 .map_err(|_| "reader thread panicked".to_owned())
4459 .and_then(|result| result)
4460 });
4461
4462 assert!(reader_results.into_iter().all(|result| result.is_ok()));
4463 Ok(())
4464 }
4465
4466 #[test]
4467 fn writer_lock_should_reject_second_active_owner() -> Result<(), Box<dyn std::error::Error>> {
4468 let temporary = tempfile::tempdir()?;
4469 let database = temporary.path().join("store.db");
4470 let first = StoreLock::acquire(&database, Duration::from_mins(1))?;
4471 let second = StoreLock::acquire(&database, Duration::from_mins(1));
4472
4473 assert!(matches!(second, Err(StoreError::LockHeld(_))));
4474 drop(first);
4475 Ok(())
4476 }
4477
4478 #[test]
4479 fn writer_lock_should_recover_expired_sidecar() -> Result<(), Box<dyn std::error::Error>> {
4480 let temporary = tempfile::tempdir()?;
4481 let database = temporary.path().join("store.db");
4482 let sidecar = lock_path(&database);
4483 fs::write(&sidecar, "expired-owner")?;
4484 std::thread::sleep(Duration::from_millis(5));
4485 let lock = StoreLock::acquire(&database, Duration::from_millis(1));
4486
4487 assert!(lock.is_ok(), "stale lock was not recovered: {lock:?}");
4488 Ok(())
4489 }
4490
4491 #[test]
4492 fn writer_lock_should_not_reclaim_live_owner_after_threshold()
4493 -> Result<(), Box<dyn std::error::Error>> {
4494 let temporary = tempfile::tempdir()?;
4495 let database = temporary.path().join("store.db");
4496 let first = StoreLock::acquire(&database, Duration::ZERO)?;
4497 std::thread::sleep(Duration::from_millis(2));
4498 let second = StoreLock::acquire(&database, Duration::ZERO);
4499
4500 assert!(matches!(second, Err(StoreError::LockHeld(_))));
4501 drop(first);
4502 Ok(())
4503 }
4504
4505 #[test]
4506 fn multiprocess_lock_helper() -> Result<(), Box<dyn std::error::Error>> {
4507 if std::env::var(LOCK_HELPER_ENV).as_deref() != Ok("1") {
4508 return Ok(());
4509 }
4510 let database = std::env::var_os(LOCK_DATABASE_ENV)
4511 .map(std::path::PathBuf::from)
4512 .ok_or_else(|| std::io::Error::other("lock database environment is missing"))?;
4513 let ready = std::env::var_os(LOCK_READY_ENV)
4514 .map(std::path::PathBuf::from)
4515 .ok_or_else(|| std::io::Error::other("lock ready environment is missing"))?;
4516 let _lock = StoreLock::acquire(&database, Duration::from_hours(1))?;
4517 fs::write(ready, "ready")?;
4518 std::thread::sleep(Duration::from_secs(30));
4519 Ok(())
4520 }
4521
4522 #[test]
4523 fn multiprocess_lock_should_recover_after_abrupt_owner_exit()
4524 -> Result<(), Box<dyn std::error::Error>> {
4525 let temporary = tempfile::tempdir()?;
4526 let database = temporary.path().join("store.db");
4527 let ready = temporary.path().join("ready");
4528 let executable = std::env::current_exe()?;
4529 let mut child = Command::new(executable)
4530 .args(["--exact", "tests::multiprocess_lock_helper", "--nocapture"])
4531 .env(LOCK_HELPER_ENV, "1")
4532 .env(LOCK_DATABASE_ENV, &database)
4533 .env(LOCK_READY_ENV, &ready)
4534 .spawn()?;
4535 let mut helper_ready = false;
4536 for _attempt in 0..200 {
4537 if ready.exists() {
4538 helper_ready = true;
4539 break;
4540 }
4541 std::thread::sleep(Duration::from_millis(5));
4542 }
4543 if !helper_ready {
4544 child.kill()?;
4545 let _status = child.wait()?;
4546 return Err(std::io::Error::other("lock helper did not become ready").into());
4547 }
4548 let while_alive = StoreLock::acquire(&database, Duration::ZERO);
4549 child.kill()?;
4550 let _status = child.wait()?;
4551 let after_exit = StoreLock::acquire(&database, Duration::ZERO);
4552
4553 assert_eq!(
4554 (
4555 matches!(while_alive, Err(StoreError::LockHeld(_))),
4556 after_exit.is_ok(),
4557 ),
4558 (true, true)
4559 );
4560 Ok(())
4561 }
4562
4563 #[test]
4564 fn published_snapshot_should_record_repository_freshness() {
4565 let mut store = match SqliteStore::in_memory() {
4566 Ok(store) => store,
4567 Err(error) => panic!("test store must initialize: {error}"),
4568 };
4569 let workspace = workspace();
4570 let (nodes, edges, evidence) = fixture();
4571 let result = store
4572 .publish_snapshot(SnapshotBatch {
4573 workspace: &workspace,
4574 snapshot_id: "snapshot:1",
4575 nodes: &nodes,
4576 edges: &edges,
4577 evidence: &evidence,
4578 fingerprints: &[],
4579 extractor_batches: &[],
4580 extractor_runs: &[],
4581 manual_links: &[],
4582 community_snapshot: None,
4583 })
4584 .and_then(|()| store.load_current_freshness("commerce"));
4585
4586 assert!(matches!(
4587 result,
4588 Ok(freshness)
4589 if freshness.len() == 1
4590 && freshness[0].state == RepoFreshnessState::Fresh
4591 ));
4592 }
4593
4594 #[test]
4595 fn incremental_artifacts_and_runs_should_round_trip() {
4596 let mut store = match SqliteStore::in_memory() {
4597 Ok(store) => store,
4598 Err(error) => panic!("test store must initialize: {error}"),
4599 };
4600 let workspace = workspace();
4601 let (nodes, edges, evidence) = fixture();
4602 let (fingerprint, run) = incremental_fixture();
4603 let extractor_batch = StoredExtractorBatch {
4604 source: fingerprint.clone(),
4605 extractor_version: "1.0.0".to_owned(),
4606 budget_fingerprint: "extraction-budgets:test".to_owned(),
4607 source_was_lossy: false,
4608 output_count: 1,
4609 payload: br#"{"observations":["GET:/orders"]}"#.to_vec(),
4610 };
4611 let result = store
4612 .publish_snapshot(SnapshotBatch {
4613 workspace: &workspace,
4614 snapshot_id: "snapshot:1",
4615 nodes: &nodes,
4616 edges: &edges,
4617 evidence: &evidence,
4618 fingerprints: std::slice::from_ref(&fingerprint),
4619 extractor_batches: std::slice::from_ref(&extractor_batch),
4620 extractor_runs: std::slice::from_ref(&run),
4621 manual_links: &[],
4622 community_snapshot: None,
4623 })
4624 .and_then(|()| {
4625 Ok((
4626 store.load_current_artifact_fingerprints("commerce")?,
4627 store.load_current_extractor_batches("commerce")?,
4628 store.load_current_extractor_runs("commerce")?,
4629 ))
4630 });
4631
4632 assert!(matches!(
4633 result,
4634 Ok((fingerprints, batches, runs))
4635 if fingerprints == vec![fingerprint]
4636 && batches == vec![extractor_batch]
4637 && runs == vec![run]
4638 ));
4639 assert!(
4640 store
4641 .load_current_extractor_batches_with_limit("commerce", 1)
4642 .expect("bounded batch read")
4643 .is_empty(),
4644 "SQLite must omit an oversized payload before copying its BLOB"
4645 );
4646 }
4647
4648 #[test]
4649 fn manual_links_should_round_trip_and_preserve_snapshot_history()
4650 -> Result<(), Box<dyn std::error::Error>> {
4651 let mut store = SqliteStore::in_memory()?;
4652 let workspace = workspace();
4653 let (nodes, edges, evidence) = fixture();
4654 let historical_links = manual_link_fixture("snapshot:historical-links");
4655 store.publish_snapshot(SnapshotBatch {
4656 workspace: &workspace,
4657 snapshot_id: "snapshot:historical-links",
4658 nodes: &nodes,
4659 edges: &edges,
4660 evidence: &evidence,
4661 fingerprints: &[],
4662 extractor_batches: &[],
4663 extractor_runs: &[],
4664 manual_links: &historical_links,
4665 community_snapshot: None,
4666 })?;
4667 store.publish_snapshot(SnapshotBatch {
4668 workspace: &workspace,
4669 snapshot_id: "snapshot:current-links",
4670 nodes: &nodes,
4671 edges: &edges,
4672 evidence: &evidence,
4673 fingerprints: &[],
4674 extractor_batches: &[],
4675 extractor_runs: &[],
4676 manual_links: &[],
4677 community_snapshot: None,
4678 })?;
4679 let mut current_links = manual_link_fixture("snapshot:current-links");
4680 current_links.truncate(1);
4681 store.persist_manual_links("snapshot:current-links", ¤t_links)?;
4682
4683 let stored_historical = store.load_manual_links("snapshot:historical-links")?;
4684 let stored_current = store.load_manual_links("snapshot:current-links")?;
4685
4686 assert_eq!(
4687 (stored_historical, stored_current),
4688 (historical_links, current_links)
4689 );
4690 Ok(())
4691 }
4692
4693 #[test]
4694 fn provider_capabilities_should_upsert_and_round_trip_without_provider_payloads()
4695 -> Result<(), Box<dyn std::error::Error>> {
4696 let mut store = SqliteStore::in_memory()?;
4697 store.save_workspace_registry(&workspace())?;
4698 let mut expected = ProviderCapabilityRecord {
4699 workspace_name: "commerce".to_owned(),
4700 repo_id: RepoId::new("repo:web"),
4701 provider: "codegraph".to_owned(),
4702 provider_version: "1.2.3".to_owned(),
4703 capabilities: vec!["call_paths".to_owned(), "symbols".to_owned()],
4704 observed_at_unix_ms: 100,
4705 };
4706 store.upsert_provider_capabilities(&expected)?;
4707 expected.capabilities = vec!["symbols".to_owned()];
4708 expected.observed_at_unix_ms = 200;
4709 store.upsert_provider_capabilities(&expected)?;
4710
4711 let actual = store.load_provider_capabilities(
4712 "commerce",
4713 &RepoId::new("repo:web"),
4714 "codegraph",
4715 "1.2.3",
4716 )?;
4717
4718 assert_eq!(actual, Some(expected));
4719 Ok(())
4720 }
4721
4722 #[test]
4723 fn clear_query_cache_should_remove_only_selected_workspace()
4724 -> Result<(), Box<dyn std::error::Error>> {
4725 let mut store = SqliteStore::in_memory()?;
4726 let workspace = workspace();
4727 let (nodes, edges, evidence) = fixture();
4728 store.publish_snapshot(SnapshotBatch {
4729 workspace: &workspace,
4730 snapshot_id: "snapshot:cache-commerce",
4731 nodes: &nodes,
4732 edges: &edges,
4733 evidence: &evidence,
4734 fingerprints: &[],
4735 extractor_batches: &[],
4736 extractor_runs: &[],
4737 manual_links: &[],
4738 community_snapshot: None,
4739 })?;
4740 let mut other_workspace = workspace.clone();
4741 other_workspace.id = WorkspaceId::new("workspace:other");
4742 other_workspace.name = "other".to_owned();
4743 store.publish_snapshot(SnapshotBatch {
4744 workspace: &other_workspace,
4745 snapshot_id: "snapshot:cache-other",
4746 nodes: &nodes,
4747 edges: &edges,
4748 evidence: &evidence,
4749 fingerprints: &[],
4750 extractor_batches: &[],
4751 extractor_runs: &[],
4752 manual_links: &[],
4753 community_snapshot: None,
4754 })?;
4755 for (workspace_name, snapshot_id) in [
4756 ("commerce", "snapshot:cache-commerce"),
4757 ("other", "snapshot:cache-other"),
4758 ] {
4759 store.connection.execute(
4760 "INSERT INTO query_cache(
4761 workspace_name, snapshot_id, input_fingerprint, result_summary_json,
4762 stored_at_unix_ms
4763 ) VALUES (?1, ?2, 'input:1', ?3, 1)",
4764 params![workspace_name, snapshot_id, b"{}".as_slice()],
4765 )?;
4766 }
4767
4768 let removed = store.clear_query_cache("commerce")?;
4769 let commerce_count = store.connection.query_row(
4770 "SELECT COUNT(*) FROM query_cache WHERE workspace_name = 'commerce'",
4771 [],
4772 |row| row.get::<_, i64>(0),
4773 )?;
4774 let other_count = store.connection.query_row(
4775 "SELECT COUNT(*) FROM query_cache WHERE workspace_name = 'other'",
4776 [],
4777 |row| row.get::<_, i64>(0),
4778 )?;
4779
4780 assert_eq!((removed, commerce_count, other_count), (1, 0, 1));
4781 Ok(())
4782 }
4783
4784 #[test]
4785 fn query_cache_should_round_trip_exact_unexpired_result()
4786 -> Result<(), Box<dyn std::error::Error>> {
4787 let mut store = SqliteStore::in_memory()?;
4788 let workspace = workspace();
4789 let (nodes, edges, evidence) = fixture();
4790 store.publish_snapshot(SnapshotBatch {
4791 workspace: &workspace,
4792 snapshot_id: "snapshot:query-cache",
4793 nodes: &nodes,
4794 edges: &edges,
4795 evidence: &evidence,
4796 fingerprints: &[],
4797 extractor_batches: &[],
4798 extractor_runs: &[],
4799 manual_links: &[],
4800 community_snapshot: None,
4801 })?;
4802 let expected = QueryCacheRecord {
4803 workspace_name: "commerce".to_owned(),
4804 snapshot_id: "snapshot:query-cache".to_owned(),
4805 input_fingerprint: "query:one".to_owned(),
4806 result_summary_json: br#"{"schema_version":1}"#.to_vec(),
4807 stored_at_unix_ms: 100,
4808 expires_at_unix_ms: Some(200),
4809 };
4810 store.put_query_cache(&expected)?;
4811
4812 let current =
4813 store.load_query_cache("commerce", "snapshot:query-cache", "query:one", 150)?;
4814 let expired =
4815 store.load_query_cache("commerce", "snapshot:query-cache", "query:one", 201)?;
4816
4817 assert_eq!((current, expired), (Some(expected), None));
4818 Ok(())
4819 }
4820
4821 #[test]
4822 fn query_cache_should_bound_entries_per_workspace() -> Result<(), Box<dyn std::error::Error>> {
4823 let mut store = SqliteStore::in_memory()?;
4824 let workspace = workspace();
4825 let (nodes, edges, evidence) = fixture();
4826 store.publish_snapshot(SnapshotBatch {
4827 workspace: &workspace,
4828 snapshot_id: "snapshot:bounded-cache",
4829 nodes: &nodes,
4830 edges: &edges,
4831 evidence: &evidence,
4832 fingerprints: &[],
4833 extractor_batches: &[],
4834 extractor_runs: &[],
4835 manual_links: &[],
4836 community_snapshot: None,
4837 })?;
4838 for sequence in 0..=1024 {
4839 store.connection.execute(
4840 "INSERT INTO query_cache(
4841 workspace_name, snapshot_id, input_fingerprint, result_summary_json,
4842 stored_at_unix_ms
4843 ) VALUES ('commerce', 'snapshot:bounded-cache', ?1, ?2, ?3)",
4844 params![
4845 format!("input:{sequence}"),
4846 b"{}".as_slice(),
4847 i64::from(sequence)
4848 ],
4849 )?;
4850 }
4851 let bounded = store.connection.query_row(
4852 "SELECT COUNT(*), MIN(stored_at_unix_ms) FROM query_cache
4853 WHERE workspace_name = 'commerce'",
4854 [],
4855 |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)),
4856 )?;
4857
4858 assert_eq!(bounded, (1024, 1));
4859 Ok(())
4860 }
4861
4862 #[test]
4863 fn fts_should_search_only_current_snapshot_nodes() {
4864 let mut store = match SqliteStore::in_memory() {
4865 Ok(store) => store,
4866 Err(error) => panic!("test store must initialize: {error}"),
4867 };
4868 let workspace = workspace();
4869 let (nodes, edges, evidence) = fixture();
4870 let result = store
4871 .publish_snapshot(SnapshotBatch {
4872 workspace: &workspace,
4873 snapshot_id: "snapshot:1",
4874 nodes: &nodes,
4875 edges: &edges,
4876 evidence: &evidence,
4877 fingerprints: &[],
4878 extractor_batches: &[],
4879 extractor_runs: &[],
4880 manual_links: &[],
4881 community_snapshot: None,
4882 })
4883 .and_then(|()| store.search_current_nodes("commerce", "orders", 10));
4884
4885 assert!(matches!(result, Ok(matches) if matches.len() == 2));
4886 }
4887
4888 #[test]
4889 fn ranked_fts_should_order_equal_scores_by_stable_node_id() {
4890 let mut store = match SqliteStore::in_memory() {
4891 Ok(store) => store,
4892 Err(error) => panic!("test store must initialize: {error}"),
4893 };
4894 let workspace = workspace();
4895 let (nodes, edges, evidence) = fixture();
4896 let setup = store.publish_snapshot(SnapshotBatch {
4897 workspace: &workspace,
4898 snapshot_id: "snapshot:ranked",
4899 nodes: &nodes,
4900 edges: &edges,
4901 evidence: &evidence,
4902 fingerprints: &[],
4903 extractor_batches: &[],
4904 extractor_runs: &[],
4905 manual_links: &[],
4906 community_snapshot: None,
4907 });
4908 assert!(setup.is_ok(), "ranked FTS fixture failed: {setup:?}");
4909
4910 let first = store.search_current_nodes_ranked("commerce", "orders", 500);
4911 let second = store.search_current_nodes_ranked("commerce", "orders", 500);
4912
4913 assert!(matches!(
4914 (first, second),
4915 (Ok(first), Ok(second))
4916 if first == second
4917 && first.len() == 2
4918 && first[0].node.id.as_str() == "node:api"
4919 && first[1].node.id.as_str() == "node:web"
4920 && first[0].fts_rank.to_bits() == first[1].fts_rank.to_bits()
4921 ));
4922 }
4923
4924 #[test]
4925 fn fts_should_cleanup_replaced_and_removed_snapshots() {
4926 let mut store = match SqliteStore::in_memory() {
4927 Ok(store) => store,
4928 Err(error) => panic!("test store must initialize: {error}"),
4929 };
4930 let workspace = workspace();
4931 let (nodes, edges, evidence) = fixture();
4932 let publish = |store: &mut SqliteStore| {
4933 store.publish_snapshot(SnapshotBatch {
4934 workspace: &workspace,
4935 snapshot_id: "snapshot:same",
4936 nodes: &nodes,
4937 edges: &edges,
4938 evidence: &evidence,
4939 fingerprints: &[],
4940 extractor_batches: &[],
4941 extractor_runs: &[],
4942 manual_links: &[],
4943 community_snapshot: None,
4944 })
4945 };
4946 let result = publish(&mut store)
4947 .and_then(|()| publish(&mut store))
4948 .and_then(|()| {
4949 let before_remove =
4950 store
4951 .connection
4952 .query_row("SELECT COUNT(*) FROM nodes_fts", [], |row| {
4953 row.get::<_, i64>(0)
4954 })?;
4955 store.remove_workspace("commerce")?;
4956 let after_remove = store
4957 .connection
4958 .query_row("SELECT COUNT(*) FROM nodes_fts", [], |row| {
4959 row.get::<_, i64>(0)
4960 })
4961 .map_err(StoreError::from)?;
4962 Ok((before_remove, after_remove))
4963 });
4964
4965 assert!(matches!(result, Ok((2, 0))));
4966 }
4967}