1use super::{
4 AtlasStore, DbError, DbResult, IndexPublicationGuard, IndexPublicationState, count_to_usize,
5 with_sqlite_read_progress,
6};
7use crate::derived_snapshot::{CapturedGraph, SnapshotBudget};
8use crate::project_identity::{
9 load_graph_generation, load_project_identity, require_bound_project_identity,
10 set_graph_generation, set_project_identity, verify_project_identity,
11};
12use crate::schema::PROJECT_ROOT_KEY;
13use projectatlas_core::graph::{
14 CanonicalResolutionKey, Completeness, ConfidenceClass, CoverageRecord, CoverageScope,
15 CoverageState, EntityResolutionKey, EntitySelector, ExtendedRelationKind, ExternalSelector,
16 GraphContractError, GraphEntity, GraphEntityKey, GraphIdentityText, GraphLimitKind,
17 GraphLimits, GraphRelationKind, LogicalRelation, PackageSelector, ProjectInstanceId,
18 RelationDependencyKey, RelationOccurrence, RelationResolution, RepositoryFilePath,
19 RepositoryNodePath, ResolutionKeyDomain, SourceSpan, SymbolSelector,
20};
21use projectatlas_core::symbols::{ParserKind, RelationKind, SymbolKind};
22use projectatlas_core::{
23 IndexGeneration, IndexWorkControl, IndexWorkStage, NodeKind, RankedConnection,
24 RankedConnectionCount, RankedConnectionDirection, RankedConnectionKind, RankedConnectionTarget,
25 normalize_native_path_display,
26};
27use rusqlite::types::Value;
28use rusqlite::{
29 Connection, OpenFlags, OptionalExtension, Row, Transaction, params, params_from_iter,
30};
31use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
32use std::num::{NonZeroU32, NonZeroU64};
33use std::path::Path;
34
35pub struct RepositoryGraphStagingGuard<'store> {
37 transaction: Transaction<'store>,
39 project: ProjectInstanceId,
41 generation: IndexGeneration,
43}
44
45const GRAPH_STAGING_MARKER_KEY: &str = "repository_graph_staging";
47const GRAPH_STAGING_MARKER_VALUE: &str = "v1";
49
50#[derive(Clone, Debug, Eq, PartialEq)]
52pub struct RepositoryGraphPage<T> {
53 pub rows: Vec<T>,
55 pub truncated: bool,
57}
58
59#[derive(Clone, Copy, Debug, Eq, PartialEq)]
61pub struct RepositoryGraphReadBudget {
62 requested_rows: NonZeroU32,
64 returned_rows: NonZeroU32,
66 decoded_bytes: NonZeroU64,
68 hydrated_entities: NonZeroU32,
70 hydrated_paths: NonZeroU32,
72}
73
74impl RepositoryGraphReadBudget {
75 pub const MAX_REQUESTED_ROWS: u32 = GraphLimits::MAX_ROWS;
77 pub const MAX_RETURNED_ROWS: u32 = GraphLimits::MAX_ROWS;
79 pub const MAX_DECODED_BYTES: u64 = 32 * 1_024 * 1_024;
81 pub const MAX_HYDRATED_ENTITIES: u32 = 2 * (GraphLimits::MAX_ROWS + 1);
83 pub const MAX_HYDRATED_PATHS: u32 = 2 * (GraphLimits::MAX_ROWS + 1);
85
86 pub fn new(
93 requested_rows: u32,
94 returned_rows: u32,
95 decoded_bytes: u64,
96 hydrated_entities: u32,
97 hydrated_paths: u32,
98 ) -> Result<Self, GraphContractError> {
99 if requested_rows == 0 || requested_rows > Self::MAX_REQUESTED_ROWS {
100 return Err(GraphContractError::InvalidLimits {
101 reason: "graph read requested-row budget is zero or above the batch ceiling",
102 });
103 }
104 if returned_rows == 0 || returned_rows > Self::MAX_RETURNED_ROWS {
105 return Err(GraphContractError::InvalidLimits {
106 reason: "graph read returned-row budget is zero or above the batch ceiling",
107 });
108 }
109 if decoded_bytes == 0 || decoded_bytes > Self::MAX_DECODED_BYTES {
110 return Err(GraphContractError::InvalidLimits {
111 reason: "graph read decoded-byte budget is zero or above the batch ceiling",
112 });
113 }
114 if hydrated_entities == 0 || hydrated_entities > Self::MAX_HYDRATED_ENTITIES {
115 return Err(GraphContractError::InvalidLimits {
116 reason: "graph read entity budget is zero or above the batch ceiling",
117 });
118 }
119 if hydrated_paths == 0 || hydrated_paths > Self::MAX_HYDRATED_PATHS {
120 return Err(GraphContractError::InvalidLimits {
121 reason: "graph read path budget is zero or above the batch ceiling",
122 });
123 }
124 Ok(Self {
125 requested_rows: NonZeroU32::new(requested_rows).ok_or(
126 GraphContractError::InvalidLimits {
127 reason: "graph read requested-row budget must be nonzero",
128 },
129 )?,
130 returned_rows: NonZeroU32::new(returned_rows).ok_or(
131 GraphContractError::InvalidLimits {
132 reason: "graph read returned-row budget must be nonzero",
133 },
134 )?,
135 decoded_bytes: NonZeroU64::new(decoded_bytes).ok_or(
136 GraphContractError::InvalidLimits {
137 reason: "graph read decoded-byte budget must be nonzero",
138 },
139 )?,
140 hydrated_entities: NonZeroU32::new(hydrated_entities).ok_or(
141 GraphContractError::InvalidLimits {
142 reason: "graph read entity budget must be nonzero",
143 },
144 )?,
145 hydrated_paths: NonZeroU32::new(hydrated_paths).ok_or(
146 GraphContractError::InvalidLimits {
147 reason: "graph read path budget must be nonzero",
148 },
149 )?,
150 })
151 }
152
153 #[must_use]
155 pub const fn requested_rows(self) -> u32 {
156 self.requested_rows.get()
157 }
158
159 #[must_use]
161 pub const fn returned_rows(self) -> u32 {
162 self.returned_rows.get()
163 }
164
165 #[must_use]
167 pub const fn decoded_bytes(self) -> u64 {
168 self.decoded_bytes.get()
169 }
170
171 #[must_use]
173 pub const fn hydrated_entities(self) -> u32 {
174 self.hydrated_entities.get()
175 }
176
177 #[must_use]
179 pub const fn hydrated_paths(self) -> u32 {
180 self.hydrated_paths.get()
181 }
182}
183
184#[derive(Clone, Copy, Debug, Eq, PartialEq)]
186pub struct RepositoryGraphReadWork {
187 pub requested_rows: u32,
189 pub returned_rows: u32,
191 pub decoded_bytes: u64,
193 pub hydrated_entities: u32,
195 pub hydrated_paths: u32,
197}
198
199#[derive(Clone, Debug, Eq, PartialEq)]
201pub struct RepositoryGraphReadBatch<T> {
202 pub rows: Vec<T>,
204 pub work: RepositoryGraphReadWork,
206}
207
208#[derive(Clone, Debug, Eq, PartialEq)]
210pub struct RepositoryGraphReadPage<T> {
211 pub page: RepositoryGraphPage<T>,
213 pub work: RepositoryGraphReadWork,
215}
216
217#[derive(Clone, Debug, Eq, PartialEq)]
219pub struct RepositoryGraphReadPages<T> {
220 pub pages: Vec<RepositoryGraphPage<T>>,
222 pub work: RepositoryGraphReadWork,
224}
225
226#[derive(Clone, Debug, Eq, PartialEq)]
228pub struct RepositoryCoverageQuery {
229 pub start_index: u32,
231 pub limit: u32,
233 pub path_prefix: Option<String>,
235 pub parser: Option<ParserKind>,
237 pub provider: Option<ParserKind>,
239 pub relation: Option<GraphRelationKind>,
241 pub state: Option<CoverageState>,
243 pub reason: Option<String>,
245}
246
247#[derive(Clone, Debug, Eq, PartialEq)]
249pub struct RepositoryCoverageRow {
250 pub coverage: CoverageRecord,
252 pub parser: Option<ParserKind>,
254 pub provider: Option<ParserKind>,
256}
257
258#[derive(Clone, Debug, Eq, PartialEq)]
260pub struct RepositoryNavigationNode {
261 pub path: String,
263 pub kind: NodeKind,
265}
266
267#[derive(Clone, Debug, Eq, PartialEq)]
269pub struct RepositoryNavigationConnections {
270 pub path: String,
272 pub counts: Vec<RankedConnectionCount>,
274 pub connections: Vec<RankedConnection>,
276 pub truncated: bool,
278}
279
280const NAVIGATION_CONNECTION_OWNER_CHUNK: usize = 8;
282
283const NAVIGATION_CONNECTION_FAMILIES: &[(RankedConnectionKind, &str, &str)] = &[
285 (RankedConnectionKind::Package, "legacy", "depends-on"),
286 (RankedConnectionKind::Import, "legacy", "imports"),
287 (RankedConnectionKind::Call, "legacy", "calls"),
288 (RankedConnectionKind::Reference, "extended", "references"),
289 (RankedConnectionKind::Test, "extended", "tests"),
290 (RankedConnectionKind::Route, "extended", "routes-to"),
291 (RankedConnectionKind::Config, "extended", "configures"),
292];
293
294#[derive(Clone, Copy, Debug, Eq, PartialEq)]
296pub struct RepositoryAffectedSourceFootprint {
297 pub rows: u64,
299 pub retained_bytes: u64,
301 pub truncated: bool,
303}
304
305#[derive(Clone, Debug, Eq, PartialEq)]
307pub struct RepositoryResolutionCandidate {
308 key: CanonicalResolutionKey,
310 entity: GraphEntity,
312}
313
314impl RepositoryResolutionCandidate {
315 #[must_use]
317 pub const fn key(&self) -> &CanonicalResolutionKey {
318 &self.key
319 }
320
321 #[must_use]
323 pub const fn entity(&self) -> &GraphEntity {
324 &self.entity
325 }
326}
327
328#[derive(Clone, Debug, Eq, PartialEq)]
330pub enum RepositoryGraphRelationQuery {
331 Outbound {
333 source: GraphEntityKey,
335 },
336 Inbound {
338 target: GraphEntityKey,
340 },
341 Family {
343 relation: GraphRelationKind,
345 },
346}
347
348#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
350pub enum RepositoryGraphDirection {
351 Outbound,
353 Inbound,
355}
356
357#[derive(Clone, Debug, Eq, PartialEq)]
359pub struct RepositoryGraphRelationRow {
360 pub relation: LogicalRelation,
362 pub source: GraphEntity,
364 pub target: Option<GraphEntity>,
366}
367
368#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
370pub struct RepositoryGraphAdjacencyContinuation {
371 project: ProjectInstanceId,
373 generation: IndexGeneration,
375 direction: RepositoryGraphDirection,
377 relation: Option<GraphRelationKind>,
379 frontier: Vec<[u8; 32]>,
381 frontier_index: u32,
383 relation_scope: String,
385 relation_kind: String,
387 relation_key: [u8; 32],
389}
390
391#[derive(Clone, Debug, Eq, PartialEq)]
393pub struct RepositoryGraphAdjacencyRow {
394 pub frontier_index: u32,
396 pub frontier: GraphEntityKey,
398 pub direction: RepositoryGraphDirection,
400 pub detail: RepositoryGraphRelationRow,
402}
403
404#[derive(Clone, Debug, Eq, PartialEq)]
406pub struct RepositoryGraphAdjacencyPage {
407 pub rows: Vec<RepositoryGraphAdjacencyRow>,
409 pub truncated: bool,
411 pub continuation: Option<RepositoryGraphAdjacencyContinuation>,
413}
414
415#[derive(Clone, Debug, Eq, PartialEq)]
417pub struct RepositoryGraphAdjacencyReadPage {
418 pub page: RepositoryGraphAdjacencyPage,
420 pub work: RepositoryGraphReadWork,
422}
423
424pub const MAX_REPOSITORY_GRAPH_FRONTIER: usize = 256;
426
427const MAX_REPOSITORY_GRAPH_ADJACENCY_WORK_ROWS: usize = GraphLimits::MAX_ROWS as usize + 1;
429
430const GRAPH_ENTITY_HYDRATION_CHUNK: usize = 128;
432
433struct EntityRow {
435 key: Vec<u8>,
437 project: Vec<u8>,
439 canonical: String,
441 kind: String,
443 repository_path: Option<String>,
445 package_manager: Option<String>,
447 package_name: Option<String>,
449 manifest_path: Option<String>,
451 symbol_name: Option<String>,
453 symbol_kind: Option<String>,
455 symbol_parent: Option<String>,
457 symbol_signature: Option<String>,
459 external_system: Option<String>,
461 external_identity: Option<String>,
463}
464
465struct RelationRow {
467 key: Vec<u8>,
469 project: Vec<u8>,
471 canonical: String,
473 source: Vec<u8>,
475 relation_scope: String,
477 relation_kind: String,
479 resolution_status: String,
481 target: Option<Vec<u8>>,
483 reference: Option<String>,
485 candidate_count: Option<i64>,
487 confidence: String,
489 completeness: String,
491}
492
493struct AdjacencyRelationRow {
495 frontier_index: u32,
497 relation: RelationRow,
499}
500
501struct OccurrenceRow {
503 relation: Vec<u8>,
505 file_path: String,
507 start_line: i64,
509 start_column: i64,
511 end_line: i64,
513 end_column: i64,
515}
516
517struct CoverageRow {
519 project: Vec<u8>,
521 scope_kind: String,
523 scope_path: Option<String>,
525 relation_scope: Option<String>,
527 relation_kind: Option<String>,
529 state: String,
531 total: i64,
533 covered: i64,
535 omitted: i64,
537 reason: Option<String>,
539 reached_limit: Option<String>,
541 parser: Option<String>,
543 provider: Option<String>,
545}
546
547pub(crate) struct RepositoryGraphReadMeter {
549 budget: RepositoryGraphReadBudget,
551 requested_rows: u32,
553 decoded_bytes: u64,
555 hydrated_entities: u32,
557 hydrated_paths: HashSet<String>,
559}
560
561impl RepositoryGraphReadMeter {
562 pub(crate) fn new(budget: RepositoryGraphReadBudget, requested_rows: usize) -> DbResult<Self> {
564 let requested_rows =
565 u32::try_from(requested_rows).map_err(|_source| GraphContractError::InvalidLimits {
566 reason: "graph read requested-row count overflowed",
567 })?;
568 if requested_rows > budget.requested_rows() {
569 return Err(GraphContractError::InvalidLimits {
570 reason: "graph read requested rows exceed the batch budget",
571 }
572 .into());
573 }
574 Ok(Self {
575 budget,
576 requested_rows,
577 decoded_bytes: 0,
578 hydrated_entities: 0,
579 hydrated_paths: HashSet::new(),
580 })
581 }
582
583 pub(crate) fn record_decoded_bytes(&mut self, bytes: u64) -> DbResult<()> {
585 let decoded_bytes =
586 self.decoded_bytes
587 .checked_add(bytes)
588 .ok_or(GraphContractError::InvalidLimits {
589 reason: "graph read decoded-byte accounting overflowed",
590 })?;
591 if decoded_bytes > self.budget.decoded_bytes() {
592 return Err(GraphContractError::InvalidLimits {
593 reason: "graph read decoded bytes exceed the batch budget",
594 }
595 .into());
596 }
597 self.decoded_bytes = decoded_bytes;
598 Ok(())
599 }
600
601 fn record_entity(&mut self, entity: &GraphEntity) -> DbResult<()> {
603 let hydrated_entities =
604 self.hydrated_entities
605 .checked_add(1)
606 .ok_or(GraphContractError::InvalidLimits {
607 reason: "graph read entity accounting overflowed",
608 })?;
609 if hydrated_entities > self.budget.hydrated_entities() {
610 return Err(GraphContractError::InvalidLimits {
611 reason: "graph read hydrated entities exceed the batch budget",
612 }
613 .into());
614 }
615 if let Some(path) = graph_entity_purpose_owner(entity) {
616 self.record_hydrated_path(path)?;
617 }
618 self.hydrated_entities = hydrated_entities;
619 Ok(())
620 }
621
622 pub(crate) fn record_hydrated_path(&mut self, path: &str) -> DbResult<()> {
624 if self.hydrated_paths.contains(path) {
625 return Ok(());
626 }
627 let hydrated_paths = u32::try_from(self.hydrated_paths.len()).map_err(|_source| {
628 GraphContractError::InvalidLimits {
629 reason: "graph read path accounting overflowed",
630 }
631 })?;
632 if hydrated_paths >= self.budget.hydrated_paths() {
633 return Err(GraphContractError::InvalidLimits {
634 reason: "graph read hydrated paths exceed the batch budget",
635 }
636 .into());
637 }
638 self.hydrated_paths.insert(path.to_string());
639 Ok(())
640 }
641
642 pub(crate) fn finish(self, returned_rows: usize) -> DbResult<RepositoryGraphReadWork> {
644 let returned_rows =
645 u32::try_from(returned_rows).map_err(|_source| GraphContractError::InvalidLimits {
646 reason: "graph read returned-row count overflowed",
647 })?;
648 if returned_rows > self.budget.returned_rows() {
649 return Err(GraphContractError::InvalidLimits {
650 reason: "graph read returned rows exceed the batch budget",
651 }
652 .into());
653 }
654 let hydrated_paths = u32::try_from(self.hydrated_paths.len()).map_err(|_source| {
655 GraphContractError::InvalidLimits {
656 reason: "graph read path accounting overflowed",
657 }
658 })?;
659 Ok(RepositoryGraphReadWork {
660 requested_rows: self.requested_rows,
661 returned_rows,
662 decoded_bytes: self.decoded_bytes,
663 hydrated_entities: self.hydrated_entities,
664 hydrated_paths,
665 })
666 }
667}
668
669struct NavigationConnectionRow {
671 owner_index: usize,
673 kind: RankedConnectionKind,
675 direction: RankedConnectionDirection,
677 relation_key: Vec<u8>,
679 resolution_status: String,
681 reference: Option<String>,
683 entity_kind: Option<String>,
685 repository_path: Option<String>,
687 package_manager: Option<String>,
689 package_name: Option<String>,
691 manifest_path: Option<String>,
693 symbol_name: Option<String>,
695 external_system: Option<String>,
697 external_identity: Option<String>,
699}
700
701fn empty_navigation_connections(path: &str) -> RepositoryNavigationConnections {
703 RepositoryNavigationConnections {
704 path: path.to_string(),
705 counts: Vec::new(),
706 connections: Vec::new(),
707 truncated: false,
708 }
709}
710
711fn collect_navigation_connection_rows(
713 connection: &Connection,
714 owners: &[RepositoryNavigationNode],
715 family_limit_plus_one: i64,
716) -> DbResult<Vec<NavigationConnectionRow>> {
717 let mut branches = Vec::with_capacity(owners.len() * NAVIGATION_CONNECTION_FAMILIES.len() * 2);
718 let mut values = Vec::new();
719 for (owner_index, owner) in owners.iter().enumerate() {
720 for &(kind, scope, relation) in NAVIGATION_CONNECTION_FAMILIES {
721 branches.push(navigation_connection_branch(
722 owner_index,
723 owner,
724 kind,
725 scope,
726 relation,
727 RankedConnectionDirection::Outbound,
728 family_limit_plus_one,
729 &mut values,
730 ));
731 if owner.kind != NodeKind::Folder || owner.path != "." {
732 branches.push(navigation_connection_branch(
733 owner_index,
734 owner,
735 kind,
736 scope,
737 relation,
738 RankedConnectionDirection::Inbound,
739 family_limit_plus_one,
740 &mut values,
741 ));
742 }
743 }
744 }
745 let sql = branches.join(" UNION ALL ");
746 let mut statement = connection.prepare(&sql)?;
747 let mut rows = statement.query(params_from_iter(values))?;
748 let mut collected = Vec::new();
749 while let Some(row) = rows.next()? {
750 collected.push(navigation_connection_row(row)?);
751 }
752 Ok(collected)
753}
754
755fn navigation_connection_branch(
757 owner_index: usize,
758 owner: &RepositoryNavigationNode,
759 kind: RankedConnectionKind,
760 scope: &'static str,
761 relation: &'static str,
762 direction: RankedConnectionDirection,
763 family_limit_plus_one: i64,
764 values: &mut Vec<Value>,
765) -> String {
766 values.push(Value::Integer(owner_index as i64));
767 values.push(Value::Text(
768 navigation_connection_kind_name(kind).to_string(),
769 ));
770 if owner.kind == NodeKind::Folder
771 && owner.path == "."
772 && direction == RankedConnectionDirection::Outbound
773 {
774 values.push(Value::Text(scope.to_string()));
775 values.push(Value::Text(relation.to_string()));
776 values.push(Value::Integer(family_limit_plus_one));
777 return "SELECT * FROM (
778 SELECT ? AS owner_index, ? AS expected_kind, 'outbound' AS direction,
779 r.relation_key, r.relation_scope, r.relation_kind,
780 r.resolution_status, r.reference_text,
781 related.entity_kind, related.repository_path,
782 related.package_manager, related.package_name, related.manifest_path,
783 related.symbol_name, related.external_system, related.external_identity
784 FROM graph_relations r INDEXED BY idx_graph_relations_kind_order
785 LEFT JOIN graph_entities related
786 ON related.entity_key = r.target_entity_key
787 WHERE r.relation_scope = ? AND r.relation_kind = ?
788 ORDER BY r.relation_key
789 LIMIT ?
790 )"
791 .to_string();
792 }
793 let (relation_key, related_key, index, direction_name) = match direction {
794 RankedConnectionDirection::Outbound => (
795 "source_entity_key",
796 "target_entity_key",
797 "idx_graph_relations_source_kind",
798 "outbound",
799 ),
800 RankedConnectionDirection::Inbound => (
801 "target_entity_key",
802 "source_entity_key",
803 "idx_graph_relations_target_kind",
804 "inbound",
805 ),
806 };
807 let owned = navigation_owned_entity_sql(owner, values);
808 let exclude_internal = if direction == RankedConnectionDirection::Inbound {
809 let owned_sources = navigation_owned_entity_sql(owner, values);
810 format!(" AND r.source_entity_key NOT IN ({owned_sources})")
811 } else {
812 String::new()
813 };
814 values.push(Value::Text(scope.to_string()));
815 values.push(Value::Text(relation.to_string()));
816 values.push(Value::Integer(family_limit_plus_one));
817 format!(
818 "SELECT * FROM (
819 SELECT ? AS owner_index, ? AS expected_kind, '{direction_name}' AS direction,
820 r.relation_key, r.relation_scope, r.relation_kind,
821 r.resolution_status, r.reference_text,
822 related.entity_kind, related.repository_path,
823 related.package_manager, related.package_name, related.manifest_path,
824 related.symbol_name, related.external_system, related.external_identity
825 FROM graph_relations r INDEXED BY {index}
826 LEFT JOIN graph_entities related ON related.entity_key = r.{related_key}
827 WHERE r.{relation_key} IN ({owned})
828 {exclude_internal}
829 AND r.relation_scope = ? AND r.relation_kind = ?
830 ORDER BY r.relation_key
831 LIMIT ?
832 )"
833 )
834}
835
836fn navigation_owned_entity_sql(
838 owner: &RepositoryNavigationNode,
839 values: &mut Vec<Value>,
840) -> String {
841 match owner.kind {
842 NodeKind::File => {
843 values.push(Value::Text(owner.path.clone()));
844 values.push(Value::Text(owner.path.clone()));
845 "SELECT entity_key
846 FROM graph_entities INDEXED BY idx_graph_entities_path
847 WHERE repository_path = ?
848 UNION
849 SELECT entity_key
850 FROM graph_entities INDEXED BY idx_graph_entities_manifest_path
851 WHERE manifest_path = ?"
852 .to_string()
853 }
854 NodeKind::Folder if owner.path == "." => "SELECT entity_key
855 FROM graph_entities INDEXED BY idx_graph_entities_path
856 WHERE repository_path IS NOT NULL
857 UNION
858 SELECT entity_key
859 FROM graph_entities INDEXED BY idx_graph_entities_manifest_path
860 WHERE manifest_path IS NOT NULL"
861 .to_string(),
862 NodeKind::Folder => {
863 let lower = format!("{}/", owner.path);
864 let upper = format!("{}0", owner.path);
865 values.push(Value::Text(owner.path.clone()));
866 values.push(Value::Text(lower.clone()));
867 values.push(Value::Text(upper.clone()));
868 values.push(Value::Text(owner.path.clone()));
869 values.push(Value::Text(lower));
870 values.push(Value::Text(upper));
871 "SELECT entity_key
872 FROM graph_entities INDEXED BY idx_graph_entities_path
873 WHERE repository_path = ?
874 UNION
875 SELECT entity_key
876 FROM graph_entities INDEXED BY idx_graph_entities_path
877 WHERE repository_path >= ? AND repository_path < ?
878 UNION
879 SELECT entity_key
880 FROM graph_entities INDEXED BY idx_graph_entities_manifest_path
881 WHERE manifest_path = ?
882 UNION
883 SELECT entity_key
884 FROM graph_entities INDEXED BY idx_graph_entities_manifest_path
885 WHERE manifest_path >= ? AND manifest_path < ?"
886 .to_string()
887 }
888 }
889}
890
891fn navigation_connection_row(row: &Row<'_>) -> DbResult<NavigationConnectionRow> {
893 let owner_index = count_to_usize("navigation_connection.owner_index", row.get(0)?)?;
894 let expected_kind: String = row.get(1)?;
895 let direction = match row.get::<_, String>(2)?.as_str() {
896 "outbound" => RankedConnectionDirection::Outbound,
897 "inbound" => RankedConnectionDirection::Inbound,
898 _ => {
899 return Err(DbError::GraphRowShape {
900 table: "graph_relations",
901 reason: "navigation relation direction is invalid",
902 });
903 }
904 };
905 let relation_key: Vec<u8> = row.get(3)?;
906 if relation_key.len() != 32 {
907 return Err(DbError::InvalidBlobLength {
908 field: "graph_relations.relation_key",
909 expected: 32,
910 found: relation_key.len(),
911 });
912 }
913 let relation_scope: String = row.get(4)?;
914 let relation_kind: String = row.get(5)?;
915 let kind = navigation_connection_kind(&relation_scope, &relation_kind)?;
916 if navigation_connection_kind_name(kind) != expected_kind {
917 return Err(DbError::GraphRowShape {
918 table: "graph_relations",
919 reason: "navigation relation family does not match its query branch",
920 });
921 }
922 let resolution_status: String = row.get(6)?;
923 if !matches!(
924 resolution_status.as_str(),
925 "resolved" | "ambiguous" | "unresolved" | "external"
926 ) {
927 return Err(DbError::InvalidEnum {
928 field: "graph_relations.resolution_status",
929 value: resolution_status,
930 });
931 }
932 Ok(NavigationConnectionRow {
933 owner_index,
934 kind,
935 direction,
936 relation_key,
937 resolution_status,
938 reference: row.get(7)?,
939 entity_kind: row.get(8)?,
940 repository_path: row.get(9)?,
941 package_manager: row.get(10)?,
942 package_name: row.get(11)?,
943 manifest_path: row.get(12)?,
944 symbol_name: row.get(13)?,
945 external_system: row.get(14)?,
946 external_identity: row.get(15)?,
947 })
948}
949
950fn navigation_connection_pages(
952 owners: &[RepositoryNavigationNode],
953 mut rows: Vec<NavigationConnectionRow>,
954 family_limit: usize,
955 sample_limit: usize,
956) -> DbResult<Vec<RepositoryNavigationConnections>> {
957 rows.sort_by(|left, right| {
958 left.owner_index
959 .cmp(&right.owner_index)
960 .then_with(|| left.kind.cmp(&right.kind))
961 .then_with(|| {
962 navigation_direction_order(left.direction)
963 .cmp(&navigation_direction_order(right.direction))
964 })
965 .then_with(|| left.relation_key.cmp(&right.relation_key))
966 });
967 let mut grouped =
968 BTreeMap::<(usize, RankedConnectionKind), Vec<NavigationConnectionRow>>::new();
969 for row in rows {
970 if row.owner_index >= owners.len() {
971 return Err(DbError::GraphRowShape {
972 table: "graph_relations",
973 reason: "navigation relation owner index is outside its request chunk",
974 });
975 }
976 grouped
977 .entry((row.owner_index, row.kind))
978 .or_default()
979 .push(row);
980 }
981
982 owners
983 .iter()
984 .enumerate()
985 .map(|(owner_index, owner)| {
986 let mut page = empty_navigation_connections(&owner.path);
987 for &(kind, _, _) in NAVIGATION_CONNECTION_FAMILIES {
988 let Some(rows) = grouped.remove(&(owner_index, kind)) else {
989 continue;
990 };
991 let mut seen = HashSet::new();
992 let unique = rows
993 .into_iter()
994 .filter(|row| seen.insert(row.relation_key.clone()))
995 .collect::<Vec<_>>();
996 let truncated = unique.len() > family_limit;
997 let count = unique.len().min(family_limit);
998 page.counts.push(RankedConnectionCount {
999 kind,
1000 count,
1001 truncated,
1002 });
1003 for (family_index, row) in unique.into_iter().enumerate() {
1004 let target = navigation_connection_target(&row)?;
1005 if family_index < family_limit && page.connections.len() < sample_limit {
1006 page.connections.push(RankedConnection {
1007 kind,
1008 direction: row.direction,
1009 target,
1010 });
1011 } else if family_index < family_limit {
1012 page.truncated = true;
1013 }
1014 }
1015 page.truncated |= truncated;
1016 }
1017 Ok(page)
1018 })
1019 .collect()
1020}
1021
1022fn navigation_connection_target(row: &NavigationConnectionRow) -> DbResult<RankedConnectionTarget> {
1024 if row.direction == RankedConnectionDirection::Outbound
1025 && matches!(row.resolution_status.as_str(), "ambiguous" | "unresolved")
1026 {
1027 let reference = row.reference.clone().ok_or(DbError::GraphRowShape {
1028 table: "graph_relations",
1029 reason: "unresolved navigation relation is missing its reference",
1030 })?;
1031 if row.entity_kind.is_some() {
1032 return Err(DbError::GraphRowShape {
1033 table: "graph_relations",
1034 reason: "unresolved navigation relation retained a target entity",
1035 });
1036 }
1037 return Ok(RankedConnectionTarget::Unresolved { reference });
1038 }
1039 if !matches!(row.resolution_status.as_str(), "resolved" | "external") {
1040 return Err(DbError::GraphRowShape {
1041 table: "graph_relations",
1042 reason: "inbound navigation relation has no resolved target",
1043 });
1044 }
1045 match row.entity_kind.as_deref() {
1046 Some("project") => Ok(RankedConnectionTarget::Local {
1047 path: ".".to_string(),
1048 symbol: None,
1049 }),
1050 Some("folder" | "file") => Ok(RankedConnectionTarget::Local {
1051 path: required_navigation_target(
1052 row.repository_path.as_deref(),
1053 "local navigation target is missing its repository path",
1054 )?,
1055 symbol: None,
1056 }),
1057 Some("symbol") => Ok(RankedConnectionTarget::Local {
1058 path: required_navigation_target(
1059 row.repository_path.as_deref(),
1060 "symbol navigation target is missing its repository path",
1061 )?,
1062 symbol: Some(required_navigation_target(
1063 row.symbol_name.as_deref(),
1064 "symbol navigation target is missing its declaration name",
1065 )?),
1066 }),
1067 Some("package") => Ok(RankedConnectionTarget::Package {
1068 manager: required_navigation_target(
1069 row.package_manager.as_deref(),
1070 "package navigation target is missing its manager",
1071 )?,
1072 name: required_navigation_target(
1073 row.package_name.as_deref(),
1074 "package navigation target is missing its name",
1075 )?,
1076 manifest: required_navigation_target(
1077 row.manifest_path.as_deref(),
1078 "package navigation target is missing its manifest",
1079 )?,
1080 }),
1081 Some("external") => Ok(RankedConnectionTarget::External {
1082 system: required_navigation_target(
1083 row.external_system.as_deref(),
1084 "external navigation target is missing its system",
1085 )?,
1086 identity: required_navigation_target(
1087 row.external_identity.as_deref(),
1088 "external navigation target is missing its identity",
1089 )?,
1090 }),
1091 Some(_) => Err(DbError::GraphRowShape {
1092 table: "graph_entities",
1093 reason: "navigation endpoint has an unsupported entity kind",
1094 }),
1095 None => Err(DbError::GraphRowShape {
1096 table: "graph_relations",
1097 reason: "resolved navigation relation target is missing",
1098 }),
1099 }
1100}
1101
1102fn required_navigation_target(value: Option<&str>, reason: &'static str) -> DbResult<String> {
1104 value
1105 .filter(|value| !value.is_empty())
1106 .map(str::to_string)
1107 .ok_or(DbError::GraphRowShape {
1108 table: "graph_entities",
1109 reason,
1110 })
1111}
1112
1113fn navigation_connection_kind(scope: &str, relation: &str) -> DbResult<RankedConnectionKind> {
1115 NAVIGATION_CONNECTION_FAMILIES
1116 .iter()
1117 .find_map(|&(kind, expected_scope, expected_relation)| {
1118 (scope == expected_scope && relation == expected_relation).then_some(kind)
1119 })
1120 .ok_or(DbError::GraphRowShape {
1121 table: "graph_relations",
1122 reason: "relation family is not available to navigation enrichment",
1123 })
1124}
1125
1126const fn navigation_connection_kind_name(kind: RankedConnectionKind) -> &'static str {
1128 match kind {
1129 RankedConnectionKind::Package => "package",
1130 RankedConnectionKind::Import => "import",
1131 RankedConnectionKind::Call => "call",
1132 RankedConnectionKind::Reference => "reference",
1133 RankedConnectionKind::Test => "test",
1134 RankedConnectionKind::Route => "route",
1135 RankedConnectionKind::Config => "config",
1136 }
1137}
1138
1139const fn navigation_direction_order(direction: RankedConnectionDirection) -> u8 {
1141 match direction {
1142 RankedConnectionDirection::Outbound => 0,
1143 RankedConnectionDirection::Inbound => 1,
1144 }
1145}
1146
1147impl AtlasStore {
1148 pub fn repository_navigation_connections(
1160 &self,
1161 owners: &[RepositoryNavigationNode],
1162 family_limit: u32,
1163 sample_limit: usize,
1164 ) -> DbResult<Vec<RepositoryNavigationConnections>> {
1165 let family_limit_plus_one = validated_limit_plus_one(
1166 family_limit,
1167 GraphLimits::MAX_ROWS,
1168 "navigation connection rows must be nonzero and within the product ceiling",
1169 )?;
1170 if sample_limit == 0 || sample_limit > GraphLimits::MAX_ROWS as usize {
1171 return Err(GraphContractError::InvalidLimits {
1172 reason: "navigation connection sample must be nonzero and within the product ceiling",
1173 }
1174 .into());
1175 }
1176 for owner in owners {
1177 match owner.kind {
1178 NodeKind::Folder => {
1179 RepositoryNodePath::new(Path::new(&owner.path))?;
1180 }
1181 NodeKind::File => {
1182 RepositoryFilePath::new(Path::new(&owner.path))?;
1183 }
1184 }
1185 }
1186 if owners.is_empty() {
1187 return Ok(Vec::new());
1188 }
1189 if self.repository_graph_generation()?.is_none() {
1190 return Ok(owners
1191 .iter()
1192 .map(|owner| empty_navigation_connections(&owner.path))
1193 .collect());
1194 }
1195 let project = load_project_identity(&self.connection)?
1196 .ok_or(DbError::ProjectInstanceIdentityMissing)?;
1197 require_bound_project_identity(&self.connection, project)?;
1198
1199 let mut result = Vec::with_capacity(owners.len());
1200 for chunk in owners.chunks(NAVIGATION_CONNECTION_OWNER_CHUNK) {
1201 let rows =
1202 collect_navigation_connection_rows(&self.connection, chunk, family_limit_plus_one)?;
1203 result.extend(navigation_connection_pages(
1204 chunk,
1205 rows,
1206 family_limit as usize,
1207 sample_limit,
1208 )?);
1209 }
1210 Ok(result)
1211 }
1212
1213 pub fn repository_graph_entity(&self, key: &GraphEntityKey) -> DbResult<Option<GraphEntity>> {
1220 let Some(generation) = self.repository_graph_generation()? else {
1221 return Ok(None);
1222 };
1223 if !verify_project_identity(&self.connection, key.project())? {
1224 return Ok(None);
1225 }
1226 Ok(self
1227 .repository_graph_entity_bounded(
1228 key,
1229 generation,
1230 maximum_repository_graph_read_budget()?,
1231 None,
1232 )?
1233 .rows
1234 .into_iter()
1235 .next())
1236 }
1237
1238 pub fn repository_graph_entity_bounded(
1247 &self,
1248 key: &GraphEntityKey,
1249 generation: IndexGeneration,
1250 budget: RepositoryGraphReadBudget,
1251 control: Option<&IndexWorkControl>,
1252 ) -> DbResult<RepositoryGraphReadBatch<GraphEntity>> {
1253 self.require_repository_graph_snapshot(key.project(), generation)?;
1254 let digest = key.digest_bytes()?;
1255 let mut meter = RepositoryGraphReadMeter::new(budget, 1)?;
1256 let mut entities = load_graph_entities_by_digest_metered(
1257 self,
1258 &[digest],
1259 key.project(),
1260 generation,
1261 control,
1262 Some(&mut meter),
1263 )?;
1264 let rows = entities.remove(&digest).into_iter().collect::<Vec<_>>();
1265 let work = meter.finish(rows.len())?;
1266 Ok(RepositoryGraphReadBatch { rows, work })
1267 }
1268
1269 pub fn repository_graph_entities_by_digest(
1277 &self,
1278 project: ProjectInstanceId,
1279 generation: IndexGeneration,
1280 digests: &[[u8; 32]],
1281 budget: RepositoryGraphReadBudget,
1282 control: Option<&IndexWorkControl>,
1283 ) -> DbResult<RepositoryGraphReadBatch<GraphEntity>> {
1284 validate_graph_hydration_request(digests)?;
1285 self.require_repository_graph_snapshot(project, generation)?;
1286 let mut meter = RepositoryGraphReadMeter::new(budget, digests.len())?;
1287 let mut entities = load_graph_entities_by_digest_metered(
1288 self,
1289 digests,
1290 project,
1291 generation,
1292 control,
1293 Some(&mut meter),
1294 )?;
1295 let mut ordered = Vec::with_capacity(digests.len());
1296 for digest in digests {
1297 ordered.push(entities.remove(digest).ok_or(DbError::GraphRowShape {
1298 table: "graph_entities",
1299 reason: "requested graph entity is missing",
1300 })?);
1301 }
1302 let work = meter.finish(ordered.len())?;
1303 Ok(RepositoryGraphReadBatch {
1304 rows: ordered,
1305 work,
1306 })
1307 }
1308
1309 pub fn repository_graph_entities_by_path(
1316 &self,
1317 project: ProjectInstanceId,
1318 path: &RepositoryNodePath,
1319 limit: u32,
1320 ) -> DbResult<RepositoryGraphPage<GraphEntity>> {
1321 validated_limit_plus_one(
1322 limit,
1323 GraphLimits::MAX_ROWS,
1324 "graph rows must be nonzero and within the product ceiling",
1325 )?;
1326 let Some(generation) = self.repository_graph_generation()? else {
1327 return Ok(empty_page());
1328 };
1329 if !verify_project_identity(&self.connection, project)? {
1330 return Ok(empty_page());
1331 }
1332 Ok(self
1333 .repository_graph_entities_by_path_bounded(
1334 project,
1335 generation,
1336 path,
1337 limit,
1338 maximum_repository_graph_read_budget()?,
1339 None,
1340 )?
1341 .page)
1342 }
1343
1344 pub fn repository_graph_entities_by_path_bounded(
1353 &self,
1354 project: ProjectInstanceId,
1355 generation: IndexGeneration,
1356 path: &RepositoryNodePath,
1357 limit: u32,
1358 budget: RepositoryGraphReadBudget,
1359 control: Option<&IndexWorkControl>,
1360 ) -> DbResult<RepositoryGraphReadPage<GraphEntity>> {
1361 let limit_plus_one = validated_limit_plus_one(
1362 limit,
1363 GraphLimits::MAX_ROWS,
1364 "graph rows must be nonzero and within the product ceiling",
1365 )?;
1366 self.require_repository_graph_snapshot(project, generation)?;
1367 let mut meter = RepositoryGraphReadMeter::new(budget, 1)?;
1368 let raw = with_sqlite_read_progress(
1369 &self.connection,
1370 control,
1371 IndexWorkStage::RepositoryTraversal,
1372 || {
1373 let mut statement = self.connection.prepare_cached(
1374 "SELECT entity_key, project_instance_id, canonical_identity, entity_kind,
1375 repository_path, package_manager, package_name, manifest_path,
1376 symbol_name, symbol_kind, symbol_parent, symbol_signature,
1377 external_system, external_identity
1378 FROM graph_entities
1379 WHERE project_instance_id = ?1 AND repository_path = ?2
1380 ORDER BY entity_kind, entity_key
1381 LIMIT ?3",
1382 )?;
1383 collect_entity_rows_metered(
1384 statement.query(params![
1385 &project.as_bytes()[..],
1386 path.as_str(),
1387 limit_plus_one
1388 ])?,
1389 &mut meter,
1390 )
1391 },
1392 )?;
1393 let page = page_from_raw(raw, limit, |row| {
1394 let entity = entity_from_row(row, project, generation)?;
1395 meter.record_entity(&entity)?;
1396 Ok(entity)
1397 })?;
1398 let work = meter.finish(page.rows.len())?;
1399 Ok(RepositoryGraphReadPage { page, work })
1400 }
1401
1402 pub fn repository_resolution_candidates(
1409 &self,
1410 key: &CanonicalResolutionKey,
1411 limit: u32,
1412 ) -> DbResult<RepositoryGraphPage<GraphEntity>> {
1413 let limit_plus_one = validated_limit_plus_one(
1414 limit,
1415 GraphLimits::MAX_ROWS,
1416 "resolution candidates must be nonzero and within the product ceiling",
1417 )?;
1418 let Some(generation) = self.repository_graph_generation()? else {
1419 return Ok(empty_page());
1420 };
1421 if !verify_project_identity(&self.connection, key.project())? {
1422 return Ok(empty_page());
1423 }
1424 if !validate_persisted_resolution_key(&self.connection, key)? {
1425 return Ok(empty_page());
1426 }
1427 let raw = {
1428 let mut statement = self.connection.prepare_cached(
1429 "SELECT entity.entity_key, entity.project_instance_id,
1430 entity.canonical_identity, entity.entity_kind,
1431 entity.repository_path, entity.package_manager,
1432 entity.package_name, entity.manifest_path,
1433 entity.symbol_name, entity.symbol_kind,
1434 entity.symbol_parent, entity.symbol_signature,
1435 entity.external_system, entity.external_identity
1436 FROM graph_entity_exports AS export
1437 INDEXED BY idx_graph_entity_exports_key
1438 JOIN graph_entities AS entity
1439 ON entity.project_instance_id = export.project_instance_id
1440 AND entity.entity_key = export.entity_key
1441 WHERE export.project_instance_id = ?1
1442 AND export.resolution_domain = ?2
1443 AND export.key_digest = ?3
1444 ORDER BY export.entity_key
1445 LIMIT ?4",
1446 )?;
1447 collect_entity_rows(statement.query(params![
1448 &key.project().as_bytes()[..],
1449 key.domain().as_str(),
1450 &key.digest_bytes()[..],
1451 limit_plus_one,
1452 ])?)?
1453 };
1454 page_from_raw(raw, limit, |row| {
1455 entity_from_row(row, key.project(), generation)
1456 })
1457 }
1458
1459 pub fn repository_resolution_candidates_for_keys(
1472 &self,
1473 project: ProjectInstanceId,
1474 keys: &[CanonicalResolutionKey],
1475 limit: u32,
1476 ) -> DbResult<RepositoryGraphPage<RepositoryResolutionCandidate>> {
1477 validated_limit_plus_one(
1478 limit,
1479 GraphLimits::MAX_ROWS,
1480 "resolution candidates must be nonzero and within the product ceiling",
1481 )?;
1482 let Some(generation) = self.repository_graph_generation()? else {
1483 return Ok(empty_page());
1484 };
1485 if !verify_project_identity(&self.connection, project)? {
1486 return Ok(empty_page());
1487 }
1488 let keys = normalized_resolution_keys(project, keys)?;
1489 validate_persisted_resolution_keys(&self.connection, &keys)?;
1490 let mut candidates = Vec::new();
1491 for chunk in keys.chunks(RESOLUTION_KEYS_PER_QUERY) {
1492 if chunk.is_empty() {
1493 continue;
1494 }
1495 let values_clause = anonymous_values_clause(chunk.len(), 4);
1496 let sql = format!(
1497 "WITH requested(project_instance_id, resolution_domain, key_digest, canonical_identity)
1498 AS (VALUES {values_clause})
1499 SELECT entity.entity_key, entity.project_instance_id,
1500 entity.canonical_identity, entity.entity_kind,
1501 entity.repository_path, entity.package_manager,
1502 entity.package_name, entity.manifest_path,
1503 entity.symbol_name, entity.symbol_kind,
1504 entity.symbol_parent, entity.symbol_signature,
1505 entity.external_system, entity.external_identity,
1506 stored.project_instance_id, stored.resolution_domain,
1507 stored.key_digest, stored.canonical_identity
1508 FROM requested
1509 JOIN graph_resolution_keys AS stored
1510 ON stored.project_instance_id = requested.project_instance_id
1511 AND stored.resolution_domain = requested.resolution_domain
1512 AND stored.key_digest = requested.key_digest
1513 AND stored.canonical_identity = requested.canonical_identity
1514 JOIN graph_entity_exports AS export
1515 INDEXED BY idx_graph_entity_exports_key
1516 ON export.project_instance_id = stored.project_instance_id
1517 AND export.resolution_domain = stored.resolution_domain
1518 AND export.key_digest = stored.key_digest
1519 JOIN graph_entities AS entity
1520 ON entity.project_instance_id = export.project_instance_id
1521 AND entity.entity_key = export.entity_key
1522 ORDER BY stored.resolution_domain, stored.key_digest, export.entity_key
1523 LIMIT ?"
1524 );
1525 let mut values = resolution_key_values(chunk, true);
1526 values.push(Value::Integer(i64::from(limit) + 1));
1527 let mut statement = self.connection.prepare(&sql)?;
1528 let mut rows = statement.query(params_from_iter(values.iter()))?;
1529 while let Some(row) = rows.next()? {
1530 let entity = entity_from_row(entity_row(row)?, project, generation)?;
1531 let key_project = project_from_blob(
1532 "graph_resolution_keys.project_instance_id",
1533 row.get::<_, Vec<u8>>(14)?,
1534 )?;
1535 require_project(project, key_project)?;
1536 let domain_text = row.get::<_, String>(15)?;
1537 let domain = ResolutionKeyDomain::try_from(domain_text.as_str())?;
1538 let digest = fixed_bytes::<32>(
1539 "graph_resolution_keys.key_digest",
1540 row.get::<_, Vec<u8>>(16)?,
1541 )?;
1542 let key = CanonicalResolutionKey::from_persisted(
1543 key_project,
1544 domain,
1545 digest,
1546 row.get(17)?,
1547 )?;
1548 candidates.push(RepositoryResolutionCandidate { key, entity });
1549 }
1550 if candidates.len() > limit as usize {
1551 break;
1552 }
1553 }
1554 candidates.sort_by(|left, right| {
1555 left.key
1556 .cmp(&right.key)
1557 .then_with(|| left.entity.key().digest().cmp(right.entity.key().digest()))
1558 .then_with(|| {
1559 left.entity
1560 .key()
1561 .canonical_identity()
1562 .cmp(right.entity.key().canonical_identity())
1563 })
1564 });
1565 candidates.dedup_by(|left, right| {
1566 left.key == right.key && left.entity.key() == right.entity.key()
1567 });
1568 let truncated = candidates.len() > limit as usize;
1569 candidates.truncate(limit as usize);
1570 Ok(RepositoryGraphPage {
1571 rows: candidates,
1572 truncated,
1573 })
1574 }
1575
1576 pub fn repository_export_keys_for_paths(
1586 &self,
1587 project: ProjectInstanceId,
1588 paths: &[String],
1589 limit: u32,
1590 ) -> DbResult<RepositoryGraphPage<CanonicalResolutionKey>> {
1591 validated_limit_plus_one(
1592 limit,
1593 GraphLimits::MAX_ROWS,
1594 "resolution export keys must be nonzero and within the product ceiling",
1595 )?;
1596 if self.repository_graph_generation()?.is_none()
1597 || !verify_project_identity(&self.connection, project)?
1598 {
1599 return Ok(empty_page());
1600 }
1601 let paths = normalized_file_paths(paths)?;
1602 let keys = resolution_keys_for_owner_paths(
1603 &self.connection,
1604 project,
1605 &paths,
1606 ResolutionOwner::EntityExports,
1607 Some(limit),
1608 )?;
1609 Ok(page_from_ordered_set(keys, limit))
1610 }
1611
1612 pub fn repository_affected_source_paths(
1624 &self,
1625 project: ProjectInstanceId,
1626 keys: &[CanonicalResolutionKey],
1627 limit: u32,
1628 ) -> DbResult<RepositoryGraphPage<RepositoryFilePath>> {
1629 validated_limit_plus_one(
1630 limit,
1631 GraphLimits::MAX_ROWS,
1632 "affected source paths must be nonzero and within the product ceiling",
1633 )?;
1634 if self.repository_graph_generation()?.is_none()
1635 || !verify_project_identity(&self.connection, project)?
1636 {
1637 return Ok(empty_page());
1638 }
1639 let keys = normalized_resolution_keys(project, keys)?;
1640 validate_persisted_resolution_keys(&self.connection, &keys)?;
1641 let paths = affected_source_paths(&self.connection, &keys, limit)?;
1642 Ok(page_from_ordered_set(paths, limit))
1643 }
1644
1645 pub fn repository_affected_source_footprint(
1659 &self,
1660 project: ProjectInstanceId,
1661 paths: &[String],
1662 limit: u32,
1663 ) -> DbResult<RepositoryAffectedSourceFootprint> {
1664 let limit_plus_one = validated_limit_plus_one(
1665 limit,
1666 GraphLimits::MAX_ROWS,
1667 "affected source footprint rows must be nonzero and within the product ceiling",
1668 )?;
1669 if self.repository_graph_generation()?.is_none()
1670 || !verify_project_identity(&self.connection, project)?
1671 {
1672 return Ok(empty_affected_source_footprint());
1673 }
1674 let paths = normalized_file_paths(paths)?;
1675 affected_source_footprint(&self.connection, project, &paths, limit_plus_one)
1676 }
1677
1678 pub fn repository_graph_relations(
1685 &self,
1686 query: RepositoryGraphRelationQuery,
1687 limit: u32,
1688 ) -> DbResult<RepositoryGraphPage<LogicalRelation>> {
1689 let page = self.repository_graph_relation_rows(query, limit, None)?;
1690 Ok(RepositoryGraphPage {
1691 rows: page.rows.into_iter().map(|row| row.relation).collect(),
1692 truncated: page.truncated,
1693 })
1694 }
1695
1696 pub fn repository_graph_relation_rows(
1704 &self,
1705 query: RepositoryGraphRelationQuery,
1706 limit: u32,
1707 control: Option<&IndexWorkControl>,
1708 ) -> DbResult<RepositoryGraphPage<RepositoryGraphRelationRow>> {
1709 let limit_plus_one = validated_limit_plus_one(
1710 limit,
1711 GraphLimits::MAX_ROWS,
1712 "graph rows must be nonzero and within the product ceiling",
1713 )?;
1714 let Some(generation) = self.repository_graph_generation()? else {
1715 return Ok(empty_page());
1716 };
1717 let (project, raw) = match query {
1718 RepositoryGraphRelationQuery::Outbound { source } => {
1719 let project = source.project();
1720 if !verify_project_identity(&self.connection, project)? {
1721 return Ok(empty_page());
1722 }
1723 let raw = with_sqlite_read_progress(
1724 &self.connection,
1725 control,
1726 IndexWorkStage::RepositoryTraversal,
1727 || {
1728 self.collect_relation_rows_by_key(
1729 "source_entity_key",
1730 &source.digest_bytes()?,
1731 limit_plus_one,
1732 )
1733 },
1734 )?;
1735 (project, raw)
1736 }
1737 RepositoryGraphRelationQuery::Inbound { target } => {
1738 let project = target.project();
1739 if !verify_project_identity(&self.connection, project)? {
1740 return Ok(empty_page());
1741 }
1742 let raw = with_sqlite_read_progress(
1743 &self.connection,
1744 control,
1745 IndexWorkStage::RepositoryTraversal,
1746 || {
1747 self.collect_relation_rows_by_key(
1748 "target_entity_key",
1749 &target.digest_bytes()?,
1750 limit_plus_one,
1751 )
1752 },
1753 )?;
1754 (project, raw)
1755 }
1756 RepositoryGraphRelationQuery::Family { relation } => {
1757 let project = load_project_identity(&self.connection)?
1758 .ok_or(DbError::GraphPublicationUnavailable)?;
1759 let (scope, kind) = relation_parts(relation);
1760 let raw = with_sqlite_read_progress(
1761 &self.connection,
1762 control,
1763 IndexWorkStage::RepositoryTraversal,
1764 || {
1765 #[cfg(feature = "sqlite-progress-test-observer")]
1766 crate::sqlite_progress_test_observer::notify(
1767 crate::sqlite_progress_test_observer::SqliteReadProgressEvent::RepositoryRelationFamilyQueryEntered,
1768 );
1769 let result = (|| {
1770 let mut statement = self.connection.prepare_cached(
1771 "SELECT relation_key, project_instance_id, canonical_identity,
1772 source_entity_key, relation_scope, relation_kind,
1773 resolution_status, target_entity_key, reference_text,
1774 candidate_count, confidence, completeness
1775 FROM graph_relations
1776 WHERE project_instance_id = ?1
1777 AND relation_scope = ?2 AND relation_kind = ?3
1778 ORDER BY relation_key
1779 LIMIT ?4",
1780 )?;
1781 collect_relation_rows(statement.query(params![
1782 &project.as_bytes()[..],
1783 scope,
1784 kind,
1785 limit_plus_one
1786 ])?)
1787 })();
1788 #[cfg(feature = "sqlite-progress-test-observer")]
1789 crate::sqlite_progress_test_observer::notify(
1790 crate::sqlite_progress_test_observer::SqliteReadProgressEvent::RepositoryRelationFamilyQueryExited,
1791 );
1792 result
1793 },
1794 )?;
1795 (project, raw)
1796 }
1797 };
1798 let entities = load_relation_entities(self, &raw, project, generation, control)?;
1799 page_from_raw(raw, limit, |row| {
1800 relation_detail_from_row(&entities, row, project, generation)
1801 })
1802 }
1803
1804 pub fn repository_graph_relation_rows_by_digest(
1813 &self,
1814 project: ProjectInstanceId,
1815 generation: IndexGeneration,
1816 digests: &[[u8; 32]],
1817 budget: RepositoryGraphReadBudget,
1818 control: Option<&IndexWorkControl>,
1819 ) -> DbResult<RepositoryGraphReadBatch<RepositoryGraphRelationRow>> {
1820 validate_graph_hydration_request(digests)?;
1821 self.require_repository_graph_snapshot(project, generation)?;
1822 let mut meter = RepositoryGraphReadMeter::new(budget, digests.len())?;
1823 if digests.is_empty() {
1824 return Ok(RepositoryGraphReadBatch {
1825 rows: Vec::new(),
1826 work: meter.finish(0)?,
1827 });
1828 }
1829 let sql = graph_relation_hydration_sql(digests.len());
1830 let mut bindings = digests
1831 .iter()
1832 .map(|digest| Value::Blob(digest.to_vec()))
1833 .collect::<Vec<_>>();
1834 bindings.push(Value::Blob(project.as_bytes().to_vec()));
1835 let raw = with_sqlite_read_progress(
1836 &self.connection,
1837 control,
1838 IndexWorkStage::RepositoryTraversal,
1839 || {
1840 let mut statement = self.connection.prepare(&sql)?;
1841 collect_relation_rows_metered(
1842 statement.query(params_from_iter(bindings.iter()))?,
1843 &mut meter,
1844 )
1845 },
1846 )?;
1847 let mut relations = HashMap::with_capacity(raw.len());
1848 for row in raw {
1849 let digest = fixed_bytes::<32>("graph_relations.relation_key", row.key.clone())?;
1850 if relations.insert(digest, row).is_some() {
1851 return Err(DbError::GraphRowShape {
1852 table: "graph_relations",
1853 reason: "batched relation hydration returned a duplicate key",
1854 });
1855 }
1856 }
1857 let mut ordered = Vec::with_capacity(digests.len());
1858 for digest in digests {
1859 ordered.push(relations.remove(digest).ok_or(DbError::GraphRowShape {
1860 table: "graph_relations",
1861 reason: "requested graph relation is missing",
1862 })?);
1863 }
1864 let references = ordered.iter().collect::<Vec<_>>();
1865 let entities = load_relation_entity_references_metered(
1866 self,
1867 &references,
1868 project,
1869 generation,
1870 control,
1871 Some(&mut meter),
1872 )?;
1873 let rows = ordered
1874 .into_iter()
1875 .map(|row| relation_detail_from_row(&entities, row, project, generation))
1876 .collect::<DbResult<Vec<_>>>()?;
1877 let work = meter.finish(rows.len())?;
1878 Ok(RepositoryGraphReadBatch { rows, work })
1879 }
1880
1881 pub fn repository_graph_adjacency_page(
1895 &self,
1896 frontier: &[GraphEntityKey],
1897 direction: RepositoryGraphDirection,
1898 continuation: Option<&RepositoryGraphAdjacencyContinuation>,
1899 limit: u32,
1900 control: Option<&IndexWorkControl>,
1901 ) -> DbResult<RepositoryGraphAdjacencyPage> {
1902 Ok(self
1903 .repository_graph_adjacency_page_bounded(
1904 frontier,
1905 direction,
1906 continuation,
1907 limit,
1908 maximum_repository_graph_read_budget()?,
1909 control,
1910 )?
1911 .page)
1912 }
1913
1914 pub fn repository_graph_adjacency_page_bounded(
1922 &self,
1923 frontier: &[GraphEntityKey],
1924 direction: RepositoryGraphDirection,
1925 continuation: Option<&RepositoryGraphAdjacencyContinuation>,
1926 limit: u32,
1927 budget: RepositoryGraphReadBudget,
1928 control: Option<&IndexWorkControl>,
1929 ) -> DbResult<RepositoryGraphAdjacencyReadPage> {
1930 self.repository_graph_adjacency_page_filtered_bounded(
1931 frontier,
1932 direction,
1933 None,
1934 continuation,
1935 limit,
1936 budget,
1937 control,
1938 )
1939 }
1940
1941 pub fn repository_graph_adjacency_page_filtered(
1949 &self,
1950 frontier: &[GraphEntityKey],
1951 direction: RepositoryGraphDirection,
1952 relation: Option<GraphRelationKind>,
1953 continuation: Option<&RepositoryGraphAdjacencyContinuation>,
1954 limit: u32,
1955 control: Option<&IndexWorkControl>,
1956 ) -> DbResult<RepositoryGraphAdjacencyPage> {
1957 Ok(self
1958 .repository_graph_adjacency_page_filtered_bounded(
1959 frontier,
1960 direction,
1961 relation,
1962 continuation,
1963 limit,
1964 maximum_repository_graph_read_budget()?,
1965 control,
1966 )?
1967 .page)
1968 }
1969
1970 pub fn repository_graph_adjacency_page_filtered_bounded(
1978 &self,
1979 frontier: &[GraphEntityKey],
1980 direction: RepositoryGraphDirection,
1981 relation: Option<GraphRelationKind>,
1982 continuation: Option<&RepositoryGraphAdjacencyContinuation>,
1983 limit: u32,
1984 budget: RepositoryGraphReadBudget,
1985 control: Option<&IndexWorkControl>,
1986 ) -> DbResult<RepositoryGraphAdjacencyReadPage> {
1987 let limit_plus_one = validated_limit_plus_one(
1988 limit,
1989 GraphLimits::MAX_ROWS,
1990 "graph adjacency rows must be nonzero and within the product ceiling",
1991 )?;
1992 if limit > budget.returned_rows() {
1993 return Err(GraphContractError::InvalidLimits {
1994 reason: "graph adjacency page limit exceeds the return budget",
1995 }
1996 .into());
1997 }
1998 if frontier.len() > MAX_REPOSITORY_GRAPH_FRONTIER {
1999 return Err(GraphContractError::InvalidLimits {
2000 reason: "graph adjacency frontier exceeds the product ceiling",
2001 }
2002 .into());
2003 }
2004 if frontier.is_empty() {
2005 if continuation.is_some() {
2006 return Err(GraphContractError::InvalidLimits {
2007 reason: "graph adjacency continuation requires a nonempty frontier",
2008 }
2009 .into());
2010 }
2011 let meter = RepositoryGraphReadMeter::new(budget, 0)?;
2012 return Ok(RepositoryGraphAdjacencyReadPage {
2013 page: empty_adjacency_page(),
2014 work: meter.finish(0)?,
2015 });
2016 }
2017 let mut meter = RepositoryGraphReadMeter::new(budget, frontier.len())?;
2018 let limit_plus_one_usize = usize::try_from(limit_plus_one).map_err(|_source| {
2019 GraphContractError::InvalidLimits {
2020 reason: "graph adjacency page limit overflowed",
2021 }
2022 })?;
2023 let project = frontier[0].project();
2024 let mut unique = BTreeSet::new();
2025 let mut frontier_digests = Vec::with_capacity(frontier.len());
2026 for key in frontier {
2027 require_project(project, key.project())?;
2028 let digest = key.digest_bytes()?;
2029 if !unique.insert(digest) {
2030 return Err(GraphContractError::InvalidLimits {
2031 reason: "graph adjacency frontier must contain unique entities",
2032 }
2033 .into());
2034 }
2035 frontier_digests.push(digest);
2036 }
2037
2038 let Some(generation) = self.repository_graph_generation()? else {
2039 if continuation.is_some() {
2040 return Err(GraphContractError::InvalidLimits {
2041 reason: "graph adjacency continuation has no active generation",
2042 }
2043 .into());
2044 }
2045 return Ok(RepositoryGraphAdjacencyReadPage {
2046 page: empty_adjacency_page(),
2047 work: meter.finish(0)?,
2048 });
2049 };
2050 if !verify_project_identity(&self.connection, project)? {
2051 if continuation.is_some() {
2052 return Err(GraphContractError::InvalidLimits {
2053 reason: "graph adjacency continuation does not match the bound project",
2054 }
2055 .into());
2056 }
2057 return Ok(RepositoryGraphAdjacencyReadPage {
2058 page: empty_adjacency_page(),
2059 work: meter.finish(0)?,
2060 });
2061 }
2062
2063 if let Some(continuation) = continuation
2064 && (continuation.project != project
2065 || continuation.generation != generation
2066 || continuation.direction != direction
2067 || continuation.relation != relation
2068 || continuation.frontier != frontier_digests
2069 || continuation.frontier_index as usize >= frontier.len())
2070 {
2071 return Err(GraphContractError::InvalidLimits {
2072 reason: "graph adjacency continuation does not match the request",
2073 }
2074 .into());
2075 }
2076
2077 let continuation_index = continuation.map_or(0, |value| value.frontier_index as usize);
2078 let active_frontier = frontier.len() - continuation_index;
2079 let work_rows = active_frontier.checked_mul(limit_plus_one_usize).ok_or(
2080 GraphContractError::InvalidLimits {
2081 reason: "graph adjacency intermediate row ceiling overflowed",
2082 },
2083 )?;
2084 if work_rows > MAX_REPOSITORY_GRAPH_ADJACENCY_WORK_ROWS {
2085 return Err(GraphContractError::InvalidLimits {
2086 reason: "graph adjacency frontier and page exceed the intermediate row ceiling",
2087 }
2088 .into());
2089 }
2090
2091 let bindings_per_frontier = if relation.is_some() { 4 } else { 2 };
2092 let mut bindings = Vec::with_capacity(
2093 active_frontier * bindings_per_frontier + continuation.map_or(2, |_| 5),
2094 );
2095 bindings.push(Value::Blob(project.as_bytes().to_vec()));
2096 for (index, digest) in frontier_digests.iter().enumerate().skip(continuation_index) {
2097 bindings.push(Value::Blob(digest.to_vec()));
2098 if let Some(relation) = relation {
2099 let (scope, kind) = relation_parts(relation);
2100 bindings.push(Value::Text(scope.to_string()));
2101 bindings.push(Value::Text(kind.to_string()));
2102 }
2103 if index == continuation_index
2104 && let Some(continuation) = continuation
2105 {
2106 bindings.push(Value::Text(continuation.relation_scope.clone()));
2107 bindings.push(Value::Text(continuation.relation_kind.clone()));
2108 bindings.push(Value::Blob(continuation.relation_key.to_vec()));
2109 }
2110 bindings.push(Value::Integer(limit_plus_one));
2111 }
2112 bindings.push(Value::Integer(limit_plus_one));
2113
2114 let sql = adjacency_relation_sql(
2115 frontier.len(),
2116 direction,
2117 continuation.map(|value| value.frontier_index as usize),
2118 relation.is_some(),
2119 );
2120 let raw = with_sqlite_read_progress(
2121 &self.connection,
2122 control,
2123 IndexWorkStage::RepositoryTraversal,
2124 || {
2125 let mut statement = self.connection.prepare(&sql)?;
2126 collect_adjacency_relation_rows_metered(
2127 statement.query(params_from_iter(bindings.iter()))?,
2128 &mut meter,
2129 )
2130 },
2131 )?;
2132 let truncated = raw.len() > limit as usize;
2133 let next = if truncated {
2134 let last = raw.get(limit as usize - 1).ok_or(DbError::GraphRowShape {
2135 table: "graph_relations",
2136 reason: "adjacency truncation row is missing",
2137 })?;
2138 Some(RepositoryGraphAdjacencyContinuation {
2139 project,
2140 generation,
2141 direction,
2142 relation,
2143 frontier: frontier_digests,
2144 frontier_index: last.frontier_index,
2145 relation_scope: last.relation.relation_scope.clone(),
2146 relation_kind: last.relation.relation_kind.clone(),
2147 relation_key: fixed_bytes::<32>(
2148 "graph_relations.relation_key",
2149 last.relation.key.clone(),
2150 )?,
2151 })
2152 } else {
2153 None
2154 };
2155 let relation_rows = raw.iter().map(|row| &row.relation).collect::<Vec<_>>();
2156 let entities = load_relation_entity_references_metered(
2157 self,
2158 &relation_rows,
2159 project,
2160 generation,
2161 control,
2162 Some(&mut meter),
2163 )?;
2164 let mut rows = Vec::with_capacity(raw.len());
2165 for row in raw {
2166 if let Some(control) = control {
2167 control.check(IndexWorkStage::RepositoryTraversal)?;
2168 }
2169 let frontier_key = frontier
2170 .get(row.frontier_index as usize)
2171 .ok_or(DbError::GraphRowShape {
2172 table: "graph_relations",
2173 reason: "adjacency row has an invalid frontier position",
2174 })?
2175 .clone();
2176 rows.push(RepositoryGraphAdjacencyRow {
2177 frontier_index: row.frontier_index,
2178 frontier: frontier_key,
2179 direction,
2180 detail: relation_detail_from_row(&entities, row.relation, project, generation)?,
2181 });
2182 }
2183 if truncated {
2184 rows.pop();
2185 }
2186 let work = meter.finish(rows.len())?;
2187 Ok(RepositoryGraphAdjacencyReadPage {
2188 page: RepositoryGraphAdjacencyPage {
2189 rows,
2190 truncated,
2191 continuation: next,
2192 },
2193 work,
2194 })
2195 }
2196
2197 pub fn repository_graph_occurrences(
2204 &self,
2205 relation: &LogicalRelation,
2206 limit: u32,
2207 ) -> DbResult<RepositoryGraphPage<RelationOccurrence>> {
2208 let limit_plus_one = validated_limit_plus_one(
2209 limit,
2210 GraphLimits::MAX_OCCURRENCES,
2211 "graph occurrences must be nonzero and within the product ceiling",
2212 )?;
2213 let Some(generation) = self.repository_graph_generation()? else {
2214 return Ok(empty_page());
2215 };
2216 if relation.generation() != generation {
2217 return Err(
2218 projectatlas_core::graph::GraphContractError::GenerationMismatch {
2219 context: "relation occurrence query",
2220 }
2221 .into(),
2222 );
2223 }
2224 if !verify_project_identity(&self.connection, relation.key().project())? {
2225 return Ok(empty_page());
2226 }
2227 let raw = {
2228 let mut statement = self.connection.prepare_cached(
2229 "SELECT relation_key, file_path, start_line, start_column,
2230 end_line, end_column
2231 FROM graph_relation_occurrences
2232 WHERE relation_key = ?1
2233 ORDER BY file_path, start_line, start_column, end_line, end_column
2234 LIMIT ?2",
2235 )?;
2236 let mut rows =
2237 statement.query(params![&relation.key().digest_bytes()?[..], limit_plus_one])?;
2238 let mut collected = Vec::new();
2239 while let Some(row) = rows.next()? {
2240 collected.push(occurrence_row(row)?);
2241 }
2242 collected
2243 };
2244 page_from_raw(raw, limit, |row| {
2245 occurrence_from_row(row, relation, generation)
2246 })
2247 }
2248
2249 pub fn repository_graph_occurrence_pages(
2261 &self,
2262 relations: &[LogicalRelation],
2263 limit: u32,
2264 control: Option<&IndexWorkControl>,
2265 ) -> DbResult<Vec<RepositoryGraphPage<RelationOccurrence>>> {
2266 Ok(self
2267 .repository_graph_occurrence_pages_bounded(
2268 relations,
2269 limit,
2270 maximum_repository_graph_read_budget()?,
2271 control,
2272 )?
2273 .pages)
2274 }
2275
2276 pub fn repository_graph_occurrence_pages_bounded(
2285 &self,
2286 relations: &[LogicalRelation],
2287 limit: u32,
2288 budget: RepositoryGraphReadBudget,
2289 control: Option<&IndexWorkControl>,
2290 ) -> DbResult<RepositoryGraphReadPages<RelationOccurrence>> {
2291 let limit_plus_one = validated_limit_plus_one(
2292 limit,
2293 GraphLimits::MAX_OCCURRENCES,
2294 "graph occurrences must be nonzero and within the product ceiling",
2295 )?;
2296 if relations.len() > MAX_REPOSITORY_GRAPH_FRONTIER {
2297 return Err(GraphContractError::InvalidLimits {
2298 reason: "graph occurrence batch exceeds the product ceiling",
2299 }
2300 .into());
2301 }
2302 let mut meter = RepositoryGraphReadMeter::new(budget, relations.len())?;
2303 if relations.is_empty() {
2304 return Ok(RepositoryGraphReadPages {
2305 pages: Vec::new(),
2306 work: meter.finish(0)?,
2307 });
2308 }
2309 let work_rows = relations.len().checked_mul(limit_plus_one as usize).ok_or(
2310 GraphContractError::InvalidLimits {
2311 reason: "graph occurrence batch work overflowed",
2312 },
2313 )?;
2314 if work_rows > MAX_REPOSITORY_GRAPH_ADJACENCY_WORK_ROWS {
2315 return Err(GraphContractError::InvalidLimits {
2316 reason: "graph occurrence batch exceeds the intermediate row ceiling",
2317 }
2318 .into());
2319 }
2320 let Some(generation) = self.repository_graph_generation()? else {
2321 return Ok(RepositoryGraphReadPages {
2322 pages: (0..relations.len()).map(|_| empty_page()).collect(),
2323 work: meter.finish(0)?,
2324 });
2325 };
2326 let project = relations[0].key().project();
2327 if !verify_project_identity(&self.connection, project)? {
2328 return Ok(RepositoryGraphReadPages {
2329 pages: (0..relations.len()).map(|_| empty_page()).collect(),
2330 work: meter.finish(0)?,
2331 });
2332 }
2333 let mut bindings = Vec::with_capacity(relations.len() * 2);
2334 for relation in relations {
2335 require_project(project, relation.key().project())?;
2336 if relation.generation() != generation {
2337 return Err(GraphContractError::GenerationMismatch {
2338 context: "relation occurrence batch query",
2339 }
2340 .into());
2341 }
2342 bindings.push(Value::Blob(relation.key().digest_bytes()?.to_vec()));
2343 bindings.push(Value::Integer(limit_plus_one));
2344 }
2345 let sql = occurrence_pages_sql(relations.len());
2346 let raw = with_sqlite_read_progress(
2347 &self.connection,
2348 control,
2349 IndexWorkStage::RepositoryTraversal,
2350 || {
2351 let mut statement = self.connection.prepare(&sql)?;
2352 let mut queried = statement.query(params_from_iter(bindings.iter()))?;
2353 let mut grouped = (0..relations.len())
2354 .map(|_| Vec::new())
2355 .collect::<Vec<Vec<OccurrenceRow>>>();
2356 while let Some(row) = queried.next()? {
2357 let index = row.get::<_, usize>(0)?;
2358 let group = grouped.get_mut(index).ok_or(DbError::GraphRowShape {
2359 table: "graph_relation_occurrences",
2360 reason: "occurrence batch returned an invalid relation position",
2361 })?;
2362 let occurrence = occurrence_row_at(row, 1)?;
2363 meter.record_decoded_bytes(
2364 occurrence_row_decoded_bytes(&occurrence)?
2365 .checked_add(8)
2366 .ok_or(GraphContractError::InvalidLimits {
2367 reason: "graph occurrence batch decoded row size overflowed",
2368 })?,
2369 )?;
2370 group.push(occurrence);
2371 }
2372 Ok(grouped)
2373 },
2374 )?;
2375 let pages = raw
2376 .into_iter()
2377 .zip(relations)
2378 .map(|(rows, relation)| {
2379 page_from_raw(rows, limit, |row| {
2380 let occurrence = occurrence_from_row(row, relation, generation)?;
2381 meter.record_hydrated_path(occurrence.file().as_str())?;
2382 Ok(occurrence)
2383 })
2384 })
2385 .collect::<DbResult<Vec<_>>>()?;
2386 let returned_rows = pages.iter().try_fold(0_usize, |count, page| {
2387 count
2388 .checked_add(page.rows.len())
2389 .ok_or(GraphContractError::InvalidLimits {
2390 reason: "graph occurrence returned-row accounting overflowed",
2391 })
2392 })?;
2393 let work = meter.finish(returned_rows)?;
2394 Ok(RepositoryGraphReadPages { pages, work })
2395 }
2396
2397 pub fn repository_graph_coverage(
2404 &self,
2405 project: ProjectInstanceId,
2406 scope: &CoverageScope,
2407 limit: u32,
2408 ) -> DbResult<RepositoryGraphPage<CoverageRecord>> {
2409 let limit_plus_one = validated_limit_plus_one(
2410 limit,
2411 GraphLimits::MAX_ROWS,
2412 "graph rows must be nonzero and within the product ceiling",
2413 )?;
2414 let Some(generation) = self.repository_graph_generation()? else {
2415 return Ok(empty_page());
2416 };
2417 if !verify_project_identity(&self.connection, project)? {
2418 return Ok(empty_page());
2419 }
2420 let (scope_kind, scope_path) = coverage_scope_parts(scope);
2421 let raw = {
2422 let mut statement = self.connection.prepare_cached(
2423 "SELECT project_instance_id, scope_kind, scope_path, relation_scope,
2424 relation_kind, state, total, covered, omitted, reason, reached_limit,
2425 NULL, NULL
2426 FROM graph_coverage
2427 WHERE project_instance_id = ?1
2428 AND scope_kind = ?2 AND scope_path IS ?3
2429 ORDER BY relation_scope, relation_kind, state, id
2430 LIMIT ?4",
2431 )?;
2432 let mut rows = statement.query(params![
2433 &project.as_bytes()[..],
2434 scope_kind,
2435 scope_path,
2436 limit_plus_one
2437 ])?;
2438 let mut collected = Vec::new();
2439 while let Some(row) = rows.next()? {
2440 collected.push(coverage_row(row)?);
2441 }
2442 collected
2443 };
2444 page_from_raw(raw, limit, |row| {
2445 coverage_from_row(row, project, generation)
2446 })
2447 }
2448
2449 pub fn repository_graph_path_coverage(
2460 &self,
2461 project: ProjectInstanceId,
2462 paths: &[RepositoryNodePath],
2463 control: Option<&IndexWorkControl>,
2464 ) -> DbResult<RepositoryGraphPage<CoverageRecord>> {
2465 validate_path_coverage_request(paths)?;
2466 if paths.is_empty() {
2467 return Ok(empty_page());
2468 }
2469 let Some(generation) = self.repository_graph_generation()? else {
2470 return Ok(empty_page());
2471 };
2472 if !verify_project_identity(&self.connection, project)? {
2473 return Ok(empty_page());
2474 }
2475 Ok(self
2476 .repository_graph_path_coverage_bounded(
2477 project,
2478 generation,
2479 paths,
2480 maximum_repository_graph_read_budget()?,
2481 control,
2482 )?
2483 .page)
2484 }
2485
2486 pub fn repository_graph_path_coverage_bounded(
2496 &self,
2497 project: ProjectInstanceId,
2498 generation: IndexGeneration,
2499 paths: &[RepositoryNodePath],
2500 budget: RepositoryGraphReadBudget,
2501 control: Option<&IndexWorkControl>,
2502 ) -> DbResult<RepositoryGraphReadPage<CoverageRecord>> {
2503 validate_path_coverage_request(paths)?;
2504 self.require_repository_graph_snapshot(project, generation)?;
2505 let mut meter = RepositoryGraphReadMeter::new(budget, paths.len())?;
2506 if paths.is_empty() {
2507 return Ok(RepositoryGraphReadPage {
2508 page: empty_page(),
2509 work: meter.finish(0)?,
2510 });
2511 }
2512
2513 let sql = path_coverage_sql(paths.len());
2514 let mut bindings = Vec::with_capacity(paths.len() + 3);
2515 bindings.push(Value::Blob(project.as_bytes().to_vec()));
2516 bindings.push(Value::Text("path".to_string()));
2517 bindings.extend(
2518 paths
2519 .iter()
2520 .map(|path| Value::Text(path.as_str().to_string())),
2521 );
2522 bindings.push(Value::Integer(i64::from(GraphLimits::MAX_ROWS) + 1));
2523 let raw = with_sqlite_read_progress(
2524 &self.connection,
2525 control,
2526 IndexWorkStage::RepositoryTraversal,
2527 || {
2528 let mut statement = self.connection.prepare(&sql)?;
2529 let mut rows = statement.query(params_from_iter(bindings.iter()))?;
2530 let mut collected = Vec::new();
2531 while let Some(row) = rows.next()? {
2532 let coverage = coverage_row(row)?;
2533 meter.record_decoded_bytes(coverage_row_decoded_bytes(&coverage)?)?;
2534 collected.push(coverage);
2535 }
2536 Ok(collected)
2537 },
2538 )?;
2539 let page = page_from_raw(raw, GraphLimits::MAX_ROWS, |row| {
2540 let coverage = coverage_from_row(row, project, generation)?;
2541 if let CoverageScope::Path { path } = coverage.scope() {
2542 meter.record_hydrated_path(path.as_str())?;
2543 }
2544 Ok(coverage)
2545 })?;
2546 let work = meter.finish(page.rows.len())?;
2547 Ok(RepositoryGraphReadPage { page, work })
2548 }
2549
2550 pub fn repository_coverage_page(
2558 &self,
2559 project: ProjectInstanceId,
2560 query: &RepositoryCoverageQuery,
2561 ) -> DbResult<RepositoryGraphPage<RepositoryCoverageRow>> {
2562 let limit_plus_one = validated_limit_plus_one(
2563 query.limit,
2564 GraphLimits::MAX_ROWS,
2565 "coverage rows must be nonzero and within the product ceiling",
2566 )?;
2567 if query.start_index >= GraphLimits::MAX_ROWS {
2568 return Err(GraphContractError::InvalidLimits {
2569 reason: "coverage start index is at or above the product ceiling",
2570 }
2571 .into());
2572 }
2573 let Some(generation) = self.repository_graph_generation()? else {
2574 return Ok(empty_page());
2575 };
2576 if !verify_project_identity(&self.connection, project)? {
2577 return Ok(empty_page());
2578 }
2579
2580 let provenance_driven = query.parser.is_some() || query.provider.is_some();
2581 let path_prefix = query.path_prefix.as_deref().filter(|prefix| *prefix != ".");
2582 let mut sql = String::from(
2583 "SELECT coverage.project_instance_id, coverage.scope_kind,
2584 coverage.scope_path, coverage.relation_scope,
2585 coverage.relation_kind, coverage.state, coverage.total,
2586 coverage.covered, coverage.omitted, coverage.reason,
2587 coverage.reached_limit, metadata.source_parser,
2588 metadata.fact_parser
2589 FROM ",
2590 );
2591 if provenance_driven {
2592 sql.push_str(
2593 "source_parse_metadata AS metadata
2594 CROSS JOIN graph_coverage AS coverage
2595 ON coverage.scope_kind = 'path'
2596 AND coverage.scope_path = metadata.path",
2597 );
2598 } else {
2599 sql.push_str(
2600 "graph_coverage AS coverage
2601 LEFT JOIN source_parse_metadata AS metadata
2602 ON metadata.path = coverage.scope_path",
2603 );
2604 }
2605 sql.push_str(" WHERE coverage.project_instance_id = ?");
2606 let mut values = vec![Value::Blob(project.as_bytes().to_vec())];
2607
2608 if let Some(prefix) = path_prefix {
2609 sql.push_str(
2610 " AND coverage.scope_kind = 'path'
2611 AND coverage.scope_path >= ? AND coverage.scope_path < ?
2612 AND (coverage.scope_path = ? OR coverage.scope_path >= ?)",
2613 );
2614 values.push(Value::Text(prefix.to_string()));
2615 values.push(Value::Text(format!("{prefix}0")));
2616 values.push(Value::Text(prefix.to_string()));
2617 values.push(Value::Text(format!("{prefix}/")));
2618 }
2619 if let Some(parser) = query.parser {
2620 sql.push_str(" AND metadata.source_parser = ?");
2621 values.push(Value::Text(parser.to_string()));
2622 }
2623 if let Some(provider) = query.provider {
2624 sql.push_str(" AND metadata.fact_parser = ?");
2625 values.push(Value::Text(provider.to_string()));
2626 }
2627 if let Some(relation) = query.relation {
2628 let (scope, kind) = relation_parts(relation);
2629 sql.push_str(" AND coverage.relation_scope = ? AND coverage.relation_kind = ?");
2630 values.push(Value::Text(scope.to_string()));
2631 values.push(Value::Text(kind.to_string()));
2632 }
2633 if let Some(state) = query.state {
2634 sql.push_str(" AND coverage.state = ?");
2635 values.push(Value::Text(coverage_state_name(state).to_string()));
2636 }
2637 if let Some(reason) = query.reason.as_deref() {
2638 sql.push_str(" AND coverage.reason = ?");
2639 values.push(Value::Text(reason.to_string()));
2640 }
2641
2642 if provenance_driven {
2643 sql.push_str(" ORDER BY metadata.path, coverage.id");
2644 } else if path_prefix.is_some() {
2645 sql.push_str(
2646 " ORDER BY coverage.scope_path, coverage.relation_scope,
2647 coverage.relation_kind, coverage.state, coverage.id",
2648 );
2649 } else if query.relation.is_some() {
2650 sql.push_str(
2651 " ORDER BY coverage.relation_scope, coverage.relation_kind,
2652 coverage.state, coverage.id",
2653 );
2654 } else if query.state.is_some() {
2655 sql.push_str(" ORDER BY coverage.state, coverage.scope_path, coverage.id");
2656 } else if query.reason.is_some() {
2657 sql.push_str(" ORDER BY coverage.reason, coverage.scope_path, coverage.id");
2658 } else {
2659 sql.push_str(
2660 " ORDER BY coverage.scope_kind, coverage.scope_path,
2661 coverage.relation_scope, coverage.relation_kind,
2662 coverage.state, coverage.id",
2663 );
2664 }
2665 sql.push_str(" LIMIT ? OFFSET ?");
2666 values.push(Value::Integer(limit_plus_one));
2667 values.push(Value::Integer(i64::from(query.start_index)));
2668
2669 let raw = {
2670 let mut statement = self.connection.prepare_cached(&sql)?;
2671 let mut rows = statement.query(params_from_iter(values.iter()))?;
2672 let mut collected = Vec::new();
2673 while let Some(row) = rows.next()? {
2674 collected.push(coverage_row(row)?);
2675 }
2676 collected
2677 };
2678 page_from_raw(raw, query.limit, |row| {
2679 coverage_discovery_from_row(row, project, generation)
2680 })
2681 }
2682
2683 pub fn repository_graph_generation(&self) -> DbResult<Option<IndexGeneration>> {
2690 let Some(publication) = self.index_publication()? else {
2691 return Ok(None);
2692 };
2693 if publication.state != IndexPublicationState::Complete
2694 || publication.generation == IndexGeneration::ZERO
2695 {
2696 return Err(DbError::GraphPublicationUnavailable);
2697 }
2698 let Some(graph_generation) = load_graph_generation(&self.connection)? else {
2699 return Ok(None);
2700 };
2701 if graph_generation == IndexGeneration::ZERO || graph_generation != publication.generation {
2702 return Err(DbError::GraphRowShape {
2703 table: "project_identity",
2704 reason: "typed graph generation does not match complete publication",
2705 });
2706 }
2707 Ok(Some(graph_generation))
2708 }
2709
2710 pub(crate) fn require_repository_graph_snapshot(
2712 &self,
2713 project: ProjectInstanceId,
2714 generation: IndexGeneration,
2715 ) -> DbResult<()> {
2716 require_bound_project_identity(&self.connection, project)?;
2717 let current = self
2718 .repository_graph_generation()?
2719 .ok_or(DbError::GraphPublicationUnavailable)?;
2720 if current != generation {
2721 return Err(GraphContractError::InvalidLimits {
2722 reason: "graph hydration generation does not match the current publication",
2723 }
2724 .into());
2725 }
2726 Ok(())
2727 }
2728
2729 fn collect_relation_rows_by_key(
2731 &self,
2732 key_column: &'static str,
2733 key: &[u8; 32],
2734 limit_plus_one: i64,
2735 ) -> DbResult<Vec<RelationRow>> {
2736 let sql = match key_column {
2737 "source_entity_key" => {
2738 "SELECT relation_key, project_instance_id, canonical_identity,
2739 source_entity_key, relation_scope, relation_kind,
2740 resolution_status, target_entity_key, reference_text,
2741 candidate_count, confidence, completeness
2742 FROM graph_relations
2743 WHERE source_entity_key = ?1
2744 ORDER BY relation_scope, relation_kind, relation_key
2745 LIMIT ?2"
2746 }
2747 "target_entity_key" => {
2748 "SELECT relation_key, project_instance_id, canonical_identity,
2749 source_entity_key, relation_scope, relation_kind,
2750 resolution_status, target_entity_key, reference_text,
2751 candidate_count, confidence, completeness
2752 FROM graph_relations
2753 WHERE target_entity_key = ?1
2754 ORDER BY relation_scope, relation_kind, relation_key
2755 LIMIT ?2"
2756 }
2757 _ => {
2758 return Err(DbError::GraphRowShape {
2759 table: "graph_relations",
2760 reason: "unsupported internal relation lookup",
2761 });
2762 }
2763 };
2764 let mut statement = self.connection.prepare_cached(sql)?;
2765 collect_relation_rows(statement.query(params![&key[..], limit_plus_one])?)
2766 }
2767}
2768
2769pub(crate) fn capture_derived_graph(
2771 connection: &Connection,
2772 project: ProjectInstanceId,
2773 generation: IndexGeneration,
2774 budget: &mut SnapshotBudget,
2775) -> DbResult<CapturedGraph> {
2776 let mut entities = Vec::new();
2777 let mut entities_by_key = HashMap::new();
2778 {
2779 let mut statement = connection.prepare(
2780 "SELECT entity_key, project_instance_id, canonical_identity, entity_kind,
2781 repository_path, package_manager, package_name, manifest_path,
2782 symbol_name, symbol_kind, symbol_parent, symbol_signature,
2783 external_system, external_identity
2784 FROM graph_entities
2785 WHERE project_instance_id = ?1
2786 ORDER BY entity_key",
2787 )?;
2788 let mut rows = statement.query(params![&project.as_bytes()[..]])?;
2789 while let Some(row) = rows.next()? {
2790 let raw = entity_row(row)?;
2791 budget.admit(entity_row_decoded_bytes(&raw)?)?;
2792 let entity = entity_from_row(raw, project, generation)?;
2793 let key = entity.key().digest_bytes()?;
2794 if entities_by_key.insert(key, entity.clone()).is_some() {
2795 return Err(DbError::DerivedSnapshotInvalid {
2796 reason: "private capture contains a duplicate entity",
2797 });
2798 }
2799 entities.push(entity);
2800 }
2801 }
2802
2803 let mut relations = Vec::new();
2804 let mut relations_by_key = HashMap::new();
2805 {
2806 let mut statement = connection.prepare(
2807 "SELECT relation_key, project_instance_id, canonical_identity,
2808 source_entity_key, relation_scope, relation_kind,
2809 resolution_status, target_entity_key, reference_text,
2810 candidate_count, confidence, completeness
2811 FROM graph_relations
2812 WHERE project_instance_id = ?1
2813 ORDER BY relation_key",
2814 )?;
2815 let mut rows = statement.query(params![&project.as_bytes()[..]])?;
2816 while let Some(row) = rows.next()? {
2817 let raw = relation_row(row)?;
2818 budget.admit(relation_row_decoded_bytes(&raw)?)?;
2819 let relation = relation_from_row(&entities_by_key, raw, project, generation)?;
2820 let key = relation.key().digest_bytes()?;
2821 if relations_by_key.insert(key, relation.clone()).is_some() {
2822 return Err(DbError::DerivedSnapshotInvalid {
2823 reason: "private capture contains a duplicate relation",
2824 });
2825 }
2826 relations.push(relation);
2827 }
2828 }
2829
2830 let mut occurrences = Vec::new();
2831 {
2832 let mut statement = connection.prepare(
2833 "SELECT relation_key, file_path, start_line, start_column, end_line, end_column
2834 FROM graph_relation_occurrences
2835 ORDER BY relation_key, file_path, start_line, start_column, end_line, end_column",
2836 )?;
2837 let mut rows = statement.query([])?;
2838 while let Some(row) = rows.next()? {
2839 let raw = occurrence_row(row)?;
2840 budget.admit(occurrence_row_decoded_bytes(&raw)?)?;
2841 let key = fixed_bytes::<32>(
2842 "graph_relation_occurrences.relation_key",
2843 raw.relation.clone(),
2844 )?;
2845 let relation = relations_by_key
2846 .get(&key)
2847 .ok_or(DbError::DerivedSnapshotInvalid {
2848 reason: "private capture occurrence owner is absent",
2849 })?;
2850 occurrences.push(occurrence_from_row(raw, relation, generation)?);
2851 }
2852 }
2853
2854 let mut coverage = Vec::new();
2855 {
2856 let mut statement = connection.prepare(
2857 "SELECT project_instance_id, scope_kind, scope_path, relation_scope,
2858 relation_kind, state, total, covered, omitted, reason,
2859 reached_limit, NULL, NULL
2860 FROM graph_coverage
2861 WHERE project_instance_id = ?1
2862 ORDER BY scope_kind, scope_path, relation_scope, relation_kind, state, id",
2863 )?;
2864 let mut rows = statement.query(params![&project.as_bytes()[..]])?;
2865 while let Some(row) = rows.next()? {
2866 let raw = coverage_row(row)?;
2867 budget.admit(coverage_row_decoded_bytes(&raw)?)?;
2868 coverage.push(coverage_from_row(raw, project, generation)?);
2869 }
2870 }
2871
2872 let mut entity_exports = Vec::new();
2873 {
2874 let mut statement = connection.prepare(
2875 "SELECT key.project_instance_id, key.resolution_domain, key.key_digest,
2876 key.canonical_identity, export.entity_key
2877 FROM graph_entity_exports AS export
2878 JOIN graph_resolution_keys AS key
2879 ON key.project_instance_id = export.project_instance_id
2880 AND key.resolution_domain = export.resolution_domain
2881 AND key.key_digest = export.key_digest
2882 WHERE export.project_instance_id = ?1
2883 ORDER BY export.entity_key, key.resolution_domain, key.canonical_identity",
2884 )?;
2885 let mut rows = statement.query(params![&project.as_bytes()[..]])?;
2886 while let Some(row) = rows.next()? {
2887 let key = resolution_key_from_row(row, project)?;
2888 let owner =
2889 fixed_bytes::<32>("graph_entity_exports.entity_key", row.get::<_, Vec<u8>>(4)?)?;
2890 budget.admit(decoded_payload_bytes(
2891 [
2892 key.canonical_identity().len(),
2893 key.digest_bytes().len(),
2894 owner.len(),
2895 ],
2896 16,
2897 )?)?;
2898 entity_exports.push((owner, key));
2899 }
2900 }
2901
2902 let mut relation_dependencies = Vec::new();
2903 {
2904 let mut statement = connection.prepare(
2905 "SELECT key.project_instance_id, key.resolution_domain, key.key_digest,
2906 key.canonical_identity, dependency.relation_key
2907 FROM graph_relation_dependencies AS dependency
2908 JOIN graph_resolution_keys AS key
2909 ON key.project_instance_id = dependency.project_instance_id
2910 AND key.resolution_domain = dependency.resolution_domain
2911 AND key.key_digest = dependency.key_digest
2912 WHERE dependency.project_instance_id = ?1
2913 ORDER BY dependency.relation_key, key.resolution_domain, key.canonical_identity",
2914 )?;
2915 let mut rows = statement.query(params![&project.as_bytes()[..]])?;
2916 while let Some(row) = rows.next()? {
2917 let key = resolution_key_from_row(row, project)?;
2918 let owner = fixed_bytes::<32>(
2919 "graph_relation_dependencies.relation_key",
2920 row.get::<_, Vec<u8>>(4)?,
2921 )?;
2922 budget.admit(decoded_payload_bytes(
2923 [
2924 key.canonical_identity().len(),
2925 key.digest_bytes().len(),
2926 owner.len(),
2927 ],
2928 16,
2929 )?)?;
2930 relation_dependencies.push((owner, key));
2931 }
2932 }
2933
2934 Ok(CapturedGraph {
2935 entities,
2936 relations,
2937 occurrences,
2938 coverage,
2939 entity_exports,
2940 relation_dependencies,
2941 })
2942}
2943
2944impl AtlasStore {
2945 pub fn repository_graph_staging_belongs_to(
2955 path: &Path,
2956 root: &Path,
2957 project: ProjectInstanceId,
2958 ) -> DbResult<bool> {
2959 let connection = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
2960 let project_root = connection
2961 .query_row(
2962 "SELECT value FROM metadata WHERE key = ?1",
2963 [PROJECT_ROOT_KEY],
2964 |row| row.get::<_, String>(0),
2965 )
2966 .optional()?;
2967 let marker = connection
2968 .query_row(
2969 "SELECT value FROM metadata WHERE key = ?1",
2970 [GRAPH_STAGING_MARKER_KEY],
2971 |row| row.get::<_, String>(0),
2972 )
2973 .optional()?;
2974 Ok(
2975 project_root.as_deref() == Some(normalize_native_path_display(root).as_str())
2976 && load_project_identity(&connection)? == Some(project)
2977 && marker.as_deref() == Some(GRAPH_STAGING_MARKER_VALUE),
2978 )
2979 }
2980
2981 pub fn create_repository_graph_staging(
2988 path: &Path,
2989 root: &Path,
2990 project: ProjectInstanceId,
2991 ) -> DbResult<Self> {
2992 if path.exists() {
2993 return Err(DbError::GraphRowShape {
2994 table: "repository graph staging database",
2995 reason: "staging path already exists",
2996 });
2997 }
2998 let mut store = Self::open_for_project(path, root)?;
2999 set_project_identity(&store.connection, project)?;
3000 store.connection.execute(
3001 "INSERT INTO metadata(key, value) VALUES (?1, ?2)
3002 ON CONFLICT(key) DO UPDATE SET value = excluded.value",
3003 params![GRAPH_STAGING_MARKER_KEY, GRAPH_STAGING_MARKER_VALUE],
3004 )?;
3005 drop_graph_rebuildable_indexes(&store.connection)?;
3006 store.checkpoint_repository_graph_staging()?;
3007 store.validated_project_instance_id = Some(project);
3008 Ok(store)
3009 }
3010
3011 pub fn checkpoint_repository_graph_staging(&self) -> DbResult<()> {
3018 let (busy, _log_frames, _checkpointed_frames) =
3019 self.connection
3020 .query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |row| {
3021 Ok((
3022 row.get::<_, i64>(0)?,
3023 row.get::<_, i64>(1)?,
3024 row.get::<_, i64>(2)?,
3025 ))
3026 })?;
3027 if busy != 0 {
3028 return Err(DbError::GraphRowShape {
3029 table: "repository graph staging database",
3030 reason: "WAL checkpoint remained busy",
3031 });
3032 }
3033 Ok(())
3034 }
3035
3036 pub fn begin_repository_graph_staging(
3043 &mut self,
3044 project: ProjectInstanceId,
3045 generation: IndexGeneration,
3046 ) -> DbResult<RepositoryGraphStagingGuard<'_>> {
3047 if generation == IndexGeneration::ZERO {
3048 return Err(GraphContractError::InvalidGeneration.into());
3049 }
3050 let transaction = self.connection.transaction()?;
3051 require_bound_project_identity(&transaction, project)?;
3052 transaction.execute("DELETE FROM graph_resolution_keys", [])?;
3053 transaction.execute("DELETE FROM graph_coverage", [])?;
3054 transaction.execute("DELETE FROM graph_relations", [])?;
3055 transaction.execute("DELETE FROM graph_entities", [])?;
3056 Ok(RepositoryGraphStagingGuard {
3057 transaction,
3058 project,
3059 generation,
3060 })
3061 }
3062}
3063
3064impl RepositoryGraphStagingGuard<'_> {
3065 pub fn append_entity_refs(&mut self, entities: &[&GraphEntity]) -> DbResult<()> {
3072 if entities.iter().any(|entity| {
3073 entity.key().project() != self.project || entity.generation() != self.generation
3074 }) {
3075 return Err(GraphContractError::GenerationMismatch {
3076 context: "repository graph staging entities",
3077 }
3078 .into());
3079 }
3080 insert_entities(&self.transaction, self.project, entities.iter().copied())
3081 }
3082
3083 #[allow(clippy::too_many_arguments)]
3090 pub fn append_batch(
3091 &mut self,
3092 entities: &[GraphEntity],
3093 relations: &[LogicalRelation],
3094 occurrences: &[RelationOccurrence],
3095 coverage: &[CoverageRecord],
3096 entity_exports: &[EntityResolutionKey],
3097 relation_dependencies: &[RelationDependencyKey],
3098 ) -> DbResult<()> {
3099 validate_graph_batch(
3100 self.project,
3101 self.generation,
3102 entities,
3103 relations,
3104 occurrences,
3105 coverage,
3106 )?;
3107 validate_resolution_key_batch(self.project, entity_exports, relation_dependencies)?;
3108 insert_graph_batch(
3109 &self.transaction,
3110 self.project,
3111 entities,
3112 relations,
3113 occurrences,
3114 coverage,
3115 )?;
3116 insert_resolution_key_batch(
3117 &self.transaction,
3118 self.project,
3119 entity_exports,
3120 relation_dependencies,
3121 )
3122 }
3123
3124 pub fn complete(self) -> DbResult<()> {
3130 set_graph_generation(&self.transaction, self.generation)?;
3131 self.transaction.commit()?;
3132 Ok(())
3133 }
3134}
3135
3136const GRAPH_STAGE_COPY_TABLES: &[(&str, &str)] = &[
3138 (
3139 "graph_entities",
3140 "entity_key, project_instance_id, canonical_identity, entity_kind, repository_path, \
3141 package_manager, package_name, manifest_path, symbol_name, symbol_kind, symbol_parent, \
3142 symbol_signature, external_system, external_identity",
3143 ),
3144 (
3145 "graph_relations",
3146 "relation_key, project_instance_id, canonical_identity, source_entity_key, \
3147 relation_scope, relation_kind, resolution_status, target_entity_key, reference_text, \
3148 candidate_count, confidence, completeness",
3149 ),
3150 (
3151 "graph_relation_occurrences",
3152 "relation_key, file_path, start_line, start_column, end_line, end_column",
3153 ),
3154 (
3155 "graph_coverage",
3156 "project_instance_id, scope_kind, scope_path, relation_scope, relation_kind, state, \
3157 total, covered, omitted, reason, reached_limit",
3158 ),
3159 (
3160 "graph_resolution_keys",
3161 "project_instance_id, resolution_domain, key_digest, canonical_identity",
3162 ),
3163 (
3164 "graph_entity_exports",
3165 "project_instance_id, entity_key, owner_path, resolution_domain, key_digest",
3166 ),
3167 (
3168 "graph_relation_dependencies",
3169 "project_instance_id, relation_key, owner_path, resolution_domain, key_digest",
3170 ),
3171];
3172
3173const GRAPH_REBUILDABLE_INDEX_NAMES: &[&str] = &[
3175 "idx_graph_entities_path",
3176 "idx_graph_entities_package",
3177 "idx_graph_entities_manifest_path",
3178 "idx_graph_entities_symbol",
3179 "idx_graph_entities_external",
3180 "idx_graph_relations_source_kind",
3181 "idx_graph_relations_target_kind",
3182 "idx_graph_relations_kind_order",
3183 "idx_graph_relations_kind_resolution",
3184 "idx_graph_occurrences_file_span",
3185 "idx_graph_coverage_scope_state",
3186 "idx_graph_coverage_scope_order",
3187 "idx_graph_coverage_path",
3188 "idx_graph_coverage_relation_state",
3189 "idx_graph_coverage_discovery_state",
3190 "idx_graph_coverage_discovery_reason",
3191 "idx_graph_entity_exports_key",
3192 "idx_graph_entity_exports_owner",
3193 "idx_graph_relation_dependencies_key",
3194 "idx_graph_relation_dependencies_owner",
3195];
3196
3197fn drop_graph_rebuildable_indexes(connection: &Connection) -> DbResult<()> {
3199 for name in GRAPH_REBUILDABLE_INDEX_NAMES {
3200 connection.execute(&format!("DROP INDEX IF EXISTS {name}"), [])?;
3201 }
3202 Ok(())
3203}
3204
3205fn graph_rebuildable_index_sql(connection: &Connection) -> DbResult<Vec<String>> {
3207 GRAPH_REBUILDABLE_INDEX_NAMES
3208 .iter()
3209 .map(|name| {
3210 connection
3211 .query_row(
3212 "SELECT sql FROM sqlite_schema WHERE type = 'index' AND name = ?1",
3213 [name],
3214 |row| row.get::<_, String>(0),
3215 )
3216 .optional()?
3217 .ok_or(DbError::GraphRowShape {
3218 table: "repository graph indexes",
3219 reason: "required rebuildable index is missing",
3220 })
3221 })
3222 .collect()
3223}
3224const GRAPH_STAGE_COPY_ROWS: usize = 512;
3226
3227fn insert_graph_stage_values(
3229 target: &Connection,
3230 table: &'static str,
3231 columns: &'static str,
3232 column_count: usize,
3233 values: &[Value],
3234) -> DbResult<()> {
3235 let rows = values.len() / column_count;
3236 let row = format!(
3237 "({})",
3238 std::iter::repeat_n("?", column_count)
3239 .collect::<Vec<_>>()
3240 .join(",")
3241 );
3242 let placeholders = std::iter::repeat_n(row, rows).collect::<Vec<_>>().join(",");
3243 let insert_sql = format!("INSERT INTO {table}({columns}) VALUES{placeholders}");
3244 target.execute(&insert_sql, params_from_iter(values.iter()))?;
3245 Ok(())
3246}
3247
3248fn copy_graph_stage_table(
3250 source: &Connection,
3251 target: &Connection,
3252 table: &'static str,
3253 columns: &'static str,
3254 control: Option<&IndexWorkControl>,
3255) -> DbResult<()> {
3256 let column_count = columns.split(',').count();
3257 let select_sql = format!("SELECT {columns} FROM {table}");
3258 let mut select = source.prepare(&select_sql)?;
3259 let mut rows = select.query([])?;
3260 let mut copied = 0_usize;
3261 let mut values = Vec::with_capacity(GRAPH_STAGE_COPY_ROWS.saturating_mul(column_count));
3262 while let Some(row) = rows.next()? {
3263 if copied.is_multiple_of(256)
3264 && let Some(control) = control
3265 {
3266 control.check(IndexWorkStage::Publication)?;
3267 }
3268 for column in 0..column_count {
3269 values.push(row.get::<_, Value>(column)?);
3270 }
3271 copied = copied.saturating_add(1);
3272 if copied.is_multiple_of(GRAPH_STAGE_COPY_ROWS) {
3273 insert_graph_stage_values(target, table, columns, column_count, &values)?;
3274 values.clear();
3275 }
3276 }
3277 if !values.is_empty() {
3278 insert_graph_stage_values(target, table, columns, column_count, &values)?;
3279 }
3280 Ok(())
3281}
3282
3283impl IndexPublicationGuard<'_> {
3284 pub fn replace_repository_graph_from_staging(
3291 &mut self,
3292 project: ProjectInstanceId,
3293 staging: &AtlasStore,
3294 control: Option<&IndexWorkControl>,
3295 ) -> DbResult<()> {
3296 let generation = self.pending_graph_generation()?;
3297 let staged_project = load_project_identity(&staging.connection)?;
3298 if staged_project != Some(project) {
3299 return Err(DbError::GraphProjectIdentityMismatch {
3300 expected: project.to_string(),
3301 found: staged_project
3302 .map_or_else(|| "missing".to_string(), |value| value.to_string()),
3303 });
3304 }
3305 if load_graph_generation(&staging.connection)? != Some(generation) {
3306 return Err(GraphContractError::GenerationMismatch {
3307 context: "staged repository graph",
3308 }
3309 .into());
3310 }
3311 let quick_check = staging
3312 .connection
3313 .query_row("PRAGMA quick_check(1)", [], |row| row.get::<_, String>(0))?;
3314 if quick_check != "ok" {
3315 return Err(DbError::GraphRowShape {
3316 table: "repository graph staging database",
3317 reason: "quick check failed",
3318 });
3319 }
3320 let mut foreign_keys = staging.connection.prepare("PRAGMA foreign_key_check")?;
3321 if foreign_keys.query([])?.next()?.is_some() {
3322 return Err(DbError::GraphRowShape {
3323 table: "repository graph staging database",
3324 reason: "foreign key check failed",
3325 });
3326 }
3327
3328 let savepoint = self.store.connection.savepoint()?;
3329 require_bound_project_identity(&savepoint, project)?;
3330 let rebuildable_indexes = graph_rebuildable_index_sql(&savepoint)?;
3331 drop_graph_rebuildable_indexes(&savepoint)?;
3332 savepoint.execute("DELETE FROM graph_resolution_keys", [])?;
3333 savepoint.execute("DELETE FROM graph_coverage", [])?;
3334 savepoint.execute("DELETE FROM graph_relations", [])?;
3335 savepoint.execute("DELETE FROM graph_entities", [])?;
3336 for (table, columns) in GRAPH_STAGE_COPY_TABLES {
3337 copy_graph_stage_table(&staging.connection, &savepoint, table, columns, control)?;
3338 }
3339 set_graph_generation(&savepoint, generation)?;
3340 for index_sql in rebuildable_indexes {
3341 savepoint.execute_batch(&index_sql)?;
3342 }
3343 savepoint.commit()?;
3344 Ok(())
3345 }
3346
3347 pub fn replace_repository_graph(
3354 &mut self,
3355 project: ProjectInstanceId,
3356 entities: &[GraphEntity],
3357 relations: &[LogicalRelation],
3358 occurrences: &[RelationOccurrence],
3359 coverage: &[CoverageRecord],
3360 ) -> DbResult<()> {
3361 self.replace_repository_graph_with_resolution_keys(
3362 project,
3363 entities,
3364 relations,
3365 occurrences,
3366 coverage,
3367 &[],
3368 &[],
3369 )
3370 }
3371
3372 #[allow(clippy::too_many_arguments)]
3380 pub fn replace_repository_graph_with_resolution_keys(
3381 &mut self,
3382 project: ProjectInstanceId,
3383 entities: &[GraphEntity],
3384 relations: &[LogicalRelation],
3385 occurrences: &[RelationOccurrence],
3386 coverage: &[CoverageRecord],
3387 entity_exports: &[EntityResolutionKey],
3388 relation_dependencies: &[RelationDependencyKey],
3389 ) -> DbResult<()> {
3390 let generation = self.pending_graph_generation()?;
3391 validate_graph_batch(
3392 project,
3393 generation,
3394 entities,
3395 relations,
3396 occurrences,
3397 coverage,
3398 )?;
3399 validate_resolution_key_batch(project, entity_exports, relation_dependencies)?;
3400 let savepoint = self.store.connection.savepoint()?;
3401 require_bound_project_identity(&savepoint, project)?;
3402 savepoint.execute("DELETE FROM graph_resolution_keys", [])?;
3403 savepoint.execute("DELETE FROM graph_coverage", [])?;
3404 savepoint.execute("DELETE FROM graph_relations", [])?;
3405 savepoint.execute("DELETE FROM graph_entities", [])?;
3406 insert_graph_batch(
3407 &savepoint,
3408 project,
3409 entities,
3410 relations,
3411 occurrences,
3412 coverage,
3413 )?;
3414 insert_resolution_key_batch(&savepoint, project, entity_exports, relation_dependencies)?;
3415 set_graph_generation(&savepoint, generation)?;
3416 savepoint.commit()?;
3417 Ok(())
3418 }
3419
3420 pub fn replace_repository_graph_for_paths(
3431 &mut self,
3432 project: ProjectInstanceId,
3433 affected_paths: &[String],
3434 entities: &[GraphEntity],
3435 relations: &[LogicalRelation],
3436 occurrences: &[RelationOccurrence],
3437 coverage: &[CoverageRecord],
3438 ) -> DbResult<()> {
3439 self.replace_repository_graph_for_paths_with_resolution_keys(
3440 project,
3441 affected_paths,
3442 entities,
3443 relations,
3444 occurrences,
3445 coverage,
3446 &[],
3447 &[],
3448 )
3449 }
3450
3451 #[allow(clippy::too_many_arguments)]
3459 pub fn replace_repository_graph_for_paths_with_resolution_keys(
3460 &mut self,
3461 project: ProjectInstanceId,
3462 affected_paths: &[String],
3463 entities: &[GraphEntity],
3464 relations: &[LogicalRelation],
3465 occurrences: &[RelationOccurrence],
3466 coverage: &[CoverageRecord],
3467 entity_exports: &[EntityResolutionKey],
3468 relation_dependencies: &[RelationDependencyKey],
3469 ) -> DbResult<()> {
3470 let generation = self.pending_graph_generation()?;
3471 validate_graph_batch(
3472 project,
3473 generation,
3474 entities,
3475 relations,
3476 occurrences,
3477 coverage,
3478 )?;
3479 validate_resolution_key_batch(project, entity_exports, relation_dependencies)?;
3480 let affected_paths = affected_paths
3481 .iter()
3482 .map(|path| RepositoryNodePath::new(Path::new(path)))
3483 .collect::<Result<Vec<_>, _>>()?;
3484 let savepoint = self.store.connection.savepoint()?;
3485 require_bound_project_identity(&savepoint, project)?;
3486 if affected_paths.iter().any(|path| path.as_str() == ".") {
3487 savepoint.execute("DELETE FROM graph_resolution_keys", [])?;
3488 savepoint.execute("DELETE FROM graph_coverage", [])?;
3489 savepoint.execute("DELETE FROM graph_relations", [])?;
3490 savepoint.execute("DELETE FROM graph_entities", [])?;
3491 insert_graph_batch(
3492 &savepoint,
3493 project,
3494 entities,
3495 relations,
3496 occurrences,
3497 coverage,
3498 )?;
3499 insert_resolution_key_batch(
3500 &savepoint,
3501 project,
3502 entity_exports,
3503 relation_dependencies,
3504 )?;
3505 set_graph_generation(&savepoint, generation)?;
3506 savepoint.commit()?;
3507 return Ok(());
3508 }
3509 let touched_keys = resolution_keys_for_owner_paths(
3510 &savepoint,
3511 project,
3512 &affected_paths,
3513 ResolutionOwner::Both,
3514 None,
3515 )?;
3516 let mut orphan_candidates = affected_external_candidates(&savepoint, &affected_paths)?;
3517 invalidate_repository_graph_paths(&savepoint, &affected_paths, &mut orphan_candidates)?;
3518 insert_graph_batch(
3519 &savepoint,
3520 project,
3521 entities,
3522 relations,
3523 occurrences,
3524 coverage,
3525 )?;
3526 insert_resolution_key_batch(&savepoint, project, entity_exports, relation_dependencies)?;
3527 for entity in entities {
3528 if matches!(entity.selector(), EntitySelector::External { .. }) {
3529 orphan_candidates.insert(entity.key().digest_bytes()?);
3530 }
3531 }
3532 remove_orphan_external_candidates(&savepoint, &orphan_candidates)?;
3533 remove_touched_orphan_resolution_keys(&savepoint, &touched_keys)?;
3534 set_graph_generation(&savepoint, generation)?;
3535 savepoint.commit()?;
3536 Ok(())
3537 }
3538
3539 fn pending_graph_generation(&self) -> DbResult<IndexGeneration> {
3541 self.previous_generation
3542 .checked_next()
3543 .ok_or(DbError::PublicationGenerationOverflow)
3544 }
3545}
3546
3547const RESOLUTION_KEYS_PER_QUERY: usize = 200;
3549const RESOLUTION_PATHS_PER_QUERY: usize = 400;
3551const EXTERNAL_CANDIDATE_KEYS_PER_QUERY: usize = 400;
3553
3554#[derive(Clone, Copy)]
3556enum ResolutionOwner {
3557 EntityExports,
3559 Both,
3561}
3562
3563impl ResolutionOwner {
3564 fn tables(self) -> &'static [(&'static str, &'static str)] {
3566 match self {
3567 Self::EntityExports => &[("graph_entity_exports", "idx_graph_entity_exports_owner")],
3568 Self::Both => &[
3569 ("graph_entity_exports", "idx_graph_entity_exports_owner"),
3570 (
3571 "graph_relation_dependencies",
3572 "idx_graph_relation_dependencies_owner",
3573 ),
3574 ],
3575 }
3576 }
3577}
3578
3579fn normalized_file_paths(paths: &[String]) -> DbResult<Vec<RepositoryNodePath>> {
3581 let mut normalized = BTreeSet::new();
3582 for path in paths {
3583 let file = RepositoryFilePath::new(Path::new(path))?;
3584 normalized.insert(RepositoryNodePath::new(Path::new(file.as_str()))?);
3585 }
3586 Ok(normalized.into_iter().collect())
3587}
3588
3589fn normalized_resolution_keys(
3591 project: ProjectInstanceId,
3592 keys: &[CanonicalResolutionKey],
3593) -> DbResult<Vec<CanonicalResolutionKey>> {
3594 let mut normalized = BTreeSet::new();
3595 for key in keys {
3596 if key.project() != project {
3597 return Err(DbError::GraphProjectIdentityMismatch {
3598 expected: project.to_string(),
3599 found: key.project().to_string(),
3600 });
3601 }
3602 normalized.insert(key.clone());
3603 }
3604 Ok(normalized.into_iter().collect())
3605}
3606
3607fn page_from_ordered_set<T: Ord>(rows: BTreeSet<T>, limit: u32) -> RepositoryGraphPage<T> {
3609 let truncated = rows.len() > limit as usize;
3610 RepositoryGraphPage {
3611 rows: rows.into_iter().take(limit as usize).collect(),
3612 truncated,
3613 }
3614}
3615
3616fn resolution_keys_for_owner_paths(
3618 connection: &Connection,
3619 project: ProjectInstanceId,
3620 paths: &[RepositoryNodePath],
3621 owners: ResolutionOwner,
3622 limit: Option<u32>,
3623) -> DbResult<BTreeSet<CanonicalResolutionKey>> {
3624 let mut keys = BTreeSet::new();
3625 for (table, index) in owners.tables() {
3626 for chunk in paths.chunks(RESOLUTION_PATHS_PER_QUERY) {
3627 if chunk.is_empty() {
3628 continue;
3629 }
3630 let placeholders = vec!["?"; chunk.len()].join(",");
3631 let limit_clause = limit.map_or("", |_| " LIMIT ?");
3632 let sql = format!(
3633 "SELECT DISTINCT resolution.project_instance_id,
3634 resolution.resolution_domain, resolution.key_digest,
3635 resolution.canonical_identity
3636 FROM {table} AS owner INDEXED BY {index}
3637 JOIN graph_resolution_keys AS resolution
3638 ON resolution.project_instance_id = owner.project_instance_id
3639 AND resolution.resolution_domain = owner.resolution_domain
3640 AND resolution.key_digest = owner.key_digest
3641 WHERE owner.project_instance_id = ?
3642 AND owner.owner_path IN ({placeholders})
3643 ORDER BY resolution.resolution_domain, resolution.key_digest{limit_clause}"
3644 );
3645 let mut values = Vec::with_capacity(chunk.len() + 2);
3646 values.push(Value::Blob(project.as_bytes().to_vec()));
3647 values.extend(
3648 chunk
3649 .iter()
3650 .map(|path| Value::Text(path.as_str().to_string())),
3651 );
3652 if let Some(limit) = limit {
3653 values.push(Value::Integer(i64::from(limit) + 1));
3654 }
3655 let mut statement = connection.prepare(&sql)?;
3656 let mut rows = statement.query(params_from_iter(values.iter()))?;
3657 while let Some(row) = rows.next()? {
3658 keys.insert(resolution_key_from_row(row, project)?);
3659 }
3660 if limit.is_some_and(|limit| keys.len() > limit as usize) {
3661 return Ok(keys);
3662 }
3663 }
3664 }
3665 Ok(keys)
3666}
3667
3668fn resolution_key_from_row(
3670 row: &Row<'_>,
3671 expected_project: ProjectInstanceId,
3672) -> DbResult<CanonicalResolutionKey> {
3673 let project = project_from_blob(
3674 "graph_resolution_keys.project_instance_id",
3675 row.get::<_, Vec<u8>>(0)?,
3676 )?;
3677 require_project(expected_project, project)?;
3678 let domain_text = row.get::<_, String>(1)?;
3679 let domain = ResolutionKeyDomain::try_from(domain_text.as_str())?;
3680 let digest = fixed_bytes::<32>(
3681 "graph_resolution_keys.key_digest",
3682 row.get::<_, Vec<u8>>(2)?,
3683 )?;
3684 Ok(CanonicalResolutionKey::from_persisted(
3685 project,
3686 domain,
3687 digest,
3688 row.get(3)?,
3689 )?)
3690}
3691
3692fn validate_persisted_resolution_key(
3694 connection: &Connection,
3695 key: &CanonicalResolutionKey,
3696) -> DbResult<bool> {
3697 let stored = connection
3698 .query_row(
3699 "SELECT canonical_identity
3700 FROM graph_resolution_keys
3701 WHERE project_instance_id = ?1
3702 AND resolution_domain = ?2
3703 AND key_digest = ?3",
3704 params![
3705 &key.project().as_bytes()[..],
3706 key.domain().as_str(),
3707 &key.digest_bytes()[..],
3708 ],
3709 |row| row.get::<_, String>(0),
3710 )
3711 .optional()?;
3712 let Some(stored) = stored else {
3713 return Ok(false);
3714 };
3715 if stored != key.canonical_identity() {
3716 return Err(DbError::ResolutionKeyCollision {
3717 domain: key.domain().as_str(),
3718 digest: key.digest_bytes(),
3719 });
3720 }
3721 CanonicalResolutionKey::from_persisted(
3722 key.project(),
3723 key.domain(),
3724 key.digest_bytes(),
3725 stored,
3726 )?;
3727 Ok(true)
3728}
3729
3730fn validate_persisted_resolution_keys(
3732 connection: &Connection,
3733 keys: &[CanonicalResolutionKey],
3734) -> DbResult<()> {
3735 for chunk in keys.chunks(RESOLUTION_KEYS_PER_QUERY) {
3736 if chunk.is_empty() {
3737 continue;
3738 }
3739 let values_clause = anonymous_values_clause(chunk.len(), 4);
3740 let sql = format!(
3741 "WITH requested(project_instance_id, resolution_domain, key_digest, canonical_identity)
3742 AS (VALUES {values_clause})
3743 SELECT requested.resolution_domain, requested.key_digest,
3744 requested.canonical_identity, stored.canonical_identity
3745 FROM requested
3746 JOIN graph_resolution_keys AS stored
3747 ON stored.project_instance_id = requested.project_instance_id
3748 AND stored.resolution_domain = requested.resolution_domain
3749 AND stored.key_digest = requested.key_digest
3750 ORDER BY requested.resolution_domain, requested.key_digest"
3751 );
3752 let values = resolution_key_values(chunk, true);
3753 let mut statement = connection.prepare(&sql)?;
3754 let mut rows = statement.query(params_from_iter(values.iter()))?;
3755 while let Some(row) = rows.next()? {
3756 let domain_text = row.get::<_, String>(0)?;
3757 let domain = ResolutionKeyDomain::try_from(domain_text.as_str())?;
3758 let digest = fixed_bytes::<32>(
3759 "graph_resolution_keys.key_digest",
3760 row.get::<_, Vec<u8>>(1)?,
3761 )?;
3762 let requested = row.get::<_, String>(2)?;
3763 let stored = row.get::<_, String>(3)?;
3764 if requested != stored {
3765 return Err(DbError::ResolutionKeyCollision {
3766 domain: domain.as_str(),
3767 digest,
3768 });
3769 }
3770 }
3771 }
3772 Ok(())
3773}
3774
3775fn affected_source_paths(
3777 connection: &Connection,
3778 keys: &[CanonicalResolutionKey],
3779 limit: u32,
3780) -> DbResult<BTreeSet<RepositoryFilePath>> {
3781 let mut paths = BTreeSet::new();
3782 for chunk in keys.chunks(RESOLUTION_KEYS_PER_QUERY) {
3783 if chunk.is_empty() {
3784 continue;
3785 }
3786 let values_clause = anonymous_values_clause(chunk.len(), 4);
3787 let sql = format!(
3788 "WITH requested(project_instance_id, resolution_domain, key_digest, canonical_identity)
3789 AS (VALUES {values_clause})
3790 SELECT DISTINCT dependency.owner_path
3791 FROM requested
3792 JOIN graph_resolution_keys AS stored
3793 ON stored.project_instance_id = requested.project_instance_id
3794 AND stored.resolution_domain = requested.resolution_domain
3795 AND stored.key_digest = requested.key_digest
3796 AND stored.canonical_identity = requested.canonical_identity
3797 JOIN graph_relation_dependencies AS dependency
3798 INDEXED BY idx_graph_relation_dependencies_key
3799 ON dependency.project_instance_id = stored.project_instance_id
3800 AND dependency.resolution_domain = stored.resolution_domain
3801 AND dependency.key_digest = stored.key_digest
3802 ORDER BY dependency.owner_path
3803 LIMIT ?"
3804 );
3805 let mut values = resolution_key_values(chunk, true);
3806 values.push(Value::Integer(i64::from(limit) + 1));
3807 let mut statement = connection.prepare(&sql)?;
3808 let mut rows = statement.query(params_from_iter(values.iter()))?;
3809 while let Some(row) = rows.next()? {
3810 paths.insert(RepositoryFilePath::new(Path::new(
3811 &row.get::<_, String>(0)?,
3812 ))?);
3813 }
3814 if paths.len() > limit as usize {
3815 return Ok(paths);
3816 }
3817 }
3818 Ok(paths)
3819}
3820
3821fn affected_source_footprint(
3823 connection: &Connection,
3824 project: ProjectInstanceId,
3825 paths: &[RepositoryNodePath],
3826 limit_plus_one: i64,
3827) -> DbResult<RepositoryAffectedSourceFootprint> {
3828 let maximum_rows = u64::try_from(limit_plus_one).map_err(|source| DbError::InvalidCount {
3829 field: "affected_source_footprint.limit_plus_one",
3830 value: limit_plus_one,
3831 source,
3832 })?;
3833 let mut footprint = empty_affected_source_footprint();
3834 for chunk in paths.chunks(RESOLUTION_PATHS_PER_QUERY) {
3835 if chunk.is_empty() {
3836 continue;
3837 }
3838 let remaining = maximum_rows.saturating_sub(footprint.rows);
3839 if remaining == 0 {
3840 footprint.truncated = true;
3841 break;
3842 }
3843 let sql = affected_source_footprint_sql(chunk.len());
3844 let mut values = Vec::with_capacity(chunk.len() + 2);
3845 values.push(Value::Blob(project.as_bytes().to_vec()));
3846 values.extend(
3847 chunk
3848 .iter()
3849 .map(|path| Value::Text(path.as_str().to_string())),
3850 );
3851 values.push(Value::Integer(i64::try_from(remaining).map_err(
3852 |source| DbError::InvalidCount {
3853 field: "affected_source_footprint.remaining_rows",
3854 value: i64::MAX,
3855 source,
3856 },
3857 )?));
3858 let mut statement = connection.prepare(&sql)?;
3859 let mut rows = statement.query(params_from_iter(values.iter()))?;
3860 while let Some(row) = rows.next()? {
3861 let bytes = nonnegative_u64(
3862 "affected_source_footprint.retained_bytes",
3863 row.get::<_, i64>(0)?,
3864 )?;
3865 footprint.rows = footprint.rows.saturating_add(1);
3866 footprint.retained_bytes = footprint.retained_bytes.saturating_add(bytes);
3867 }
3868 if footprint.rows >= maximum_rows {
3869 footprint.truncated = true;
3870 break;
3871 }
3872 }
3873 Ok(footprint)
3874}
3875
3876fn affected_source_footprint_sql(path_count: usize) -> String {
3878 let requested = vec!["(?)"; path_count].join(",");
3879 format!(
3880 "WITH selected_project(project_instance_id) AS (VALUES (?)),
3881 requested(path) AS (VALUES {requested})
3882 SELECT retained_bytes
3883 FROM (
3884 SELECT length(CAST(metadata.path AS BLOB))
3885 + coalesce(length(CAST(metadata.language AS BLOB)), 0)
3886 + length(CAST(metadata.source_parser AS BLOB))
3887 + length(CAST(metadata.fact_parser AS BLOB)) + 16
3888 + length(CAST(metadata.updated_at AS BLOB)) AS retained_bytes
3889 FROM requested
3890 JOIN source_parse_metadata AS metadata
3891 INDEXED BY sqlite_autoindex_source_parse_metadata_1
3892 ON metadata.path = requested.path
3893 UNION ALL
3894 SELECT 32 + length(CAST(symbol.path AS BLOB))
3895 + coalesce(length(CAST(symbol.language AS BLOB)), 0)
3896 + length(CAST(symbol.name AS BLOB))
3897 + length(CAST(symbol.kind AS BLOB))
3898 + length(CAST(symbol.signature AS BLOB))
3899 + coalesce(length(CAST(symbol.documentation AS BLOB)), 0)
3900 + coalesce(length(CAST(symbol.parent AS BLOB)), 0)
3901 + length(CAST(symbol.parser AS BLOB))
3902 + coalesce(length(CAST(symbol.detail AS BLOB)), 0)
3903 + length(CAST(symbol.created_at AS BLOB))
3904 + length(CAST(symbol.updated_at AS BLOB))
3905 FROM requested
3906 JOIN symbols AS symbol INDEXED BY idx_symbols_path
3907 ON symbol.path = requested.path
3908 UNION ALL
3909 SELECT 16 + length(CAST(symbol_relation.path AS BLOB))
3910 + length(CAST(symbol_relation.source_name AS BLOB))
3911 + length(CAST(symbol_relation.target_name AS BLOB))
3912 + length(CAST(symbol_relation.kind AS BLOB))
3913 + length(CAST(symbol_relation.context AS BLOB))
3914 + length(CAST(symbol_relation.parser AS BLOB))
3915 + length(CAST(symbol_relation.created_at AS BLOB))
3916 FROM requested
3917 JOIN symbol_relations AS symbol_relation INDEXED BY idx_symbol_relations_path
3918 ON symbol_relation.path = requested.path
3919 UNION ALL
3920 SELECT 48 + length(CAST(entity.canonical_identity AS BLOB))
3921 + length(CAST(entity.entity_kind AS BLOB))
3922 + coalesce(length(CAST(entity.repository_path AS BLOB)), 0)
3923 + coalesce(length(CAST(entity.package_manager AS BLOB)), 0)
3924 + coalesce(length(CAST(entity.package_name AS BLOB)), 0)
3925 + coalesce(length(CAST(entity.manifest_path AS BLOB)), 0)
3926 + coalesce(length(CAST(entity.symbol_name AS BLOB)), 0)
3927 + coalesce(length(CAST(entity.symbol_kind AS BLOB)), 0)
3928 + coalesce(length(CAST(entity.symbol_parent AS BLOB)), 0)
3929 + coalesce(length(CAST(entity.symbol_signature AS BLOB)), 0)
3930 + coalesce(length(CAST(entity.external_system AS BLOB)), 0)
3931 + coalesce(length(CAST(entity.external_identity AS BLOB)), 0)
3932 FROM requested
3933 JOIN graph_entities AS entity INDEXED BY idx_graph_entities_path
3934 ON entity.repository_path = requested.path
3935 JOIN selected_project
3936 ON selected_project.project_instance_id = entity.project_instance_id
3937 UNION ALL
3938 SELECT 48 + length(CAST(entity.canonical_identity AS BLOB))
3939 + length(CAST(entity.entity_kind AS BLOB))
3940 + coalesce(length(CAST(entity.repository_path AS BLOB)), 0)
3941 + coalesce(length(CAST(entity.package_manager AS BLOB)), 0)
3942 + coalesce(length(CAST(entity.package_name AS BLOB)), 0)
3943 + coalesce(length(CAST(entity.manifest_path AS BLOB)), 0)
3944 + coalesce(length(CAST(entity.symbol_name AS BLOB)), 0)
3945 + coalesce(length(CAST(entity.symbol_kind AS BLOB)), 0)
3946 + coalesce(length(CAST(entity.symbol_parent AS BLOB)), 0)
3947 + coalesce(length(CAST(entity.symbol_signature AS BLOB)), 0)
3948 + coalesce(length(CAST(entity.external_system AS BLOB)), 0)
3949 + coalesce(length(CAST(entity.external_identity AS BLOB)), 0)
3950 FROM requested
3951 JOIN graph_entities AS entity INDEXED BY idx_graph_entities_manifest_path
3952 ON entity.manifest_path = requested.path
3953 JOIN selected_project
3954 ON selected_project.project_instance_id = entity.project_instance_id
3955 UNION ALL
3956 SELECT 80 + length(CAST(relation.canonical_identity AS BLOB))
3957 + length(CAST(relation.relation_scope AS BLOB))
3958 + length(CAST(relation.relation_kind AS BLOB))
3959 + length(CAST(relation.resolution_status AS BLOB))
3960 + coalesce(length(relation.target_entity_key), 0)
3961 + coalesce(length(CAST(relation.reference_text AS BLOB)), 0)
3962 + CASE WHEN relation.candidate_count IS NULL THEN 0 ELSE 8 END
3963 + length(CAST(relation.confidence AS BLOB))
3964 + length(CAST(relation.completeness AS BLOB))
3965 FROM requested
3966 JOIN graph_entities AS entity INDEXED BY idx_graph_entities_path
3967 ON entity.repository_path = requested.path
3968 JOIN selected_project
3969 ON selected_project.project_instance_id = entity.project_instance_id
3970 JOIN graph_relations AS relation INDEXED BY idx_graph_relations_source_kind
3971 ON relation.source_entity_key = entity.entity_key
3972 AND relation.project_instance_id = selected_project.project_instance_id
3973 UNION ALL
3974 SELECT 80 + length(CAST(relation.canonical_identity AS BLOB))
3975 + length(CAST(relation.relation_scope AS BLOB))
3976 + length(CAST(relation.relation_kind AS BLOB))
3977 + length(CAST(relation.resolution_status AS BLOB))
3978 + coalesce(length(relation.target_entity_key), 0)
3979 + coalesce(length(CAST(relation.reference_text AS BLOB)), 0)
3980 + CASE WHEN relation.candidate_count IS NULL THEN 0 ELSE 8 END
3981 + length(CAST(relation.confidence AS BLOB))
3982 + length(CAST(relation.completeness AS BLOB))
3983 FROM requested
3984 JOIN graph_entities AS entity INDEXED BY idx_graph_entities_manifest_path
3985 ON entity.manifest_path = requested.path
3986 JOIN selected_project
3987 ON selected_project.project_instance_id = entity.project_instance_id
3988 JOIN graph_relations AS relation INDEXED BY idx_graph_relations_source_kind
3989 ON relation.source_entity_key = entity.entity_key
3990 AND relation.project_instance_id = selected_project.project_instance_id
3991 UNION ALL
3992 SELECT 72 + length(CAST(occurrence.file_path AS BLOB))
3993 FROM requested
3994 JOIN graph_relation_occurrences AS occurrence
3995 INDEXED BY idx_graph_occurrences_file_span
3996 ON occurrence.file_path = requested.path
3997 UNION ALL
3998 SELECT 48 + length(CAST(coverage.scope_kind AS BLOB))
3999 + coalesce(length(CAST(coverage.scope_path AS BLOB)), 0)
4000 + coalesce(length(CAST(coverage.relation_scope AS BLOB)), 0)
4001 + coalesce(length(CAST(coverage.relation_kind AS BLOB)), 0)
4002 + length(CAST(coverage.state AS BLOB))
4003 + coalesce(length(CAST(coverage.reason AS BLOB)), 0)
4004 + coalesce(length(CAST(coverage.reached_limit AS BLOB)), 0)
4005 FROM requested
4006 JOIN graph_coverage AS coverage INDEXED BY idx_graph_coverage_path
4007 ON coverage.scope_path = requested.path
4008 JOIN selected_project
4009 ON selected_project.project_instance_id = coverage.project_instance_id
4010 UNION ALL
4011 SELECT 80 + length(CAST(export.owner_path AS BLOB))
4012 + length(CAST(export.resolution_domain AS BLOB))
4013 FROM requested
4014 JOIN graph_entity_exports AS export INDEXED BY idx_graph_entity_exports_owner
4015 ON export.owner_path = requested.path
4016 JOIN selected_project
4017 ON selected_project.project_instance_id = export.project_instance_id
4018 UNION ALL
4019 SELECT length(witness.project_instance_id)
4020 + length(CAST(witness.resolution_domain AS BLOB))
4021 + length(witness.key_digest)
4022 + length(CAST(witness.canonical_identity AS BLOB))
4023 FROM requested
4024 JOIN graph_entity_exports AS export INDEXED BY idx_graph_entity_exports_owner
4025 ON export.owner_path = requested.path
4026 JOIN selected_project
4027 ON selected_project.project_instance_id = export.project_instance_id
4028 LEFT JOIN graph_resolution_keys AS witness
4029 ON witness.project_instance_id = export.project_instance_id
4030 AND witness.resolution_domain = export.resolution_domain
4031 AND witness.key_digest = export.key_digest
4032 UNION ALL
4033 SELECT 80 + length(CAST(dependency.owner_path AS BLOB))
4034 + length(CAST(dependency.resolution_domain AS BLOB))
4035 FROM requested
4036 JOIN graph_relation_dependencies AS dependency
4037 INDEXED BY idx_graph_relation_dependencies_owner
4038 ON dependency.owner_path = requested.path
4039 JOIN selected_project
4040 ON selected_project.project_instance_id = dependency.project_instance_id
4041 UNION ALL
4042 SELECT length(witness.project_instance_id)
4043 + length(CAST(witness.resolution_domain AS BLOB))
4044 + length(witness.key_digest)
4045 + length(CAST(witness.canonical_identity AS BLOB))
4046 FROM requested
4047 JOIN graph_relation_dependencies AS dependency
4048 INDEXED BY idx_graph_relation_dependencies_owner
4049 ON dependency.owner_path = requested.path
4050 JOIN selected_project
4051 ON selected_project.project_instance_id = dependency.project_instance_id
4052 LEFT JOIN graph_resolution_keys AS witness
4053 ON witness.project_instance_id = dependency.project_instance_id
4054 AND witness.resolution_domain = dependency.resolution_domain
4055 AND witness.key_digest = dependency.key_digest
4056 )
4057 LIMIT ?"
4058 )
4059}
4060
4061fn anonymous_values_clause(rows: usize, columns: usize) -> String {
4063 let row = format!("({})", vec!["?"; columns].join(","));
4064 vec![row; rows].join(",")
4065}
4066
4067fn resolution_key_values(keys: &[CanonicalResolutionKey], witness: bool) -> Vec<Value> {
4069 let columns = if witness { 4 } else { 3 };
4070 let mut values = Vec::with_capacity(keys.len() * columns);
4071 for key in keys {
4072 values.push(Value::Blob(key.project().as_bytes().to_vec()));
4073 values.push(Value::Text(key.domain().as_str().to_string()));
4074 values.push(Value::Blob(key.digest_bytes().to_vec()));
4075 if witness {
4076 values.push(Value::Text(key.canonical_identity().to_string()));
4077 }
4078 }
4079 values
4080}
4081
4082fn validate_resolution_key_batch(
4084 project: ProjectInstanceId,
4085 entity_exports: &[EntityResolutionKey],
4086 relation_dependencies: &[RelationDependencyKey],
4087) -> DbResult<()> {
4088 let foreign = entity_exports.iter().find_map(|binding| {
4089 (binding.entity().project() != project || binding.key().project() != project)
4090 .then(|| binding.key().project())
4091 });
4092 let foreign = foreign.or_else(|| {
4093 relation_dependencies.iter().find_map(|binding| {
4094 (binding.relation().project() != project || binding.key().project() != project)
4095 .then(|| binding.key().project())
4096 })
4097 });
4098 if let Some(found) = foreign {
4099 return Err(DbError::GraphProjectIdentityMismatch {
4100 expected: project.to_string(),
4101 found: found.to_string(),
4102 });
4103 }
4104 Ok(())
4105}
4106
4107fn insert_resolution_key_batch(
4109 connection: &Connection,
4110 project: ProjectInstanceId,
4111 entity_exports: &[EntityResolutionKey],
4112 relation_dependencies: &[RelationDependencyKey],
4113) -> DbResult<()> {
4114 let mut insert_key = connection.prepare_cached(
4115 "INSERT INTO graph_resolution_keys(
4116 project_instance_id, resolution_domain, key_digest, canonical_identity
4117 ) VALUES(?1, ?2, ?3, ?4)
4118 ON CONFLICT(project_instance_id, resolution_domain, key_digest)
4119 DO UPDATE SET canonical_identity = excluded.canonical_identity
4120 WHERE graph_resolution_keys.canonical_identity = excluded.canonical_identity",
4121 )?;
4122 let mut insert_export = connection.prepare_cached(
4123 "INSERT INTO graph_entity_exports(
4124 project_instance_id, entity_key, owner_path, resolution_domain, key_digest
4125 )
4126 SELECT entity.project_instance_id, entity.entity_key,
4127 CASE entity.entity_kind
4128 WHEN 'file' THEN entity.repository_path
4129 WHEN 'symbol' THEN entity.repository_path
4130 WHEN 'package' THEN entity.manifest_path
4131 END,
4132 ?3, ?4
4133 FROM graph_entities AS entity
4134 WHERE entity.project_instance_id = ?1 AND entity.entity_key = ?2
4135 AND entity.entity_kind IN ('file', 'symbol', 'package')
4136 ON CONFLICT(project_instance_id, entity_key, resolution_domain, key_digest)
4137 DO UPDATE SET owner_path = excluded.owner_path",
4138 )?;
4139 let mut insert_dependency = connection.prepare_cached(
4140 "INSERT INTO graph_relation_dependencies(
4141 project_instance_id, relation_key, owner_path, resolution_domain, key_digest
4142 )
4143 SELECT relation.project_instance_id, relation.relation_key,
4144 CASE source.entity_kind
4145 WHEN 'file' THEN source.repository_path
4146 WHEN 'symbol' THEN source.repository_path
4147 WHEN 'package' THEN source.manifest_path
4148 END,
4149 ?3, ?4
4150 FROM graph_relations AS relation
4151 JOIN graph_entities AS source
4152 ON source.project_instance_id = relation.project_instance_id
4153 AND source.entity_key = relation.source_entity_key
4154 WHERE relation.project_instance_id = ?1 AND relation.relation_key = ?2
4155 AND source.entity_kind IN ('file', 'symbol', 'package')
4156 ON CONFLICT(project_instance_id, relation_key, resolution_domain, key_digest)
4157 DO UPDATE SET owner_path = excluded.owner_path",
4158 )?;
4159
4160 for binding in entity_exports {
4161 insert_resolution_key(&mut insert_key, binding.key())?;
4162 let key = binding.key();
4163 let inserted = insert_export.execute(params![
4164 &project.as_bytes()[..],
4165 &binding.entity().digest_bytes()?[..],
4166 key.domain().as_str(),
4167 &key.digest_bytes()[..],
4168 ])?;
4169 if inserted != 1 {
4170 return Err(DbError::GraphRowShape {
4171 table: "graph_entity_exports",
4172 reason: "export keys require a local file, symbol, or package owner",
4173 });
4174 }
4175 }
4176 for binding in relation_dependencies {
4177 insert_resolution_key(&mut insert_key, binding.key())?;
4178 let key = binding.key();
4179 let inserted = insert_dependency.execute(params![
4180 &project.as_bytes()[..],
4181 &binding.relation().digest_bytes()?[..],
4182 key.domain().as_str(),
4183 &key.digest_bytes()[..],
4184 ])?;
4185 if inserted != 1 {
4186 return Err(DbError::GraphRowShape {
4187 table: "graph_relation_dependencies",
4188 reason: "dependency keys require a local file, symbol, or package source",
4189 });
4190 }
4191 }
4192 Ok(())
4193}
4194
4195fn insert_resolution_key(
4197 statement: &mut rusqlite::CachedStatement<'_>,
4198 key: &CanonicalResolutionKey,
4199) -> DbResult<()> {
4200 let inserted = statement.execute(params![
4201 &key.project().as_bytes()[..],
4202 key.domain().as_str(),
4203 &key.digest_bytes()[..],
4204 key.canonical_identity(),
4205 ])?;
4206 if inserted == 0 {
4207 return Err(DbError::ResolutionKeyCollision {
4208 domain: key.domain().as_str(),
4209 digest: key.digest_bytes(),
4210 });
4211 }
4212 Ok(())
4213}
4214
4215fn remove_touched_orphan_resolution_keys(
4217 connection: &Connection,
4218 keys: &BTreeSet<CanonicalResolutionKey>,
4219) -> DbResult<()> {
4220 let keys = keys.iter().cloned().collect::<Vec<_>>();
4221 for chunk in keys.chunks(RESOLUTION_KEYS_PER_QUERY) {
4222 if chunk.is_empty() {
4223 continue;
4224 }
4225 let values_clause = anonymous_values_clause(chunk.len(), 3);
4226 let sql = format!(
4227 "WITH touched(project_instance_id, resolution_domain, key_digest)
4228 AS (VALUES {values_clause})
4229 DELETE FROM graph_resolution_keys
4230 WHERE rowid IN (
4231 SELECT stored.rowid
4232 FROM graph_resolution_keys AS stored
4233 JOIN touched
4234 ON touched.project_instance_id = stored.project_instance_id
4235 AND touched.resolution_domain = stored.resolution_domain
4236 AND touched.key_digest = stored.key_digest
4237 WHERE NOT EXISTS (
4238 SELECT 1 FROM graph_entity_exports AS export
4239 WHERE export.project_instance_id = stored.project_instance_id
4240 AND export.resolution_domain = stored.resolution_domain
4241 AND export.key_digest = stored.key_digest
4242 )
4243 AND NOT EXISTS (
4244 SELECT 1 FROM graph_relation_dependencies AS dependency
4245 WHERE dependency.project_instance_id = stored.project_instance_id
4246 AND dependency.resolution_domain = stored.resolution_domain
4247 AND dependency.key_digest = stored.key_digest
4248 )
4249 )"
4250 );
4251 let values = resolution_key_values(chunk, false);
4252 connection.execute(&sql, params_from_iter(values.iter()))?;
4253 }
4254 Ok(())
4255}
4256
4257fn affected_external_candidates(
4259 connection: &Connection,
4260 affected_paths: &[RepositoryNodePath],
4261) -> DbResult<HashSet<[u8; 32]>> {
4262 let mut by_repository_path = connection.prepare_cached(
4263 "SELECT entity_key FROM graph_entities
4264 WHERE repository_path = ?1
4265 OR (repository_path >= ?2 AND repository_path < ?3)",
4266 )?;
4267 let mut by_manifest_path = connection.prepare_cached(
4268 "SELECT entity_key FROM graph_entities
4269 WHERE manifest_path = ?1 OR (manifest_path >= ?2 AND manifest_path < ?3)",
4270 )?;
4271 let mut local_keys = HashSet::new();
4272 for path in affected_paths {
4273 let path = path.as_str();
4274 let (descendant_start, descendant_end) = repository_descendant_bounds(path);
4275 for statement in [&mut by_repository_path, &mut by_manifest_path] {
4276 let mut rows = statement.query(params![path, descendant_start, descendant_end])?;
4277 while let Some(row) = rows.next()? {
4278 local_keys.insert(fixed_bytes::<32>(
4279 "graph_entities.entity_key",
4280 row.get::<_, Vec<u8>>(0)?,
4281 )?);
4282 }
4283 }
4284 }
4285
4286 let mut candidates = HashSet::new();
4287 let local_keys = local_keys.into_iter().collect::<Vec<_>>();
4288 for chunk in local_keys.chunks(EXTERNAL_CANDIDATE_KEYS_PER_QUERY) {
4289 let sql = external_candidate_batch_sql(chunk.len());
4290 let mut statement = connection.prepare_cached(&sql)?;
4291 let mut rows = statement.query(params_from_iter(chunk.iter().map(|key| &key[..])))?;
4292 while let Some(row) = rows.next()? {
4293 candidates.insert(fixed_bytes::<32>(
4294 "graph_entities.entity_key",
4295 row.get::<_, Vec<u8>>(0)?,
4296 )?);
4297 }
4298 }
4299 Ok(candidates)
4300}
4301
4302fn external_candidate_batch_sql(key_count: usize) -> String {
4304 let values = anonymous_values_clause(key_count, 1);
4305 format!(
4306 "WITH affected(entity_key) AS (VALUES {values})
4307 SELECT relation.target_entity_key
4308 FROM affected
4309 CROSS JOIN graph_relations AS relation
4310 INDEXED BY idx_graph_relations_source_kind
4311 JOIN graph_entities AS external
4312 ON external.entity_key = relation.target_entity_key
4313 WHERE relation.source_entity_key = affected.entity_key
4314 AND external.entity_kind = 'external'
4315 UNION ALL
4316 SELECT relation.source_entity_key
4317 FROM affected
4318 CROSS JOIN graph_relations AS relation
4319 INDEXED BY idx_graph_relations_target_kind
4320 JOIN graph_entities AS external
4321 ON external.entity_key = relation.source_entity_key
4322 WHERE relation.target_entity_key = affected.entity_key
4323 AND external.entity_kind = 'external'"
4324 )
4325}
4326
4327fn invalidate_repository_graph_paths(
4329 connection: &Connection,
4330 affected_paths: &[RepositoryNodePath],
4331 orphan_candidates: &mut HashSet<[u8; 32]>,
4332) -> DbResult<()> {
4333 let mut affected_relations = HashSet::new();
4334 let mut relation_occurrences = connection.prepare_cached(
4335 "SELECT relation_key FROM graph_relation_occurrences
4336 WHERE file_path = ?1 OR (file_path >= ?2 AND file_path < ?3)",
4337 )?;
4338 let mut occurrences = connection.prepare_cached(
4339 "DELETE FROM graph_relation_occurrences
4340 WHERE file_path = ?1 OR (file_path >= ?2 AND file_path < ?3)",
4341 )?;
4342 let mut coverage = connection.prepare_cached(
4343 "DELETE FROM graph_coverage
4344 INDEXED BY idx_graph_coverage_path
4345 WHERE scope_kind = 'path'
4346 AND (scope_path = ?1 OR (scope_path >= ?2 AND scope_path < ?3))",
4347 )?;
4348 let mut entities_by_path = connection.prepare_cached(
4349 "DELETE FROM graph_entities
4350 WHERE repository_path = ?1
4351 OR (repository_path >= ?2 AND repository_path < ?3)",
4352 )?;
4353 let mut entities_by_manifest = connection.prepare_cached(
4354 "DELETE FROM graph_entities
4355 WHERE manifest_path = ?1 OR (manifest_path >= ?2 AND manifest_path < ?3)",
4356 )?;
4357 for path in affected_paths {
4358 let path = path.as_str();
4359 let (descendant_start, descendant_end) = repository_descendant_bounds(path);
4360 let mut rows =
4361 relation_occurrences.query(params![path, descendant_start, descendant_end])?;
4362 while let Some(row) = rows.next()? {
4363 affected_relations.insert(fixed_bytes::<32>(
4364 "graph_relation_occurrences.relation_key",
4365 row.get::<_, Vec<u8>>(0)?,
4366 )?);
4367 }
4368 occurrences.execute(params![path, descendant_start, descendant_end])?;
4369 coverage.execute(params![path, descendant_start, descendant_end])?;
4370 entities_by_path.execute(params![path, descendant_start, descendant_end])?;
4371 entities_by_manifest.execute(params![path, descendant_start, descendant_end])?;
4372 }
4373 collect_external_relation_endpoints(connection, &affected_relations, orphan_candidates)?;
4374 let mut relation = connection.prepare_cached(
4375 "DELETE FROM graph_relations
4376 WHERE relation_key = ?1
4377 AND NOT EXISTS (
4378 SELECT 1 FROM graph_relation_occurrences
4379 WHERE relation_key = ?1
4380 )",
4381 )?;
4382 for relation_key in affected_relations {
4383 relation.execute([&relation_key[..]])?;
4384 }
4385 Ok(())
4386}
4387
4388fn collect_external_relation_endpoints(
4390 connection: &Connection,
4391 relation_keys: &HashSet<[u8; 32]>,
4392 candidates: &mut HashSet<[u8; 32]>,
4393) -> DbResult<()> {
4394 let mut endpoints = connection.prepare_cached(
4395 "SELECT source_entity_key, target_entity_key
4396 FROM graph_relations
4397 WHERE relation_key = ?1",
4398 )?;
4399 let mut is_external = connection.prepare_cached(
4400 "SELECT EXISTS(
4401 SELECT 1 FROM graph_entities
4402 WHERE entity_key = ?1 AND entity_kind = 'external'
4403 )",
4404 )?;
4405 for relation_key in relation_keys {
4406 let endpoints = endpoints
4407 .query_row([&relation_key[..]], |row| {
4408 Ok((row.get::<_, Vec<u8>>(0)?, row.get::<_, Option<Vec<u8>>>(1)?))
4409 })
4410 .optional()?;
4411 let Some((source, target)) = endpoints else {
4412 continue;
4413 };
4414 for endpoint in [Some(source), target].into_iter().flatten() {
4415 let endpoint = fixed_bytes::<32>("graph_relations endpoint", endpoint)?;
4416 if is_external.query_row([&endpoint[..]], |row| row.get::<_, bool>(0))? {
4417 candidates.insert(endpoint);
4418 }
4419 }
4420 }
4421 Ok(())
4422}
4423
4424fn remove_orphan_external_candidates(
4426 connection: &Connection,
4427 candidates: &HashSet<[u8; 32]>,
4428) -> DbResult<()> {
4429 let mut statement = connection.prepare_cached(
4430 "DELETE FROM graph_entities
4431 WHERE entity_key = ?1 AND entity_kind = 'external'
4432 AND NOT EXISTS (
4433 SELECT 1 FROM graph_relations INDEXED BY idx_graph_relations_source_kind
4434 WHERE source_entity_key = ?1
4435 )
4436 AND NOT EXISTS (
4437 SELECT 1 FROM graph_relations INDEXED BY idx_graph_relations_target_kind
4438 WHERE target_entity_key = ?1
4439 )",
4440 )?;
4441 for candidate in candidates {
4442 statement.execute([&candidate[..]])?;
4443 }
4444 Ok(())
4445}
4446
4447fn repository_descendant_bounds(path: &str) -> (String, String) {
4449 (format!("{path}/"), format!("{path}0"))
4450}
4451
4452fn validate_graph_batch(
4454 project: ProjectInstanceId,
4455 generation: IndexGeneration,
4456 entities: &[GraphEntity],
4457 relations: &[LogicalRelation],
4458 occurrences: &[RelationOccurrence],
4459 coverage: &[CoverageRecord],
4460) -> DbResult<()> {
4461 if entities
4462 .iter()
4463 .any(|entity| entity.key().project() != project)
4464 || relations
4465 .iter()
4466 .any(|relation| relation.key().project() != project)
4467 || occurrences
4468 .iter()
4469 .any(|occurrence| occurrence.relation().project() != project)
4470 {
4471 return Err(DbError::GraphProjectIdentityMismatch {
4472 expected: project.to_string(),
4473 found: "record from another project".to_string(),
4474 });
4475 }
4476 if entities
4477 .iter()
4478 .any(|entity| entity.generation() != generation)
4479 || relations
4480 .iter()
4481 .any(|relation| relation.generation() != generation)
4482 || occurrences
4483 .iter()
4484 .any(|occurrence| occurrence.generation() != generation)
4485 || coverage
4486 .iter()
4487 .any(|record| record.generation() != generation)
4488 {
4489 return Err(
4490 projectatlas_core::graph::GraphContractError::GenerationMismatch {
4491 context: "repository graph publication batch",
4492 }
4493 .into(),
4494 );
4495 }
4496 Ok(())
4497}
4498
4499fn insert_graph_batch(
4501 connection: &Connection,
4502 project: ProjectInstanceId,
4503 entities: &[GraphEntity],
4504 relations: &[LogicalRelation],
4505 occurrences: &[RelationOccurrence],
4506 coverage: &[CoverageRecord],
4507) -> DbResult<()> {
4508 insert_entities(connection, project, entities)?;
4509 insert_relations(connection, project, relations)?;
4510 insert_occurrences(connection, occurrences)?;
4511 insert_coverage(connection, project, coverage)
4512}
4513
4514fn insert_entities<'entity>(
4516 connection: &Connection,
4517 project: ProjectInstanceId,
4518 entities: impl IntoIterator<Item = &'entity GraphEntity>,
4519) -> DbResult<()> {
4520 let mut insert = connection.prepare_cached(
4521 "INSERT INTO graph_entities(
4522 entity_key, project_instance_id, canonical_identity, entity_kind,
4523 repository_path, package_manager, package_name, manifest_path,
4524 symbol_name, symbol_kind, symbol_parent, symbol_signature,
4525 external_system, external_identity
4526 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)
4527 ON CONFLICT(entity_key) DO NOTHING",
4528 )?;
4529 let mut existing = connection.prepare_cached(
4530 "SELECT project_instance_id, canonical_identity
4531 FROM graph_entities WHERE entity_key = ?1",
4532 )?;
4533 for entity in entities {
4534 let columns = entity_columns(entity.selector());
4535 let key = entity.key().digest_bytes()?;
4536 insert.execute(params![
4537 &key[..],
4538 &project.as_bytes()[..],
4539 entity.key().canonical_identity(),
4540 columns.kind,
4541 columns.repository_path,
4542 columns.package_manager,
4543 columns.package_name,
4544 columns.manifest_path,
4545 columns.symbol_name,
4546 columns.symbol_kind,
4547 columns.symbol_parent,
4548 columns.symbol_signature,
4549 columns.external_system,
4550 columns.external_identity,
4551 ])?;
4552 let (stored_project, stored_canonical): (Vec<u8>, String) =
4553 existing.query_row([&key[..]], |row| Ok((row.get(0)?, row.get(1)?)))?;
4554 if fixed_bytes::<16>("graph_entities.project_instance_id", stored_project)?
4555 != project.as_bytes()
4556 || stored_canonical != entity.key().canonical_identity()
4557 {
4558 return Err(
4559 projectatlas_core::graph::GraphContractError::StableKeyCollision {
4560 digest: entity.key().digest().to_string(),
4561 }
4562 .into(),
4563 );
4564 }
4565 }
4566 Ok(())
4567}
4568
4569fn insert_relations(
4571 connection: &Connection,
4572 project: ProjectInstanceId,
4573 relations: &[LogicalRelation],
4574) -> DbResult<()> {
4575 let mut statement = connection.prepare_cached(
4576 "INSERT INTO graph_relations(
4577 relation_key, project_instance_id, canonical_identity, source_entity_key,
4578 relation_scope, relation_kind, resolution_status, target_entity_key,
4579 reference_text, candidate_count, confidence, completeness
4580 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
4581 ON CONFLICT(relation_key) DO UPDATE SET
4582 confidence = excluded.confidence,
4583 completeness = excluded.completeness
4584 WHERE graph_relations.project_instance_id = excluded.project_instance_id
4585 AND graph_relations.canonical_identity = excluded.canonical_identity
4586 AND graph_relations.source_entity_key = excluded.source_entity_key
4587 AND graph_relations.relation_scope = excluded.relation_scope
4588 AND graph_relations.relation_kind = excluded.relation_kind
4589 AND graph_relations.resolution_status = excluded.resolution_status
4590 AND graph_relations.target_entity_key IS excluded.target_entity_key
4591 AND graph_relations.reference_text IS excluded.reference_text
4592 AND graph_relations.candidate_count IS excluded.candidate_count",
4593 )?;
4594 for relation in relations {
4595 let (scope, kind) = relation_parts(relation.kind());
4596 let resolution = resolution_columns(relation.resolution())?;
4597 let key = relation.key().digest_bytes()?;
4598 let source = relation.source().digest_bytes()?;
4599 let changed = statement.execute(params![
4600 &key[..],
4601 &project.as_bytes()[..],
4602 relation.key().canonical_identity(),
4603 &source[..],
4604 scope,
4605 kind,
4606 resolution.status,
4607 resolution.target.as_ref().map(|target| &target[..]),
4608 resolution.reference,
4609 resolution.candidate_count,
4610 confidence_name(relation.confidence()),
4611 completeness_name(relation.completeness()),
4612 ])?;
4613 if changed == 0 {
4614 return Err(
4615 projectatlas_core::graph::GraphContractError::StableKeyCollision {
4616 digest: relation.key().digest().to_string(),
4617 }
4618 .into(),
4619 );
4620 }
4621 }
4622 Ok(())
4623}
4624
4625fn insert_occurrences(connection: &Connection, occurrences: &[RelationOccurrence]) -> DbResult<()> {
4627 let mut statement = connection.prepare_cached(
4628 "INSERT INTO graph_relation_occurrences(
4629 relation_key, file_path, start_line, start_column, end_line, end_column
4630 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)
4631 ON CONFLICT(relation_key, file_path, start_line, start_column, end_line, end_column)
4632 DO NOTHING",
4633 )?;
4634 for occurrence in occurrences {
4635 let key = occurrence.relation().digest_bytes()?;
4636 let span = occurrence.span();
4637 statement.execute(params![
4638 &key[..],
4639 occurrence.file().as_str(),
4640 i64::from(span.start_line()),
4641 i64::from(span.start_column()),
4642 i64::from(span.end_line()),
4643 i64::from(span.end_column()),
4644 ])?;
4645 }
4646 Ok(())
4647}
4648
4649fn insert_coverage(
4651 connection: &Connection,
4652 project: ProjectInstanceId,
4653 coverage: &[CoverageRecord],
4654) -> DbResult<()> {
4655 let mut remove = connection.prepare_cached(
4656 "DELETE FROM graph_coverage
4657 WHERE project_instance_id = ?1 AND scope_kind = ?2 AND scope_path IS ?3
4658 AND relation_scope IS ?4 AND relation_kind IS ?5",
4659 )?;
4660 let mut insert = connection.prepare_cached(
4661 "INSERT INTO graph_coverage(
4662 project_instance_id, scope_kind, scope_path, relation_scope, relation_kind,
4663 state, total, covered, omitted, reason, reached_limit
4664 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
4665 )?;
4666 for record in coverage {
4667 let (scope_kind, scope_path) = coverage_scope_parts(record.scope());
4668 let (relation_scope, relation_kind) = record
4669 .relation()
4670 .map(relation_parts)
4671 .map_or((None, None), |(scope, kind)| (Some(scope), Some(kind)));
4672 let values = params![
4673 &project.as_bytes()[..],
4674 scope_kind,
4675 scope_path,
4676 relation_scope,
4677 relation_kind,
4678 ];
4679 remove.execute(values)?;
4680 insert.execute(params![
4681 &project.as_bytes()[..],
4682 scope_kind,
4683 scope_path,
4684 relation_scope,
4685 relation_kind,
4686 coverage_state_name(record.state()),
4687 sqlite_count("graph_coverage.total", record.total())?,
4688 sqlite_count("graph_coverage.covered", record.covered())?,
4689 sqlite_count("graph_coverage.omitted", record.omitted())?,
4690 record.reason().map(GraphIdentityText::as_str),
4691 record.reached_limit().map(limit_kind_name),
4692 ])?;
4693 }
4694 Ok(())
4695}
4696
4697struct EntityColumns<'selector> {
4699 kind: &'static str,
4701 repository_path: Option<&'selector str>,
4703 package_manager: Option<&'selector str>,
4705 package_name: Option<&'selector str>,
4707 manifest_path: Option<&'selector str>,
4709 symbol_name: Option<&'selector str>,
4711 symbol_kind: Option<&'static str>,
4713 symbol_parent: Option<&'selector str>,
4715 symbol_signature: Option<&'selector str>,
4717 external_system: Option<&'selector str>,
4719 external_identity: Option<&'selector str>,
4721}
4722
4723fn entity_columns(selector: &EntitySelector) -> EntityColumns<'_> {
4725 match selector {
4726 EntitySelector::Project => EntityColumns {
4727 kind: "project",
4728 repository_path: None,
4729 package_manager: None,
4730 package_name: None,
4731 manifest_path: None,
4732 symbol_name: None,
4733 symbol_kind: None,
4734 symbol_parent: None,
4735 symbol_signature: None,
4736 external_system: None,
4737 external_identity: None,
4738 },
4739 EntitySelector::Folder { path } => EntityColumns {
4740 kind: "folder",
4741 repository_path: Some(path.as_str()),
4742 package_manager: None,
4743 package_name: None,
4744 manifest_path: None,
4745 symbol_name: None,
4746 symbol_kind: None,
4747 symbol_parent: None,
4748 symbol_signature: None,
4749 external_system: None,
4750 external_identity: None,
4751 },
4752 EntitySelector::File { path } => EntityColumns {
4753 kind: "file",
4754 repository_path: Some(path.as_str()),
4755 package_manager: None,
4756 package_name: None,
4757 manifest_path: None,
4758 symbol_name: None,
4759 symbol_kind: None,
4760 symbol_parent: None,
4761 symbol_signature: None,
4762 external_system: None,
4763 external_identity: None,
4764 },
4765 EntitySelector::Package { package } => EntityColumns {
4766 kind: "package",
4767 repository_path: None,
4768 package_manager: Some(package.manager.as_str()),
4769 package_name: Some(package.name.as_str()),
4770 manifest_path: Some(package.manifest.as_str()),
4771 symbol_name: None,
4772 symbol_kind: None,
4773 symbol_parent: None,
4774 symbol_signature: None,
4775 external_system: None,
4776 external_identity: None,
4777 },
4778 EntitySelector::Symbol { symbol } => EntityColumns {
4779 kind: "symbol",
4780 repository_path: Some(symbol.file.as_str()),
4781 package_manager: None,
4782 package_name: None,
4783 manifest_path: None,
4784 symbol_name: Some(symbol.name.as_str()),
4785 symbol_kind: Some(symbol_kind_name(symbol.kind)),
4786 symbol_parent: symbol.parent.as_ref().map(GraphIdentityText::as_str),
4787 symbol_signature: Some(symbol.signature.as_str()),
4788 external_system: None,
4789 external_identity: None,
4790 },
4791 EntitySelector::External { external } => EntityColumns {
4792 kind: "external",
4793 repository_path: None,
4794 package_manager: None,
4795 package_name: None,
4796 manifest_path: None,
4797 symbol_name: None,
4798 symbol_kind: None,
4799 symbol_parent: None,
4800 symbol_signature: None,
4801 external_system: Some(external.system.as_str()),
4802 external_identity: Some(external.identity.as_str()),
4803 },
4804 }
4805}
4806
4807struct ResolutionColumns<'resolution> {
4809 status: &'static str,
4811 target: Option<[u8; 32]>,
4813 reference: Option<&'resolution str>,
4815 candidate_count: Option<i64>,
4817}
4818
4819fn resolution_columns(resolution: &RelationResolution) -> DbResult<ResolutionColumns<'_>> {
4821 match resolution {
4822 RelationResolution::Resolved { target, .. } => Ok(ResolutionColumns {
4823 status: "resolved",
4824 target: Some(target.digest_bytes()?),
4825 reference: None,
4826 candidate_count: None,
4827 }),
4828 RelationResolution::Ambiguous {
4829 reference,
4830 candidates,
4831 } => Ok(ResolutionColumns {
4832 status: "ambiguous",
4833 target: None,
4834 reference: Some(reference.as_str()),
4835 candidate_count: Some(i64::from(candidates.get())),
4836 }),
4837 RelationResolution::Unresolved { reference } => Ok(ResolutionColumns {
4838 status: "unresolved",
4839 target: None,
4840 reference: Some(reference.as_str()),
4841 candidate_count: None,
4842 }),
4843 RelationResolution::External { target, .. } => Ok(ResolutionColumns {
4844 status: "external",
4845 target: Some(target.digest_bytes()?),
4846 reference: None,
4847 candidate_count: None,
4848 }),
4849 }
4850}
4851
4852fn entity_from_row(
4854 row: EntityRow,
4855 expected_project: ProjectInstanceId,
4856 generation: IndexGeneration,
4857) -> DbResult<GraphEntity> {
4858 let project = project_from_blob("graph_entities.project_instance_id", row.project.clone())?;
4859 require_project(expected_project, project)?;
4860 validate_entity_row_shape(&row)?;
4861 let selector = match row.kind.as_str() {
4862 "project" => EntitySelector::Project,
4863 "folder" => EntitySelector::Folder {
4864 path: RepositoryNodePath::new(Path::new(required_text(
4865 "graph_entities",
4866 "folder path is missing",
4867 row.repository_path.as_deref(),
4868 )?))?,
4869 },
4870 "file" => EntitySelector::File {
4871 path: RepositoryFilePath::new(Path::new(required_text(
4872 "graph_entities",
4873 "file path is missing",
4874 row.repository_path.as_deref(),
4875 )?))?,
4876 },
4877 "package" => EntitySelector::Package {
4878 package: PackageSelector {
4879 manager: GraphIdentityText::new(required_text(
4880 "graph_entities",
4881 "package manager is missing",
4882 row.package_manager.as_deref(),
4883 )?)?,
4884 name: GraphIdentityText::new(required_text(
4885 "graph_entities",
4886 "package name is missing",
4887 row.package_name.as_deref(),
4888 )?)?,
4889 manifest: RepositoryFilePath::new(Path::new(required_text(
4890 "graph_entities",
4891 "package manifest is missing",
4892 row.manifest_path.as_deref(),
4893 )?))?,
4894 },
4895 },
4896 "symbol" => EntitySelector::Symbol {
4897 symbol: SymbolSelector {
4898 file: RepositoryFilePath::new(Path::new(required_text(
4899 "graph_entities",
4900 "symbol file is missing",
4901 row.repository_path.as_deref(),
4902 )?))?,
4903 name: GraphIdentityText::new(required_text(
4904 "graph_entities",
4905 "symbol name is missing",
4906 row.symbol_name.as_deref(),
4907 )?)?,
4908 kind: parse_symbol_kind(required_text(
4909 "graph_entities",
4910 "symbol kind is missing",
4911 row.symbol_kind.as_deref(),
4912 )?)?,
4913 parent: row.symbol_parent.map(GraphIdentityText::new).transpose()?,
4914 signature: GraphIdentityText::new(required_text(
4915 "graph_entities",
4916 "symbol signature is missing",
4917 row.symbol_signature.as_deref(),
4918 )?)?,
4919 },
4920 },
4921 "external" => EntitySelector::External {
4922 external: ExternalSelector {
4923 system: GraphIdentityText::new(required_text(
4924 "graph_entities",
4925 "external system is missing",
4926 row.external_system.as_deref(),
4927 )?)?,
4928 identity: GraphIdentityText::new(required_text(
4929 "graph_entities",
4930 "external identity is missing",
4931 row.external_identity.as_deref(),
4932 )?)?,
4933 },
4934 },
4935 value => {
4936 return Err(DbError::InvalidEnum {
4937 field: "graph_entities.entity_kind",
4938 value: value.to_string(),
4939 });
4940 }
4941 };
4942 let entity = GraphEntity::new(project, selector, generation)?;
4943 validate_entity_key(&entity, row.key, &row.canonical)?;
4944 Ok(entity)
4945}
4946
4947fn validate_entity_row_shape(row: &EntityRow) -> DbResult<()> {
4949 let repository = row.repository_path.is_some();
4950 let package = (
4951 row.package_manager.is_some(),
4952 row.package_name.is_some(),
4953 row.manifest_path.is_some(),
4954 );
4955 let symbol = (
4956 row.symbol_name.is_some(),
4957 row.symbol_kind.is_some(),
4958 row.symbol_parent.is_some(),
4959 row.symbol_signature.is_some(),
4960 );
4961 let external = (
4962 row.external_system.is_some(),
4963 row.external_identity.is_some(),
4964 );
4965 let valid = match row.kind.as_str() {
4966 "project" => {
4967 !repository
4968 && package == (false, false, false)
4969 && symbol == (false, false, false, false)
4970 && external == (false, false)
4971 }
4972 "folder" | "file" => {
4973 repository
4974 && package == (false, false, false)
4975 && symbol == (false, false, false, false)
4976 && external == (false, false)
4977 }
4978 "package" => {
4979 !repository
4980 && package == (true, true, true)
4981 && symbol == (false, false, false, false)
4982 && external == (false, false)
4983 }
4984 "symbol" => {
4985 repository
4986 && package == (false, false, false)
4987 && symbol.0
4988 && symbol.1
4989 && symbol.3
4990 && external == (false, false)
4991 }
4992 "external" => {
4993 !repository
4994 && package == (false, false, false)
4995 && symbol == (false, false, false, false)
4996 && external == (true, true)
4997 }
4998 _ => true,
4999 };
5000 if !valid {
5001 return Err(DbError::GraphRowShape {
5002 table: "graph_entities",
5003 reason: "selector columns contradict entity kind",
5004 });
5005 }
5006 Ok(())
5007}
5008
5009fn adjacency_relation_sql(
5011 frontier_count: usize,
5012 direction: RepositoryGraphDirection,
5013 continuation_index: Option<usize>,
5014 relation_filter: bool,
5015) -> String {
5016 let (key_column, index_name) = match direction {
5017 RepositoryGraphDirection::Outbound => {
5018 ("source_entity_key", "idx_graph_relations_source_kind")
5019 }
5020 RepositoryGraphDirection::Inbound => {
5021 ("target_entity_key", "idx_graph_relations_target_kind")
5022 }
5023 };
5024 let request = "request(project_instance_id) AS (VALUES (?))";
5025 let relation_filter = if relation_filter {
5026 "AND relation.relation_scope = ? AND relation.relation_kind = ?"
5027 } else {
5028 ""
5029 };
5030 let branches = (continuation_index.unwrap_or(0)..frontier_count)
5031 .map(|frontier_index| {
5032 let continuation = if continuation_index == Some(frontier_index) {
5033 "AND (relation.relation_scope, relation.relation_kind,
5034 relation.relation_key) > (?, ?, ?)"
5035 .to_string()
5036 } else {
5037 String::new()
5038 };
5039 format!(
5040 "SELECT * FROM (
5041 SELECT {frontier_index} AS frontier_index,
5042 relation.relation_key, relation.project_instance_id,
5043 relation.canonical_identity, relation.source_entity_key,
5044 relation.relation_scope, relation.relation_kind,
5045 relation.resolution_status, relation.target_entity_key,
5046 relation.reference_text, relation.candidate_count,
5047 relation.confidence, relation.completeness
5048 FROM graph_relations AS relation INDEXED BY {index_name}
5049 CROSS JOIN request
5050 WHERE relation.{key_column} = ?
5051 AND relation.project_instance_id = request.project_instance_id
5052 {relation_filter}
5053 {continuation}
5054 ORDER BY relation.relation_scope, relation.relation_kind,
5055 relation.relation_key
5056 LIMIT ?
5057 )"
5058 )
5059 })
5060 .collect::<Vec<_>>()
5061 .join(" UNION ALL ");
5062 format!(
5063 "WITH {request}
5064 {branches}
5065 ORDER BY frontier_index, relation_scope, relation_kind,
5066 relation_key
5067 LIMIT ?"
5068 )
5069}
5070
5071fn graph_values_clause(rows: usize, columns: usize) -> String {
5073 let row = format!("({})", vec!["?"; columns].join(", "));
5074 vec![row; rows].join(", ")
5075}
5076
5077fn graph_entity_hydration_sql(entity_count: usize) -> String {
5079 format!(
5080 "WITH requested(entity_key) AS (VALUES {})
5081 SELECT entity.entity_key, entity.project_instance_id,
5082 entity.canonical_identity, entity.entity_kind,
5083 entity.repository_path, entity.package_manager,
5084 entity.package_name, entity.manifest_path,
5085 entity.symbol_name, entity.symbol_kind,
5086 entity.symbol_parent, entity.symbol_signature,
5087 entity.external_system, entity.external_identity
5088 FROM requested
5089 JOIN graph_entities AS entity
5090 ON entity.entity_key = requested.entity_key
5091 WHERE entity.project_instance_id = ?",
5092 graph_values_clause(entity_count, 1),
5093 )
5094}
5095
5096fn graph_relation_hydration_sql(relation_count: usize) -> String {
5098 format!(
5099 "WITH requested(relation_key) AS (VALUES {})
5100 SELECT relation.relation_key, relation.project_instance_id,
5101 relation.canonical_identity, relation.source_entity_key,
5102 relation.relation_scope, relation.relation_kind,
5103 relation.resolution_status, relation.target_entity_key,
5104 relation.reference_text, relation.candidate_count,
5105 relation.confidence, relation.completeness
5106 FROM requested
5107 JOIN graph_relations AS relation INDEXED BY idx_graph_relations_project_key
5108 ON relation.relation_key = requested.relation_key
5109 AND relation.project_instance_id = ?",
5110 graph_values_clause(relation_count, 1),
5111 )
5112}
5113
5114fn occurrence_pages_sql(relation_count: usize) -> String {
5116 let branches = (0..relation_count)
5117 .map(|index| {
5118 format!(
5119 "SELECT {index} AS relation_index, relation_key, file_path,
5120 start_line, start_column, end_line, end_column
5121 FROM (
5122 SELECT relation_key, file_path, start_line, start_column,
5123 end_line, end_column
5124 FROM graph_relation_occurrences
5125 WHERE relation_key = ?
5126 ORDER BY file_path, start_line, start_column,
5127 end_line, end_column
5128 LIMIT ?
5129 ) AS occurrence_page_{index}"
5130 )
5131 })
5132 .collect::<Vec<_>>()
5133 .join(" UNION ALL ");
5134 format!(
5135 "{branches}
5136 ORDER BY relation_index, file_path, start_line, start_column,
5137 end_line, end_column"
5138 )
5139}
5140
5141fn path_coverage_sql(path_count: usize) -> String {
5143 let path_bindings = vec!["?"; path_count].join(", ");
5144 format!(
5145 "SELECT project_instance_id, scope_kind, scope_path, relation_scope,
5146 relation_kind, state, total, covered, omitted, reason, reached_limit,
5147 NULL, NULL
5148 FROM graph_coverage
5149 WHERE project_instance_id = ?
5150 AND scope_kind = ?
5151 AND scope_path IN ({path_bindings})
5152 ORDER BY scope_path, relation_scope, relation_kind, state, id
5153 LIMIT ?"
5154 )
5155}
5156
5157fn load_relation_entities(
5159 store: &AtlasStore,
5160 rows: &[RelationRow],
5161 project: ProjectInstanceId,
5162 generation: IndexGeneration,
5163 control: Option<&IndexWorkControl>,
5164) -> DbResult<HashMap<[u8; 32], GraphEntity>> {
5165 let references = rows.iter().collect::<Vec<_>>();
5166 load_relation_entity_references(store, &references, project, generation, control)
5167}
5168
5169fn load_relation_entity_references(
5171 store: &AtlasStore,
5172 rows: &[&RelationRow],
5173 project: ProjectInstanceId,
5174 generation: IndexGeneration,
5175 control: Option<&IndexWorkControl>,
5176) -> DbResult<HashMap<[u8; 32], GraphEntity>> {
5177 load_relation_entity_references_metered(store, rows, project, generation, control, None)
5178}
5179
5180fn load_relation_entity_references_metered(
5182 store: &AtlasStore,
5183 rows: &[&RelationRow],
5184 project: ProjectInstanceId,
5185 generation: IndexGeneration,
5186 control: Option<&IndexWorkControl>,
5187 meter: Option<&mut RepositoryGraphReadMeter>,
5188) -> DbResult<HashMap<[u8; 32], GraphEntity>> {
5189 let mut digests = BTreeSet::new();
5190 for row in rows {
5191 let row_project =
5192 project_from_blob("graph_relations.project_instance_id", row.project.clone())?;
5193 require_project(project, row_project)?;
5194 digests.insert(fixed_bytes::<32>(
5195 "graph_relations.source_entity_key",
5196 row.source.clone(),
5197 )?);
5198 if let Some(target) = &row.target {
5199 digests.insert(fixed_bytes::<32>(
5200 "graph_relations.target_entity_key",
5201 target.clone(),
5202 )?);
5203 }
5204 }
5205 load_graph_entities_by_digest_metered(
5206 store,
5207 &digests.into_iter().collect::<Vec<_>>(),
5208 project,
5209 generation,
5210 control,
5211 meter,
5212 )
5213}
5214
5215fn load_graph_entities_by_digest_metered(
5217 store: &AtlasStore,
5218 digests: &[[u8; 32]],
5219 project: ProjectInstanceId,
5220 generation: IndexGeneration,
5221 control: Option<&IndexWorkControl>,
5222 mut meter: Option<&mut RepositoryGraphReadMeter>,
5223) -> DbResult<HashMap<[u8; 32], GraphEntity>> {
5224 let mut entities = HashMap::with_capacity(digests.len());
5225 for chunk in digests.chunks(GRAPH_ENTITY_HYDRATION_CHUNK) {
5226 if let Some(control) = control {
5227 control.check(IndexWorkStage::RepositoryTraversal)?;
5228 }
5229 let sql = graph_entity_hydration_sql(chunk.len());
5230 let mut bindings = chunk
5231 .iter()
5232 .map(|digest| Value::Blob(digest.to_vec()))
5233 .collect::<Vec<_>>();
5234 bindings.push(Value::Blob(project.as_bytes().to_vec()));
5235 let raw = with_sqlite_read_progress(
5236 &store.connection,
5237 control,
5238 IndexWorkStage::RepositoryTraversal,
5239 || {
5240 let mut statement = store.connection.prepare(&sql)?;
5241 let rows = statement.query(params_from_iter(bindings.iter()))?;
5242 if let Some(meter) = meter.as_deref_mut() {
5243 collect_entity_rows_metered(rows, meter)
5244 } else {
5245 collect_entity_rows(rows)
5246 }
5247 },
5248 )?;
5249 for row in raw {
5250 let entity = entity_from_row(row, project, generation)?;
5251 let digest = entity.key().digest_bytes()?;
5252 if entities.contains_key(&digest) {
5253 return Err(DbError::GraphRowShape {
5254 table: "graph_entities",
5255 reason: "batched entity hydration returned a duplicate key",
5256 });
5257 }
5258 if let Some(meter) = meter.as_deref_mut() {
5259 meter.record_entity(&entity)?;
5260 }
5261 entities.insert(digest, entity);
5262 }
5263 }
5264 Ok(entities)
5265}
5266
5267fn maximum_repository_graph_read_budget() -> DbResult<RepositoryGraphReadBudget> {
5269 Ok(RepositoryGraphReadBudget::new(
5270 RepositoryGraphReadBudget::MAX_REQUESTED_ROWS,
5271 RepositoryGraphReadBudget::MAX_RETURNED_ROWS,
5272 RepositoryGraphReadBudget::MAX_DECODED_BYTES,
5273 RepositoryGraphReadBudget::MAX_HYDRATED_ENTITIES,
5274 RepositoryGraphReadBudget::MAX_HYDRATED_PATHS,
5275 )?)
5276}
5277
5278fn validate_path_coverage_request(paths: &[RepositoryNodePath]) -> DbResult<()> {
5280 if paths.len() > MAX_REPOSITORY_GRAPH_FRONTIER {
5281 return Err(GraphContractError::InvalidLimits {
5282 reason: "graph coverage path set exceeds the product ceiling",
5283 }
5284 .into());
5285 }
5286 if paths
5287 .iter()
5288 .map(RepositoryNodePath::as_str)
5289 .collect::<BTreeSet<_>>()
5290 .len()
5291 != paths.len()
5292 {
5293 return Err(GraphContractError::InvalidLimits {
5294 reason: "graph coverage paths must be unique",
5295 }
5296 .into());
5297 }
5298 Ok(())
5299}
5300
5301fn validate_graph_hydration_request(digests: &[[u8; 32]]) -> DbResult<()> {
5303 if digests.len() > MAX_REPOSITORY_GRAPH_FRONTIER {
5304 return Err(GraphContractError::InvalidLimits {
5305 reason: "graph hydration key set exceeds the product ceiling",
5306 }
5307 .into());
5308 }
5309 if digests.iter().copied().collect::<HashSet<_>>().len() != digests.len() {
5310 return Err(GraphContractError::InvalidLimits {
5311 reason: "graph hydration keys must be unique",
5312 }
5313 .into());
5314 }
5315 Ok(())
5316}
5317
5318fn graph_entity_purpose_owner(entity: &GraphEntity) -> Option<&str> {
5320 match entity.selector() {
5321 EntitySelector::Project => Some("."),
5322 EntitySelector::Folder { path } => Some(path.as_str()),
5323 EntitySelector::File { path } => Some(path.as_str()),
5324 EntitySelector::Package { package } => Some(package.manifest.as_str()),
5325 EntitySelector::Symbol { symbol } => Some(symbol.file.as_str()),
5326 EntitySelector::External { .. } => None,
5327 }
5328}
5329
5330fn relation_detail_from_row(
5332 entities: &HashMap<[u8; 32], GraphEntity>,
5333 row: RelationRow,
5334 expected_project: ProjectInstanceId,
5335 generation: IndexGeneration,
5336) -> DbResult<RepositoryGraphRelationRow> {
5337 let source_key = fixed_bytes::<32>("graph_relations.source_entity_key", row.source.clone())?;
5338 let target_key = row
5339 .target
5340 .as_ref()
5341 .map(|target| fixed_bytes::<32>("graph_relations.target_entity_key", target.clone()))
5342 .transpose()?;
5343 let relation = relation_from_row(entities, row, expected_project, generation)?;
5344 let source = entities
5345 .get(&source_key)
5346 .cloned()
5347 .ok_or(DbError::GraphRowShape {
5348 table: "graph_relations",
5349 reason: "source entity is missing",
5350 })?;
5351 let target = target_key
5352 .map(|key| {
5353 entities.get(&key).cloned().ok_or(DbError::GraphRowShape {
5354 table: "graph_relations",
5355 reason: "retained target entity is missing",
5356 })
5357 })
5358 .transpose()?;
5359 Ok(RepositoryGraphRelationRow {
5360 relation,
5361 source,
5362 target,
5363 })
5364}
5365
5366fn relation_from_row(
5368 entities: &HashMap<[u8; 32], GraphEntity>,
5369 row: RelationRow,
5370 expected_project: ProjectInstanceId,
5371 generation: IndexGeneration,
5372) -> DbResult<LogicalRelation> {
5373 let project = project_from_blob("graph_relations.project_instance_id", row.project.clone())?;
5374 require_project(expected_project, project)?;
5375 let source_key = fixed_bytes::<32>("graph_relations.source_entity_key", row.source.clone())?;
5376 let source = entities
5377 .get(&source_key)
5378 .cloned()
5379 .ok_or(DbError::GraphRowShape {
5380 table: "graph_relations",
5381 reason: "source entity is missing",
5382 })?;
5383 let kind = parse_relation_kind(&row.relation_scope, &row.relation_kind)?;
5384 let resolution = match row.resolution_status.as_str() {
5385 "resolved" => {
5386 require_relation_resolution_shape(&row, true, false, false)?;
5387 let target_key = fixed_bytes::<32>(
5388 "graph_relations.target_entity_key",
5389 row.target.clone().ok_or(DbError::GraphRowShape {
5390 table: "graph_relations",
5391 reason: "resolved target is missing",
5392 })?,
5393 )?;
5394 let target = entities
5395 .get(&target_key)
5396 .cloned()
5397 .ok_or(DbError::GraphRowShape {
5398 table: "graph_relations",
5399 reason: "resolved target entity is missing",
5400 })?;
5401 RelationResolution::resolved(&target)?
5402 }
5403 "external" => {
5404 require_relation_resolution_shape(&row, true, false, false)?;
5405 let target_key = fixed_bytes::<32>(
5406 "graph_relations.target_entity_key",
5407 row.target.clone().ok_or(DbError::GraphRowShape {
5408 table: "graph_relations",
5409 reason: "external target is missing",
5410 })?,
5411 )?;
5412 let target = entities
5413 .get(&target_key)
5414 .cloned()
5415 .ok_or(DbError::GraphRowShape {
5416 table: "graph_relations",
5417 reason: "external target entity is missing",
5418 })?;
5419 RelationResolution::external(&target)?
5420 }
5421 "ambiguous" => {
5422 require_relation_resolution_shape(&row, false, true, true)?;
5423 let candidates = positive_u32(
5424 "graph_relations.candidate_count",
5425 row.candidate_count.ok_or(DbError::GraphRowShape {
5426 table: "graph_relations",
5427 reason: "ambiguous candidate count is missing",
5428 })?,
5429 )?;
5430 RelationResolution::Ambiguous {
5431 reference: GraphIdentityText::new(row.reference.clone().ok_or(
5432 DbError::GraphRowShape {
5433 table: "graph_relations",
5434 reason: "ambiguous reference is missing",
5435 },
5436 )?)?,
5437 candidates,
5438 }
5439 }
5440 "unresolved" => {
5441 require_relation_resolution_shape(&row, false, true, false)?;
5442 RelationResolution::Unresolved {
5443 reference: GraphIdentityText::new(row.reference.clone().ok_or(
5444 DbError::GraphRowShape {
5445 table: "graph_relations",
5446 reason: "unresolved reference is missing",
5447 },
5448 )?)?,
5449 }
5450 }
5451 value => {
5452 return Err(DbError::InvalidEnum {
5453 field: "graph_relations.resolution_status",
5454 value: value.to_string(),
5455 });
5456 }
5457 };
5458 let relation = LogicalRelation::new(
5459 &source,
5460 kind,
5461 resolution,
5462 parse_confidence(&row.confidence)?,
5463 parse_completeness(&row.completeness)?,
5464 generation,
5465 )?;
5466 validate_relation_key(&relation, row.key, &row.canonical)?;
5467 Ok(relation)
5468}
5469
5470fn require_relation_resolution_shape(
5472 row: &RelationRow,
5473 target_required: bool,
5474 reference_required: bool,
5475 candidates_required: bool,
5476) -> DbResult<()> {
5477 let valid = row.target.is_some() == target_required
5478 && row.reference.is_some() == reference_required
5479 && row.candidate_count.is_some() == candidates_required;
5480 if !valid {
5481 return Err(DbError::GraphRowShape {
5482 table: "graph_relations",
5483 reason: "resolution columns contradict status",
5484 });
5485 }
5486 Ok(())
5487}
5488
5489fn occurrence_from_row(
5491 row: OccurrenceRow,
5492 relation: &LogicalRelation,
5493 generation: IndexGeneration,
5494) -> DbResult<RelationOccurrence> {
5495 let stored_key = fixed_bytes::<32>("graph_relation_occurrences.relation_key", row.relation)?;
5496 if stored_key != relation.key().digest_bytes()? {
5497 return Err(DbError::GraphRowShape {
5498 table: "graph_relation_occurrences",
5499 reason: "occurrence relation key does not match query",
5500 });
5501 }
5502 RelationOccurrence::new(
5503 relation,
5504 RepositoryFilePath::new(Path::new(&row.file_path))?,
5505 SourceSpan::new(
5506 positive_u32_value("graph_relation_occurrences.start_line", row.start_line)?,
5507 nonnegative_u32("graph_relation_occurrences.start_column", row.start_column)?,
5508 positive_u32_value("graph_relation_occurrences.end_line", row.end_line)?,
5509 nonnegative_u32("graph_relation_occurrences.end_column", row.end_column)?,
5510 )?,
5511 generation,
5512 )
5513 .map_err(Into::into)
5514}
5515
5516fn coverage_from_row(
5518 row: CoverageRow,
5519 expected_project: ProjectInstanceId,
5520 generation: IndexGeneration,
5521) -> DbResult<CoverageRecord> {
5522 let project = project_from_blob("graph_coverage.project_instance_id", row.project)?;
5523 require_project(expected_project, project)?;
5524 let scope = match (row.scope_kind.as_str(), row.scope_path) {
5525 ("project", None) => CoverageScope::Project,
5526 ("path", Some(path)) => CoverageScope::Path {
5527 path: RepositoryNodePath::new(Path::new(&path))?,
5528 },
5529 ("project" | "path", _) => {
5530 return Err(DbError::GraphRowShape {
5531 table: "graph_coverage",
5532 reason: "scope columns contradict scope kind",
5533 });
5534 }
5535 (value, _) => {
5536 return Err(DbError::InvalidEnum {
5537 field: "graph_coverage.scope_kind",
5538 value: value.to_string(),
5539 });
5540 }
5541 };
5542 let relation = match (row.relation_scope, row.relation_kind) {
5543 (None, None) => None,
5544 (Some(scope), Some(kind)) => Some(parse_relation_kind(&scope, &kind)?),
5545 _ => {
5546 return Err(DbError::GraphRowShape {
5547 table: "graph_coverage",
5548 reason: "relation scope and kind must both be present or absent",
5549 });
5550 }
5551 };
5552 let persisted_total = nonnegative_u64("graph_coverage.total", row.total)?;
5553 let record = CoverageRecord::new(
5554 scope,
5555 relation,
5556 parse_coverage_state(&row.state)?,
5557 nonnegative_u64("graph_coverage.covered", row.covered)?,
5558 nonnegative_u64("graph_coverage.omitted", row.omitted)?,
5559 generation,
5560 row.reason.map(GraphIdentityText::new).transpose()?,
5561 row.reached_limit
5562 .as_deref()
5563 .map(parse_limit_kind)
5564 .transpose()?,
5565 )?;
5566 if record.total() != persisted_total {
5567 return Err(DbError::GraphRowShape {
5568 table: "graph_coverage",
5569 reason: "total does not equal covered plus omitted",
5570 });
5571 }
5572 Ok(record)
5573}
5574
5575fn coverage_discovery_from_row(
5577 row: CoverageRow,
5578 expected_project: ProjectInstanceId,
5579 generation: IndexGeneration,
5580) -> DbResult<RepositoryCoverageRow> {
5581 let parser = row
5582 .parser
5583 .as_deref()
5584 .map(|value| parse_parser_kind("source_parse_metadata.source_parser", value))
5585 .transpose()?;
5586 let provider = row
5587 .provider
5588 .as_deref()
5589 .map(|value| parse_parser_kind("source_parse_metadata.fact_parser", value))
5590 .transpose()?;
5591 let coverage = coverage_from_row(row, expected_project, generation)?;
5592 Ok(RepositoryCoverageRow {
5593 coverage,
5594 parser,
5595 provider,
5596 })
5597}
5598
5599fn require_project(expected: ProjectInstanceId, found: ProjectInstanceId) -> DbResult<()> {
5601 if expected != found {
5602 return Err(DbError::GraphProjectIdentityMismatch {
5603 expected: expected.to_string(),
5604 found: found.to_string(),
5605 });
5606 }
5607 Ok(())
5608}
5609
5610fn validate_entity_key(
5612 entity: &GraphEntity,
5613 stored_key: Vec<u8>,
5614 stored_canonical: &str,
5615) -> DbResult<()> {
5616 let stored_key = fixed_bytes::<32>("graph_entities.entity_key", stored_key)?;
5617 if stored_key != entity.key().digest_bytes()? {
5618 return Err(projectatlas_core::graph::GraphContractError::InvalidStableKeyDigest.into());
5619 }
5620 if stored_canonical != entity.key().canonical_identity() {
5621 return Err(
5622 projectatlas_core::graph::GraphContractError::StableKeyCollision {
5623 digest: entity.key().digest().to_string(),
5624 }
5625 .into(),
5626 );
5627 }
5628 Ok(())
5629}
5630
5631fn validate_relation_key(
5633 relation: &LogicalRelation,
5634 stored_key: Vec<u8>,
5635 stored_canonical: &str,
5636) -> DbResult<()> {
5637 let stored_key = fixed_bytes::<32>("graph_relations.relation_key", stored_key)?;
5638 if stored_key != relation.key().digest_bytes()? {
5639 return Err(projectatlas_core::graph::GraphContractError::InvalidStableKeyDigest.into());
5640 }
5641 if stored_canonical != relation.key().canonical_identity() {
5642 return Err(
5643 projectatlas_core::graph::GraphContractError::StableKeyCollision {
5644 digest: relation.key().digest().to_string(),
5645 }
5646 .into(),
5647 );
5648 }
5649 Ok(())
5650}
5651
5652fn collect_entity_rows(mut rows: rusqlite::Rows<'_>) -> DbResult<Vec<EntityRow>> {
5654 let mut collected = Vec::new();
5655 while let Some(row) = rows.next()? {
5656 collected.push(entity_row(row)?);
5657 }
5658 Ok(collected)
5659}
5660
5661fn collect_entity_rows_metered(
5663 mut rows: rusqlite::Rows<'_>,
5664 meter: &mut RepositoryGraphReadMeter,
5665) -> DbResult<Vec<EntityRow>> {
5666 let mut collected = Vec::new();
5667 while let Some(row) = rows.next()? {
5668 let raw = entity_row(row)?;
5669 meter.record_decoded_bytes(entity_row_decoded_bytes(&raw)?)?;
5670 collected.push(raw);
5671 }
5672 Ok(collected)
5673}
5674
5675fn entity_row(row: &Row<'_>) -> rusqlite::Result<EntityRow> {
5677 Ok(EntityRow {
5678 key: row.get(0)?,
5679 project: row.get(1)?,
5680 canonical: row.get(2)?,
5681 kind: row.get(3)?,
5682 repository_path: row.get(4)?,
5683 package_manager: row.get(5)?,
5684 package_name: row.get(6)?,
5685 manifest_path: row.get(7)?,
5686 symbol_name: row.get(8)?,
5687 symbol_kind: row.get(9)?,
5688 symbol_parent: row.get(10)?,
5689 symbol_signature: row.get(11)?,
5690 external_system: row.get(12)?,
5691 external_identity: row.get(13)?,
5692 })
5693}
5694
5695fn entity_row_decoded_bytes(row: &EntityRow) -> DbResult<u64> {
5697 decoded_payload_bytes(
5698 [
5699 row.key.len(),
5700 row.project.len(),
5701 row.canonical.len(),
5702 row.kind.len(),
5703 row.repository_path.as_ref().map_or(0, String::len),
5704 row.package_manager.as_ref().map_or(0, String::len),
5705 row.package_name.as_ref().map_or(0, String::len),
5706 row.manifest_path.as_ref().map_or(0, String::len),
5707 row.symbol_name.as_ref().map_or(0, String::len),
5708 row.symbol_kind.as_ref().map_or(0, String::len),
5709 row.symbol_parent.as_ref().map_or(0, String::len),
5710 row.symbol_signature.as_ref().map_or(0, String::len),
5711 row.external_system.as_ref().map_or(0, String::len),
5712 row.external_identity.as_ref().map_or(0, String::len),
5713 ],
5714 0,
5715 )
5716}
5717
5718fn collect_relation_rows(mut rows: rusqlite::Rows<'_>) -> DbResult<Vec<RelationRow>> {
5720 let mut collected = Vec::new();
5721 while let Some(row) = rows.next()? {
5722 collected.push(relation_row(row)?);
5723 }
5724 Ok(collected)
5725}
5726
5727fn collect_relation_rows_metered(
5729 mut rows: rusqlite::Rows<'_>,
5730 meter: &mut RepositoryGraphReadMeter,
5731) -> DbResult<Vec<RelationRow>> {
5732 let mut collected = Vec::new();
5733 while let Some(row) = rows.next()? {
5734 let raw = relation_row(row)?;
5735 meter.record_decoded_bytes(relation_row_decoded_bytes(&raw)?)?;
5736 collected.push(raw);
5737 }
5738 Ok(collected)
5739}
5740
5741fn collect_adjacency_relation_rows_metered(
5743 mut rows: rusqlite::Rows<'_>,
5744 meter: &mut RepositoryGraphReadMeter,
5745) -> DbResult<Vec<AdjacencyRelationRow>> {
5746 let mut collected = Vec::new();
5747 while let Some(row) = rows.next()? {
5748 let raw = AdjacencyRelationRow {
5749 frontier_index: row.get(0)?,
5750 relation: relation_row_at(row, 1)?,
5751 };
5752 meter.record_decoded_bytes(
5753 relation_row_decoded_bytes(&raw.relation)?
5754 .checked_add(8)
5755 .ok_or(GraphContractError::InvalidLimits {
5756 reason: "graph adjacency decoded row size overflowed",
5757 })?,
5758 )?;
5759 collected.push(raw);
5760 }
5761 Ok(collected)
5762}
5763
5764fn relation_row(row: &Row<'_>) -> rusqlite::Result<RelationRow> {
5766 relation_row_at(row, 0)
5767}
5768
5769fn relation_row_at(row: &Row<'_>, offset: usize) -> rusqlite::Result<RelationRow> {
5771 Ok(RelationRow {
5772 key: row.get(offset)?,
5773 project: row.get(offset + 1)?,
5774 canonical: row.get(offset + 2)?,
5775 source: row.get(offset + 3)?,
5776 relation_scope: row.get(offset + 4)?,
5777 relation_kind: row.get(offset + 5)?,
5778 resolution_status: row.get(offset + 6)?,
5779 target: row.get(offset + 7)?,
5780 reference: row.get(offset + 8)?,
5781 candidate_count: row.get(offset + 9)?,
5782 confidence: row.get(offset + 10)?,
5783 completeness: row.get(offset + 11)?,
5784 })
5785}
5786
5787fn relation_row_decoded_bytes(row: &RelationRow) -> DbResult<u64> {
5789 decoded_payload_bytes(
5790 [
5791 row.key.len(),
5792 row.project.len(),
5793 row.canonical.len(),
5794 row.source.len(),
5795 row.relation_scope.len(),
5796 row.relation_kind.len(),
5797 row.resolution_status.len(),
5798 row.target.as_ref().map_or(0, Vec::len),
5799 row.reference.as_ref().map_or(0, String::len),
5800 row.confidence.len(),
5801 row.completeness.len(),
5802 ],
5803 8,
5804 )
5805}
5806
5807fn decoded_payload_bytes(
5809 lengths: impl IntoIterator<Item = usize>,
5810 fixed_bytes: u64,
5811) -> DbResult<u64> {
5812 let mut decoded = fixed_bytes;
5813 for length in lengths {
5814 let length =
5815 u64::try_from(length).map_err(|_source| GraphContractError::InvalidLimits {
5816 reason: "graph read decoded field length overflowed",
5817 })?;
5818 decoded = decoded
5819 .checked_add(length)
5820 .ok_or(GraphContractError::InvalidLimits {
5821 reason: "graph read decoded row size overflowed",
5822 })?;
5823 }
5824 Ok(decoded)
5825}
5826
5827fn occurrence_row(row: &Row<'_>) -> rusqlite::Result<OccurrenceRow> {
5829 occurrence_row_at(row, 0)
5830}
5831
5832fn occurrence_row_at(row: &Row<'_>, offset: usize) -> rusqlite::Result<OccurrenceRow> {
5834 Ok(OccurrenceRow {
5835 relation: row.get(offset)?,
5836 file_path: row.get(offset + 1)?,
5837 start_line: row.get(offset + 2)?,
5838 start_column: row.get(offset + 3)?,
5839 end_line: row.get(offset + 4)?,
5840 end_column: row.get(offset + 5)?,
5841 })
5842}
5843
5844fn occurrence_row_decoded_bytes(row: &OccurrenceRow) -> DbResult<u64> {
5846 decoded_payload_bytes([row.relation.len(), row.file_path.len()], 32)
5847}
5848
5849fn coverage_row(row: &Row<'_>) -> rusqlite::Result<CoverageRow> {
5851 Ok(CoverageRow {
5852 project: row.get(0)?,
5853 scope_kind: row.get(1)?,
5854 scope_path: row.get(2)?,
5855 relation_scope: row.get(3)?,
5856 relation_kind: row.get(4)?,
5857 state: row.get(5)?,
5858 total: row.get(6)?,
5859 covered: row.get(7)?,
5860 omitted: row.get(8)?,
5861 reason: row.get(9)?,
5862 reached_limit: row.get(10)?,
5863 parser: row.get(11)?,
5864 provider: row.get(12)?,
5865 })
5866}
5867
5868fn coverage_row_decoded_bytes(row: &CoverageRow) -> DbResult<u64> {
5870 decoded_payload_bytes(
5871 [
5872 row.project.len(),
5873 row.scope_kind.len(),
5874 row.scope_path.as_ref().map_or(0, String::len),
5875 row.relation_scope.as_ref().map_or(0, String::len),
5876 row.relation_kind.as_ref().map_or(0, String::len),
5877 row.state.len(),
5878 row.reason.as_ref().map_or(0, String::len),
5879 row.reached_limit.as_ref().map_or(0, String::len),
5880 row.parser.as_ref().map_or(0, String::len),
5881 row.provider.as_ref().map_or(0, String::len),
5882 ],
5883 24,
5884 )
5885}
5886
5887fn page_from_raw<Raw, Domain>(
5889 raw: Vec<Raw>,
5890 limit: u32,
5891 mut convert: impl FnMut(Raw) -> DbResult<Domain>,
5892) -> DbResult<RepositoryGraphPage<Domain>> {
5893 let mut rows = raw
5894 .into_iter()
5895 .map(&mut convert)
5896 .collect::<DbResult<Vec<_>>>()?;
5897 let truncated = rows.len() > limit as usize;
5898 if truncated {
5899 rows.pop();
5900 }
5901 Ok(RepositoryGraphPage { rows, truncated })
5902}
5903
5904fn empty_page<T>() -> RepositoryGraphPage<T> {
5906 RepositoryGraphPage {
5907 rows: Vec::new(),
5908 truncated: false,
5909 }
5910}
5911
5912fn empty_adjacency_page() -> RepositoryGraphAdjacencyPage {
5914 RepositoryGraphAdjacencyPage {
5915 rows: Vec::new(),
5916 truncated: false,
5917 continuation: None,
5918 }
5919}
5920
5921const fn empty_affected_source_footprint() -> RepositoryAffectedSourceFootprint {
5923 RepositoryAffectedSourceFootprint {
5924 rows: 0,
5925 retained_bytes: 0,
5926 truncated: false,
5927 }
5928}
5929
5930fn validated_limit_plus_one(limit: u32, ceiling: u32, reason: &'static str) -> DbResult<i64> {
5932 if limit == 0 || limit > ceiling {
5933 return Err(projectatlas_core::graph::GraphContractError::InvalidLimits { reason }.into());
5934 }
5935 Ok(i64::from(limit) + 1)
5936}
5937
5938fn fixed_bytes<const WIDTH: usize>(field: &'static str, bytes: Vec<u8>) -> DbResult<[u8; WIDTH]> {
5940 let found = bytes.len();
5941 bytes
5942 .try_into()
5943 .map_err(|_bytes| DbError::InvalidBlobLength {
5944 field,
5945 expected: WIDTH,
5946 found,
5947 })
5948}
5949
5950fn project_from_blob(field: &'static str, bytes: Vec<u8>) -> DbResult<ProjectInstanceId> {
5952 ProjectInstanceId::from_bytes(fixed_bytes::<16>(field, bytes)?).map_err(Into::into)
5953}
5954
5955fn required_text<'value>(
5957 table: &'static str,
5958 reason: &'static str,
5959 value: Option<&'value str>,
5960) -> DbResult<&'value str> {
5961 value.ok_or(DbError::GraphRowShape { table, reason })
5962}
5963
5964fn nonnegative_u64(field: &'static str, value: i64) -> DbResult<u64> {
5966 u64::try_from(value).map_err(|source| DbError::InvalidCount {
5967 field,
5968 value,
5969 source,
5970 })
5971}
5972
5973fn positive_u32(field: &'static str, value: i64) -> DbResult<NonZeroU32> {
5975 let value = positive_u32_value(field, value)?;
5976 NonZeroU32::new(value).ok_or(DbError::GraphRowShape {
5977 table: "graph_relations",
5978 reason: "candidate count must be positive",
5979 })
5980}
5981
5982fn positive_u32_value(field: &'static str, value: i64) -> DbResult<u32> {
5984 let converted = u32::try_from(value).map_err(|source| DbError::InvalidCount {
5985 field,
5986 value,
5987 source,
5988 })?;
5989 if converted == 0 {
5990 return Err(DbError::GraphRowShape {
5991 table: "repository_graph",
5992 reason: "positive integer column contains zero",
5993 });
5994 }
5995 Ok(converted)
5996}
5997
5998fn nonnegative_u32(field: &'static str, value: i64) -> DbResult<u32> {
6000 u32::try_from(value).map_err(|source| DbError::InvalidCount {
6001 field,
6002 value,
6003 source,
6004 })
6005}
6006
6007fn sqlite_count(field: &'static str, value: u64) -> DbResult<i64> {
6009 i64::try_from(value).map_err(|_source| DbError::GraphCountOverflow { field, value })
6010}
6011
6012const fn relation_parts(relation: GraphRelationKind) -> (&'static str, &'static str) {
6014 match relation {
6015 GraphRelationKind::Legacy(RelationKind::Contains) => ("legacy", "contains"),
6016 GraphRelationKind::Legacy(RelationKind::Imports) => ("legacy", "imports"),
6017 GraphRelationKind::Legacy(RelationKind::Calls) => ("legacy", "calls"),
6018 GraphRelationKind::Legacy(RelationKind::DependsOn) => ("legacy", "depends-on"),
6019 GraphRelationKind::Extended(ExtendedRelationKind::References) => ("extended", "references"),
6020 GraphRelationKind::Extended(ExtendedRelationKind::Tests) => ("extended", "tests"),
6021 GraphRelationKind::Extended(ExtendedRelationKind::RoutesTo) => ("extended", "routes-to"),
6022 GraphRelationKind::Extended(ExtendedRelationKind::Configures) => ("extended", "configures"),
6023 GraphRelationKind::Extended(ExtendedRelationKind::Deploys) => ("extended", "deploys"),
6024 GraphRelationKind::Extended(ExtendedRelationKind::Reads) => ("extended", "reads"),
6025 GraphRelationKind::Extended(ExtendedRelationKind::Writes) => ("extended", "writes"),
6026 }
6027}
6028
6029fn parse_relation_kind(scope: &str, kind: &str) -> DbResult<GraphRelationKind> {
6031 match (scope, kind) {
6032 ("legacy", "contains") => Ok(GraphRelationKind::Legacy(RelationKind::Contains)),
6033 ("legacy", "imports") => Ok(GraphRelationKind::Legacy(RelationKind::Imports)),
6034 ("legacy", "calls") => Ok(GraphRelationKind::Legacy(RelationKind::Calls)),
6035 ("legacy", "depends-on") => Ok(GraphRelationKind::Legacy(RelationKind::DependsOn)),
6036 ("extended", "references") => Ok(GraphRelationKind::Extended(
6037 ExtendedRelationKind::References,
6038 )),
6039 ("extended", "tests") => Ok(GraphRelationKind::Extended(ExtendedRelationKind::Tests)),
6040 ("extended", "routes-to") => {
6041 Ok(GraphRelationKind::Extended(ExtendedRelationKind::RoutesTo))
6042 }
6043 ("extended", "configures") => Ok(GraphRelationKind::Extended(
6044 ExtendedRelationKind::Configures,
6045 )),
6046 ("extended", "deploys") => Ok(GraphRelationKind::Extended(ExtendedRelationKind::Deploys)),
6047 ("extended", "reads") => Ok(GraphRelationKind::Extended(ExtendedRelationKind::Reads)),
6048 ("extended", "writes") => Ok(GraphRelationKind::Extended(ExtendedRelationKind::Writes)),
6049 _ => Err(DbError::InvalidEnum {
6050 field: "graph_relations.relation_kind",
6051 value: format!("{scope}:{kind}"),
6052 }),
6053 }
6054}
6055
6056const fn symbol_kind_name(kind: SymbolKind) -> &'static str {
6058 match kind {
6059 SymbolKind::Function => "function",
6060 SymbolKind::Method => "method",
6061 SymbolKind::Class => "class",
6062 SymbolKind::Struct => "struct",
6063 SymbolKind::Enum => "enum",
6064 SymbolKind::Trait => "trait",
6065 SymbolKind::Interface => "interface",
6066 SymbolKind::Module => "module",
6067 SymbolKind::Type => "type",
6068 SymbolKind::Value => "value",
6069 SymbolKind::Import => "import",
6070 SymbolKind::Package => "package",
6071 SymbolKind::Workspace => "workspace",
6072 SymbolKind::Dependency => "dependency",
6073 SymbolKind::Unknown => "unknown",
6074 }
6075}
6076
6077fn parse_symbol_kind(value: &str) -> DbResult<SymbolKind> {
6079 match value {
6080 "function" => Ok(SymbolKind::Function),
6081 "method" => Ok(SymbolKind::Method),
6082 "class" => Ok(SymbolKind::Class),
6083 "struct" => Ok(SymbolKind::Struct),
6084 "enum" => Ok(SymbolKind::Enum),
6085 "trait" => Ok(SymbolKind::Trait),
6086 "interface" => Ok(SymbolKind::Interface),
6087 "module" => Ok(SymbolKind::Module),
6088 "type" => Ok(SymbolKind::Type),
6089 "value" => Ok(SymbolKind::Value),
6090 "import" => Ok(SymbolKind::Import),
6091 "package" => Ok(SymbolKind::Package),
6092 "workspace" => Ok(SymbolKind::Workspace),
6093 "dependency" => Ok(SymbolKind::Dependency),
6094 "unknown" => Ok(SymbolKind::Unknown),
6095 _ => Err(DbError::InvalidEnum {
6096 field: "graph_entities.symbol_kind",
6097 value: value.to_string(),
6098 }),
6099 }
6100}
6101
6102const fn confidence_name(confidence: ConfidenceClass) -> &'static str {
6104 match confidence {
6105 ConfidenceClass::Exact => "exact",
6106 ConfidenceClass::High => "high",
6107 ConfidenceClass::Medium => "medium",
6108 ConfidenceClass::Low => "low",
6109 }
6110}
6111
6112fn parse_confidence(value: &str) -> DbResult<ConfidenceClass> {
6114 match value {
6115 "exact" => Ok(ConfidenceClass::Exact),
6116 "high" => Ok(ConfidenceClass::High),
6117 "medium" => Ok(ConfidenceClass::Medium),
6118 "low" => Ok(ConfidenceClass::Low),
6119 _ => Err(DbError::InvalidEnum {
6120 field: "graph_relations.confidence",
6121 value: value.to_string(),
6122 }),
6123 }
6124}
6125
6126const fn completeness_name(completeness: Completeness) -> &'static str {
6128 match completeness {
6129 Completeness::Complete => "complete",
6130 Completeness::Partial => "partial",
6131 }
6132}
6133
6134fn parse_completeness(value: &str) -> DbResult<Completeness> {
6136 match value {
6137 "complete" => Ok(Completeness::Complete),
6138 "partial" => Ok(Completeness::Partial),
6139 _ => Err(DbError::InvalidEnum {
6140 field: "graph_relations.completeness",
6141 value: value.to_string(),
6142 }),
6143 }
6144}
6145
6146fn coverage_scope_parts(scope: &CoverageScope) -> (&'static str, Option<&str>) {
6148 match scope {
6149 CoverageScope::Project => ("project", None),
6150 CoverageScope::Path { path } => ("path", Some(path.as_str())),
6151 }
6152}
6153
6154const fn coverage_state_name(state: CoverageState) -> &'static str {
6156 match state {
6157 CoverageState::Complete => "complete",
6158 CoverageState::Partial => "partial",
6159 CoverageState::Failed => "failed",
6160 CoverageState::Ignored => "ignored",
6161 CoverageState::Oversized => "oversized",
6162 CoverageState::Quarantined => "quarantined",
6163 CoverageState::Stale => "stale",
6164 }
6165}
6166
6167pub(crate) fn parse_coverage_state(value: &str) -> DbResult<CoverageState> {
6169 match value {
6170 "complete" => Ok(CoverageState::Complete),
6171 "partial" => Ok(CoverageState::Partial),
6172 "failed" => Ok(CoverageState::Failed),
6173 "ignored" => Ok(CoverageState::Ignored),
6174 "oversized" => Ok(CoverageState::Oversized),
6175 "quarantined" => Ok(CoverageState::Quarantined),
6176 "stale" => Ok(CoverageState::Stale),
6177 _ => Err(DbError::InvalidEnum {
6178 field: "graph_coverage.state",
6179 value: value.to_string(),
6180 }),
6181 }
6182}
6183
6184fn parse_parser_kind(field: &'static str, value: &str) -> DbResult<ParserKind> {
6186 match value {
6187 "tree-sitter" => Ok(ParserKind::TreeSitter),
6188 "manifest" => Ok(ParserKind::Manifest),
6189 "structural" => Ok(ParserKind::Structural),
6190 "fallback" => Ok(ParserKind::Fallback),
6191 _ => Err(DbError::InvalidEnum {
6192 field,
6193 value: value.to_string(),
6194 }),
6195 }
6196}
6197
6198const fn limit_kind_name(limit: GraphLimitKind) -> &'static str {
6200 match limit {
6201 GraphLimitKind::Rows => "rows",
6202 GraphLimitKind::Nodes => "nodes",
6203 GraphLimitKind::Edges => "edges",
6204 GraphLimitKind::Occurrences => "occurrences",
6205 GraphLimitKind::Visited => "visited",
6206 GraphLimitKind::IntermediateBytes => "intermediate_bytes",
6207 GraphLimitKind::Deadline => "deadline",
6208 GraphLimitKind::Depth => "depth",
6209 GraphLimitKind::OutputBytes => "output_bytes",
6210 }
6211}
6212
6213pub(crate) fn parse_limit_kind(value: &str) -> DbResult<GraphLimitKind> {
6215 match value {
6216 "rows" => Ok(GraphLimitKind::Rows),
6217 "nodes" => Ok(GraphLimitKind::Nodes),
6218 "edges" => Ok(GraphLimitKind::Edges),
6219 "occurrences" => Ok(GraphLimitKind::Occurrences),
6220 "visited" => Ok(GraphLimitKind::Visited),
6221 "intermediate_bytes" => Ok(GraphLimitKind::IntermediateBytes),
6222 "deadline" => Ok(GraphLimitKind::Deadline),
6223 "depth" => Ok(GraphLimitKind::Depth),
6224 "output_bytes" => Ok(GraphLimitKind::OutputBytes),
6225 _ => Err(DbError::InvalidEnum {
6226 field: "graph_coverage.reached_limit",
6227 value: value.to_string(),
6228 }),
6229 }
6230}
6231
6232#[cfg(test)]
6233mod tests {
6234 use super::*;
6235 use crate::IndexedFileText;
6236 use projectatlas_core::symbols::{CodeSymbol, ParserKind, SymbolGraph, SymbolRelation};
6237 use projectatlas_core::{Node, NodeKind};
6238 use std::cell::RefCell;
6239 use std::error::Error;
6240 use std::fmt::Debug;
6241 use std::fs;
6242 use std::io;
6243 use std::time::{Duration, Instant};
6244
6245 thread_local! {
6246 static TRACED_STATEMENTS: RefCell<Vec<String>> = const { RefCell::new(Vec::new()) };
6248 }
6249
6250 fn record_traced_statement(sql: &str) {
6252 TRACED_STATEMENTS.with(|statements| statements.borrow_mut().push(sql.to_string()));
6253 }
6254
6255 fn trace_statements<T>(
6257 store: &mut AtlasStore,
6258 operation: impl FnOnce(&AtlasStore) -> DbResult<T>,
6259 ) -> Result<(T, Vec<String>), Box<dyn Error>> {
6260 TRACED_STATEMENTS.with(|statements| statements.borrow_mut().clear());
6261 store.connection.trace(Some(record_traced_statement));
6262 let result = operation(store);
6263 store.connection.trace(None);
6264 let statements =
6265 TRACED_STATEMENTS.with(|statements| std::mem::take(&mut *statements.borrow_mut()));
6266 Ok((result?, statements))
6267 }
6268
6269 #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
6271 enum DetailedRelationTraceStatement {
6272 ProjectIdentity,
6274 PublicationMetadata,
6276 ActiveGraphGeneration,
6278 AdjacencyRelations,
6280 RelationsByDigest,
6282 RelationEntities,
6284 RelationOccurrences,
6286 PurposeOwners,
6288 }
6289
6290 fn classify_detailed_relation_statement(
6292 statement: &str,
6293 ) -> Option<DetailedRelationTraceStatement> {
6294 if statement
6295 .contains("SELECT project_instance_id FROM project_identity WHERE singleton = 1")
6296 {
6297 Some(DetailedRelationTraceStatement::ProjectIdentity)
6298 } else if statement.contains("SELECT state.value, fingerprint.value, generation.value")
6299 && statement.contains("FROM metadata AS state")
6300 {
6301 Some(DetailedRelationTraceStatement::PublicationMetadata)
6302 } else if statement
6303 .contains("SELECT active_generation FROM project_identity WHERE singleton = 1")
6304 {
6305 Some(DetailedRelationTraceStatement::ActiveGraphGeneration)
6306 } else if statement.contains("FROM graph_relations AS relation INDEXED BY")
6307 && statement.contains("idx_graph_relations_source_kind")
6308 {
6309 Some(DetailedRelationTraceStatement::AdjacencyRelations)
6310 } else if statement
6311 .contains("JOIN graph_relations AS relation INDEXED BY idx_graph_relations_project_key")
6312 {
6313 Some(DetailedRelationTraceStatement::RelationsByDigest)
6314 } else if statement.contains("JOIN graph_entities AS entity") {
6315 Some(DetailedRelationTraceStatement::RelationEntities)
6316 } else if statement.contains("FROM graph_relation_occurrences") {
6317 Some(DetailedRelationTraceStatement::RelationOccurrences)
6318 } else if statement.contains("FROM nodes n")
6319 && statement.contains("JOIN purposes p")
6320 && statement.contains("LEFT JOIN summaries s")
6321 {
6322 Some(DetailedRelationTraceStatement::PurposeOwners)
6323 } else {
6324 None
6325 }
6326 }
6327
6328 fn require_traced_statement_multiset(
6330 statements: &[String],
6331 expected: &[(DetailedRelationTraceStatement, usize)],
6332 context: &str,
6333 ) -> Result<(), Box<dyn Error>> {
6334 let mut actual = BTreeMap::new();
6335 for statement in statements {
6336 let family = classify_detailed_relation_statement(statement).ok_or_else(|| {
6337 io::Error::other(format!(
6338 "{context} executed an unclassified production statement: {statement}"
6339 ))
6340 })?;
6341 *actual.entry(family).or_insert(0) += 1;
6342 }
6343 let expected = expected.iter().copied().collect::<BTreeMap<_, _>>();
6344 require_eq(
6345 &actual,
6346 &expected,
6347 &format!("{context} complete statement multiset: {statements:?}"),
6348 )
6349 }
6350
6351 fn collect_bounded_outbound_calls(
6353 store: &AtlasStore,
6354 anchor: &GraphEntityKey,
6355 ) -> Result<(Vec<[u8; 32]>, usize, usize), Box<dyn Error>> {
6356 let mut visited = BTreeMap::new();
6357 visited.insert(anchor.digest_bytes()?, anchor.clone());
6358 let mut frontier = vec![anchor.clone()];
6359 let mut inspected_edges = 0_usize;
6360 let mut peak_frontier = frontier.len();
6361 let calls = GraphRelationKind::Legacy(RelationKind::Calls);
6362
6363 while !frontier.is_empty() {
6364 let mut next = BTreeMap::new();
6365 for chunk in frontier.chunks(MAX_REPOSITORY_GRAPH_FRONTIER) {
6366 let work_per_row = MAX_REPOSITORY_GRAPH_ADJACENCY_WORK_ROWS / chunk.len();
6367 let page_limit = work_per_row
6368 .saturating_sub(1)
6369 .min(GraphLimits::MAX_ROWS as usize)
6370 .max(1) as u32;
6371 let mut continuation = None;
6372 loop {
6373 let page = store.repository_graph_adjacency_page_filtered(
6374 chunk,
6375 RepositoryGraphDirection::Outbound,
6376 Some(calls),
6377 continuation.as_ref(),
6378 page_limit,
6379 None,
6380 )?;
6381 inspected_edges = inspected_edges
6382 .checked_add(page.rows.len())
6383 .ok_or_else(|| io::Error::other("measured edge count overflowed"))?;
6384 for row in page.rows {
6385 if let Some(target) = row.detail.target {
6386 let digest = target.key().digest_bytes()?;
6387 if !visited.contains_key(&digest) {
6388 next.entry(digest).or_insert_with(|| target.key().clone());
6389 }
6390 }
6391 }
6392 if !page.truncated {
6393 break;
6394 }
6395 continuation = Some(page.continuation.ok_or_else(|| {
6396 io::Error::other("truncated adjacency page omitted its continuation")
6397 })?);
6398 }
6399 }
6400 frontier = next.into_values().collect();
6401 peak_frontier = peak_frontier.max(frontier.len());
6402 for key in &frontier {
6403 visited.insert(key.digest_bytes()?, key.clone());
6404 }
6405 }
6406
6407 Ok((
6408 visited.into_keys().collect(),
6409 inspected_edges,
6410 peak_frontier,
6411 ))
6412 }
6413
6414 struct GraphFixture {
6416 project: ProjectInstanceId,
6418 entities: Vec<GraphEntity>,
6420 relations: Vec<LogicalRelation>,
6422 occurrences: Vec<RelationOccurrence>,
6424 coverage: Vec<CoverageRecord>,
6426 }
6427
6428 struct DetailedRelationTraceFixture {
6430 project: ProjectInstanceId,
6432 source: GraphEntity,
6434 relations: Vec<LogicalRelation>,
6436 }
6437
6438 struct ResolutionFixture {
6440 graph: GraphFixture,
6442 resolved: CanonicalResolutionKey,
6444 ambiguous: CanonicalResolutionKey,
6446 unresolved: CanonicalResolutionKey,
6448 }
6449
6450 fn graph_fixture(
6452 project: ProjectInstanceId,
6453 generation: IndexGeneration,
6454 ) -> Result<GraphFixture, Box<dyn Error>> {
6455 let project_entity = GraphEntity::new(project, EntitySelector::Project, generation)?;
6456 let folder = GraphEntity::new(
6457 project,
6458 EntitySelector::Folder {
6459 path: RepositoryNodePath::new(Path::new("src"))?,
6460 },
6461 generation,
6462 )?;
6463 let file = GraphEntity::new(
6464 project,
6465 EntitySelector::File {
6466 path: RepositoryFilePath::new(Path::new("src/Äuth.rs"))?,
6467 },
6468 generation,
6469 )?;
6470 let package = GraphEntity::new(
6471 project,
6472 EntitySelector::Package {
6473 package: PackageSelector {
6474 manager: GraphIdentityText::new("cargo")?,
6475 name: GraphIdentityText::new("ProjectAtlas")?,
6476 manifest: RepositoryFilePath::new(Path::new("Cargo.toml"))?,
6477 },
6478 },
6479 generation,
6480 )?;
6481 let symbol = GraphEntity::new(
6482 project,
6483 EntitySelector::Symbol {
6484 symbol: SymbolSelector {
6485 file: RepositoryFilePath::new(Path::new("src/Äuth.rs"))?,
6486 name: GraphIdentityText::new("verifyToken")?,
6487 kind: SymbolKind::Function,
6488 parent: Some(GraphIdentityText::new("Auth")?),
6489 signature: GraphIdentityText::new("verifyToken(&str)")?,
6490 },
6491 },
6492 generation,
6493 )?;
6494 let external = GraphEntity::new(
6495 project,
6496 EntitySelector::External {
6497 external: ExternalSelector {
6498 system: GraphIdentityText::new("crates.io")?,
6499 identity: GraphIdentityText::new("serde@1")?,
6500 },
6501 },
6502 generation,
6503 )?;
6504
6505 let resolved = LogicalRelation::new(
6506 &file,
6507 GraphRelationKind::Legacy(RelationKind::Calls),
6508 RelationResolution::resolved(&symbol)?,
6509 ConfidenceClass::Exact,
6510 Completeness::Complete,
6511 generation,
6512 )?;
6513 let ambiguous = LogicalRelation::new(
6514 &file,
6515 GraphRelationKind::Extended(ExtendedRelationKind::References),
6516 RelationResolution::Ambiguous {
6517 reference: GraphIdentityText::new("Session")?,
6518 candidates: NonZeroU32::new(2)
6519 .ok_or_else(|| io::Error::other("fixture candidate count is zero"))?,
6520 },
6521 ConfidenceClass::Medium,
6522 Completeness::Partial,
6523 generation,
6524 )?;
6525 let unresolved = LogicalRelation::new(
6526 &file,
6527 GraphRelationKind::Extended(ExtendedRelationKind::Configures),
6528 RelationResolution::Unresolved {
6529 reference: GraphIdentityText::new("AUTH_KEY")?,
6530 },
6531 ConfidenceClass::Low,
6532 Completeness::Partial,
6533 generation,
6534 )?;
6535 let external_relation = LogicalRelation::new(
6536 &file,
6537 GraphRelationKind::Legacy(RelationKind::DependsOn),
6538 RelationResolution::external(&external)?,
6539 ConfidenceClass::High,
6540 Completeness::Complete,
6541 generation,
6542 )?;
6543 let occurrences = vec![
6544 RelationOccurrence::new(
6545 &resolved,
6546 RepositoryFilePath::new(Path::new("src/Äuth.rs"))?,
6547 SourceSpan::new(10, 4, 10, 18)?,
6548 generation,
6549 )?,
6550 RelationOccurrence::new(
6551 &resolved,
6552 RepositoryFilePath::new(Path::new("src/Äuth.rs"))?,
6553 SourceSpan::new(22, 2, 22, 16)?,
6554 generation,
6555 )?,
6556 ];
6557 let coverage = vec![
6558 CoverageRecord::new(
6559 CoverageScope::Project,
6560 None,
6561 CoverageState::Complete,
6562 4,
6563 0,
6564 generation,
6565 None,
6566 None,
6567 )?,
6568 CoverageRecord::new(
6569 CoverageScope::Path {
6570 path: RepositoryNodePath::new(Path::new("src"))?,
6571 },
6572 None,
6573 CoverageState::Partial,
6574 3,
6575 1,
6576 generation,
6577 Some(GraphIdentityText::new("one parser region omitted")?),
6578 Some(GraphLimitKind::Rows),
6579 )?,
6580 incomplete_coverage(
6581 GraphRelationKind::Legacy(RelationKind::Calls),
6582 CoverageState::Failed,
6583 "parser failed",
6584 generation,
6585 )?,
6586 incomplete_coverage(
6587 GraphRelationKind::Legacy(RelationKind::Imports),
6588 CoverageState::Ignored,
6589 "ignored by policy",
6590 generation,
6591 )?,
6592 incomplete_coverage(
6593 GraphRelationKind::Legacy(RelationKind::Contains),
6594 CoverageState::Oversized,
6595 "file exceeded limit",
6596 generation,
6597 )?,
6598 incomplete_coverage(
6599 GraphRelationKind::Legacy(RelationKind::DependsOn),
6600 CoverageState::Quarantined,
6601 "provider quarantined",
6602 generation,
6603 )?,
6604 incomplete_coverage(
6605 GraphRelationKind::Extended(ExtendedRelationKind::References),
6606 CoverageState::Stale,
6607 "source changed",
6608 generation,
6609 )?,
6610 ];
6611 Ok(GraphFixture {
6612 project,
6613 entities: vec![project_entity, folder, file, package, symbol, external],
6614 relations: vec![resolved, ambiguous, unresolved, external_relation],
6615 occurrences,
6616 coverage,
6617 })
6618 }
6619
6620 fn declaration_key(
6622 project: ProjectInstanceId,
6623 identity: &str,
6624 relation: GraphRelationKind,
6625 ) -> Result<CanonicalResolutionKey, Box<dyn Error>> {
6626 Ok(CanonicalResolutionKey::new(
6627 project,
6628 ResolutionKeyDomain::Declaration,
6629 &GraphIdentityText::new("tree-sitter")?,
6630 &GraphIdentityText::new("rust")?,
6631 None,
6632 Some(&GraphIdentityText::new("crate")?),
6633 Some(relation),
6634 &GraphIdentityText::new(identity)?,
6635 ))
6636 }
6637
6638 fn publish_resolution_fixture(
6640 store: &mut AtlasStore,
6641 fingerprint: &str,
6642 ) -> Result<ResolutionFixture, Box<dyn Error>> {
6643 let project = store
6644 .project_instance_id()?
6645 .ok_or_else(|| io::Error::other("bound fixture identity is missing"))?;
6646 let graph = graph_fixture(project, IndexGeneration::new(1))?;
6647 let resolved = declaration_key(
6648 project,
6649 "verifyToken",
6650 GraphRelationKind::Legacy(RelationKind::Calls),
6651 )?;
6652 let ambiguous = declaration_key(
6653 project,
6654 "Session",
6655 GraphRelationKind::Extended(ExtendedRelationKind::References),
6656 )?;
6657 let unresolved = declaration_key(
6658 project,
6659 "AUTH_KEY",
6660 GraphRelationKind::Extended(ExtendedRelationKind::Configures),
6661 )?;
6662 let export = EntityResolutionKey::new(graph.entities[4].key().clone(), resolved.clone())?;
6663 let exports = vec![export.clone(), export];
6664 let dependencies = vec![
6665 RelationDependencyKey::new(graph.relations[0].key().clone(), resolved.clone())?,
6666 RelationDependencyKey::new(graph.relations[1].key().clone(), ambiguous.clone())?,
6667 RelationDependencyKey::new(graph.relations[2].key().clone(), unresolved.clone())?,
6668 ];
6669 let mut publication = store.begin_index_publication(fingerprint)?;
6670 publication.begin_scan_replacement()?;
6671 publication.upsert_scan_node_batch(&[
6672 graph_node(".", NodeKind::Folder, None),
6673 graph_node("src", NodeKind::Folder, Some(".")),
6674 graph_node("src/Äuth.rs", NodeKind::File, Some("src")),
6675 graph_node("Cargo.toml", NodeKind::File, Some(".")),
6676 ])?;
6677 publication.finish_scan_replacement()?;
6678 publication.replace_repository_graph_with_resolution_keys(
6679 graph.project,
6680 &graph.entities,
6681 &graph.relations,
6682 &graph.occurrences,
6683 &graph.coverage,
6684 &exports,
6685 &dependencies,
6686 )?;
6687 publication.complete()?;
6688 Ok(ResolutionFixture {
6689 graph,
6690 resolved,
6691 ambiguous,
6692 unresolved,
6693 })
6694 }
6695
6696 fn replace_fixture_symbol_rows(
6698 store: &mut AtlasStore,
6699 path: &str,
6700 symbol_count: usize,
6701 ) -> Result<(), Box<dyn Error>> {
6702 let symbols = (0..symbol_count)
6703 .map(|index| CodeSymbol {
6704 path: path.to_string(),
6705 language: Some("rust".to_string()),
6706 name: format!("symbol_{index}"),
6707 kind: SymbolKind::Function,
6708 signature: format!("fn symbol_{index}()"),
6709 exported: index == 0,
6710 documentation: None,
6711 line_start: index + 1,
6712 line_end: index + 1,
6713 parent: None,
6714 parser: ParserKind::TreeSitter,
6715 detail: Some("function_item".to_string()),
6716 })
6717 .collect();
6718 store.replace_symbol_graph(&SymbolGraph {
6719 path: path.to_string(),
6720 language: Some("rust".to_string()),
6721 parser: ParserKind::TreeSitter,
6722 symbols,
6723 relations: vec![SymbolRelation {
6724 path: path.to_string(),
6725 source_name: "symbol_0".to_string(),
6726 target_name: "dependency".to_string(),
6727 kind: RelationKind::Calls,
6728 line: 1,
6729 context: "dependency()".to_string(),
6730 parser: ParserKind::TreeSitter,
6731 }],
6732 })?;
6733 Ok(())
6734 }
6735
6736 fn incomplete_coverage(
6738 relation: GraphRelationKind,
6739 state: CoverageState,
6740 reason: &str,
6741 generation: IndexGeneration,
6742 ) -> Result<CoverageRecord, Box<dyn Error>> {
6743 Ok(CoverageRecord::new(
6744 CoverageScope::Project,
6745 Some(relation),
6746 state,
6747 0,
6748 1,
6749 generation,
6750 Some(GraphIdentityText::new(reason)?),
6751 None,
6752 )?)
6753 }
6754
6755 fn graph_node(path: &str, kind: NodeKind, parent_path: Option<&str>) -> Node {
6757 Node {
6758 path: path.to_string(),
6759 kind,
6760 parent_path: parent_path.map(str::to_string),
6761 extension: None,
6762 language: None,
6763 size_bytes: None,
6764 mtime_ns: None,
6765 content_hash: None,
6766 }
6767 }
6768
6769 struct NavigationFixture {
6771 project: ProjectInstanceId,
6773 api_path: String,
6775 manifest_path: String,
6777 }
6778
6779 fn publish_navigation_fixture(
6781 store: &mut AtlasStore,
6782 fingerprint: &str,
6783 ) -> Result<NavigationFixture, Box<dyn Error>> {
6784 let project = store
6785 .project_instance_id()?
6786 .ok_or_else(|| io::Error::other("navigation fixture identity is missing"))?;
6787 let generation = IndexGeneration::new(1);
6788 let api_path = "src/auth/api.rs".to_string();
6789 let manifest_path = "Cargo.toml".to_string();
6790 let mut nodes = vec![
6791 graph_node(".", NodeKind::Folder, None),
6792 graph_node("src", NodeKind::Folder, Some(".")),
6793 graph_node("src/auth", NodeKind::Folder, Some("src")),
6794 graph_node(&api_path, NodeKind::File, Some("src/auth")),
6795 graph_node("src/auth/caller.rs", NodeKind::File, Some("src/auth")),
6796 graph_node("src/other.rs", NodeKind::File, Some("src")),
6797 graph_node("src/authz.rs", NodeKind::File, Some("src")),
6798 graph_node("tests", NodeKind::Folder, Some(".")),
6799 graph_node("tests/api_test.rs", NodeKind::File, Some("tests")),
6800 graph_node("clients", NodeKind::Folder, Some(".")),
6801 graph_node(&manifest_path, NodeKind::File, Some(".")),
6802 ];
6803 let api = GraphEntity::new(
6804 project,
6805 EntitySelector::File {
6806 path: RepositoryFilePath::new(Path::new(&api_path))?,
6807 },
6808 generation,
6809 )?;
6810 let internal_caller = GraphEntity::new(
6811 project,
6812 EntitySelector::File {
6813 path: RepositoryFilePath::new(Path::new("src/auth/caller.rs"))?,
6814 },
6815 generation,
6816 )?;
6817 let other = GraphEntity::new(
6818 project,
6819 EntitySelector::File {
6820 path: RepositoryFilePath::new(Path::new("src/other.rs"))?,
6821 },
6822 generation,
6823 )?;
6824 let sibling = GraphEntity::new(
6825 project,
6826 EntitySelector::File {
6827 path: RepositoryFilePath::new(Path::new("src/authz.rs"))?,
6828 },
6829 generation,
6830 )?;
6831 let test = GraphEntity::new(
6832 project,
6833 EntitySelector::File {
6834 path: RepositoryFilePath::new(Path::new("tests/api_test.rs"))?,
6835 },
6836 generation,
6837 )?;
6838 let package = GraphEntity::new(
6839 project,
6840 EntitySelector::Package {
6841 package: PackageSelector {
6842 manager: GraphIdentityText::new("cargo")?,
6843 name: GraphIdentityText::new("projectatlas-navigation")?,
6844 manifest: RepositoryFilePath::new(Path::new(&manifest_path))?,
6845 },
6846 },
6847 generation,
6848 )?;
6849 let mut entities = vec![
6850 api.clone(),
6851 internal_caller.clone(),
6852 other.clone(),
6853 sibling.clone(),
6854 test.clone(),
6855 package.clone(),
6856 ];
6857 let mut relations = vec![
6858 LogicalRelation::new(
6859 &api,
6860 GraphRelationKind::Legacy(RelationKind::DependsOn),
6861 RelationResolution::resolved(&package)?,
6862 ConfidenceClass::Exact,
6863 Completeness::Complete,
6864 generation,
6865 )?,
6866 LogicalRelation::new(
6867 &api,
6868 GraphRelationKind::Legacy(RelationKind::Imports),
6869 RelationResolution::resolved(&other)?,
6870 ConfidenceClass::Exact,
6871 Completeness::Complete,
6872 generation,
6873 )?,
6874 LogicalRelation::new(
6875 &api,
6876 GraphRelationKind::Legacy(RelationKind::Calls),
6877 RelationResolution::resolved(&other)?,
6878 ConfidenceClass::Exact,
6879 Completeness::Complete,
6880 generation,
6881 )?,
6882 LogicalRelation::new(
6883 &api,
6884 GraphRelationKind::Extended(ExtendedRelationKind::References),
6885 RelationResolution::Unresolved {
6886 reference: GraphIdentityText::new("SessionStore")?,
6887 },
6888 ConfidenceClass::High,
6889 Completeness::Partial,
6890 generation,
6891 )?,
6892 LogicalRelation::new(
6893 &test,
6894 GraphRelationKind::Extended(ExtendedRelationKind::Tests),
6895 RelationResolution::resolved(&api)?,
6896 ConfidenceClass::Exact,
6897 Completeness::Complete,
6898 generation,
6899 )?,
6900 LogicalRelation::new(
6901 &api,
6902 GraphRelationKind::Extended(ExtendedRelationKind::RoutesTo),
6903 RelationResolution::resolved(&other)?,
6904 ConfidenceClass::High,
6905 Completeness::Complete,
6906 generation,
6907 )?,
6908 LogicalRelation::new(
6909 &api,
6910 GraphRelationKind::Extended(ExtendedRelationKind::Configures),
6911 RelationResolution::Unresolved {
6912 reference: GraphIdentityText::new("AUTH_MODE")?,
6913 },
6914 ConfidenceClass::High,
6915 Completeness::Partial,
6916 generation,
6917 )?,
6918 LogicalRelation::new(
6919 &internal_caller,
6920 GraphRelationKind::Legacy(RelationKind::Calls),
6921 RelationResolution::resolved(&api)?,
6922 ConfidenceClass::Exact,
6923 Completeness::Complete,
6924 generation,
6925 )?,
6926 LogicalRelation::new(
6927 &sibling,
6928 GraphRelationKind::Legacy(RelationKind::Imports),
6929 RelationResolution::resolved(&other)?,
6930 ConfidenceClass::Exact,
6931 Completeness::Complete,
6932 generation,
6933 )?,
6934 ];
6935 for index in 0..4 {
6936 let path = format!("clients/caller-{index}.rs");
6937 nodes.push(graph_node(&path, NodeKind::File, Some("clients")));
6938 let caller = GraphEntity::new(
6939 project,
6940 EntitySelector::File {
6941 path: RepositoryFilePath::new(Path::new(&path))?,
6942 },
6943 generation,
6944 )?;
6945 relations.push(LogicalRelation::new(
6946 &caller,
6947 GraphRelationKind::Legacy(RelationKind::Calls),
6948 RelationResolution::resolved(&api)?,
6949 ConfidenceClass::Exact,
6950 Completeness::Complete,
6951 generation,
6952 )?);
6953 entities.push(caller);
6954 }
6955
6956 let mut publication = store.begin_index_publication(fingerprint)?;
6957 publication.begin_scan_replacement()?;
6958 publication.upsert_scan_node_batch(&nodes)?;
6959 publication.finish_scan_replacement()?;
6960 publication.replace_repository_graph(project, &entities, &relations, &[], &[])?;
6961 publication.complete()?;
6962 Ok(NavigationFixture {
6963 project,
6964 api_path,
6965 manifest_path,
6966 })
6967 }
6968
6969 fn require(condition: bool, message: &str) -> Result<(), Box<dyn Error>> {
6971 if condition {
6972 Ok(())
6973 } else {
6974 Err(io::Error::other(message.to_string()).into())
6975 }
6976 }
6977
6978 fn require_eq<T: Debug + PartialEq>(
6980 actual: &T,
6981 expected: &T,
6982 context: &str,
6983 ) -> Result<(), Box<dyn Error>> {
6984 require(
6985 actual == expected,
6986 &format!("{context}: expected {expected:?}, found {actual:?}"),
6987 )
6988 }
6989
6990 fn require_db_error<T>(result: DbResult<T>, message: &str) -> Result<DbError, Box<dyn Error>> {
6992 let Err(error) = result else {
6993 return Err(io::Error::other(message.to_string()).into());
6994 };
6995 Ok(error)
6996 }
6997
6998 fn assert_cursor_hydration_indexes(store: &AtlasStore) -> Result<(), Box<dyn Error>> {
7000 let project = store
7001 .project_instance_id()?
7002 .ok_or_else(|| io::Error::other("bound fixture identity is missing"))?;
7003 let cases = [
7004 (
7005 "entity cursor hydration",
7006 graph_entity_hydration_sql(2),
7007 vec![
7008 Value::Blob(vec![0; 32]),
7009 Value::Blob(vec![1; 32]),
7010 Value::Blob(project.as_bytes().to_vec()),
7011 ],
7012 "project_instance_id=? AND entity_key=?",
7013 "SCAN entity",
7014 false,
7015 ),
7016 (
7017 "relation cursor hydration",
7018 graph_relation_hydration_sql(2),
7019 vec![
7020 Value::Blob(vec![0; 32]),
7021 Value::Blob(vec![1; 32]),
7022 Value::Blob(project.as_bytes().to_vec()),
7023 ],
7024 "idx_graph_relations_project_key",
7025 "SCAN relation",
7026 false,
7027 ),
7028 (
7029 "batched occurrence hydration",
7030 occurrence_pages_sql(2),
7031 vec![
7032 Value::Blob(vec![0; 32]),
7033 Value::Integer(2),
7034 Value::Blob(vec![1; 32]),
7035 Value::Integer(2),
7036 ],
7037 "sqlite_autoindex_graph_relation_occurrences_1",
7038 "SCAN graph_relation_occurrences",
7039 true,
7040 ),
7041 (
7042 "batched path coverage hydration",
7043 path_coverage_sql(2),
7044 vec![
7045 Value::Blob(project.as_bytes().to_vec()),
7046 Value::Text("path".to_string()),
7047 Value::Text("Cargo.toml".to_string()),
7048 Value::Text("src/Äuth.rs".to_string()),
7049 Value::Integer(i64::from(GraphLimits::MAX_ROWS) + 1),
7050 ],
7051 "idx_graph_coverage_scope_order",
7052 "SCAN graph_coverage",
7053 false,
7054 ),
7055 ];
7056 for (context, sql, bindings, required_plan, forbidden_scan, allow_bounded_sort) in cases {
7057 let mut statement = store
7058 .connection
7059 .prepare(&format!("EXPLAIN QUERY PLAN {sql}"))?;
7060 let details = statement
7061 .query_map(params_from_iter(bindings.iter()), |row| {
7062 row.get::<_, String>(3)
7063 })?
7064 .collect::<Result<Vec<_>, _>>()?;
7065 require(
7066 details.iter().any(|detail| detail.contains(required_plan))
7067 && details.iter().all(|detail| {
7068 !detail.contains(forbidden_scan)
7069 && (allow_bounded_sort || !detail.contains("USE TEMP B-TREE"))
7070 }),
7071 &format!(
7072 "{context} did not use {required_plan} without a scan or sort: {details:?}"
7073 ),
7074 )?;
7075 }
7076 Ok(())
7077 }
7078
7079 fn assert_query_indexes(store: &AtlasStore) -> Result<(), Box<dyn Error>> {
7081 let cases: &[(&str, &str, &[&str])] = &[
7082 (
7083 "entity path lookup",
7084 "EXPLAIN QUERY PLAN
7085 SELECT entity_key FROM graph_entities
7086 WHERE project_instance_id = zeroblob(16)
7087 AND repository_path = 'src/Äuth.rs'
7088 ORDER BY entity_kind, entity_key
7089 LIMIT 11",
7090 &["idx_graph_entities_path"],
7091 ),
7092 (
7093 "outbound relation lookup",
7094 "EXPLAIN QUERY PLAN
7095 SELECT relation_key FROM graph_relations
7096 WHERE source_entity_key = zeroblob(32)
7097 ORDER BY relation_scope, relation_kind, relation_key
7098 LIMIT 11",
7099 &["idx_graph_relations_source_kind"],
7100 ),
7101 (
7102 "inbound relation lookup",
7103 "EXPLAIN QUERY PLAN
7104 SELECT relation_key FROM graph_relations
7105 WHERE target_entity_key = zeroblob(32)
7106 ORDER BY relation_scope, relation_kind, relation_key
7107 LIMIT 11",
7108 &["idx_graph_relations_target_kind"],
7109 ),
7110 (
7111 "relation family lookup",
7112 "EXPLAIN QUERY PLAN
7113 SELECT relation_key FROM graph_relations
7114 WHERE project_instance_id = zeroblob(16)
7115 AND relation_scope = 'legacy'
7116 AND relation_kind = 'calls'
7117 ORDER BY relation_key
7118 LIMIT 11",
7119 &["idx_graph_relations_kind_order"],
7120 ),
7121 (
7122 "relation occurrence lookup",
7123 "EXPLAIN QUERY PLAN
7124 SELECT file_path FROM graph_relation_occurrences
7125 WHERE relation_key = zeroblob(32)
7126 ORDER BY file_path, start_line, start_column, end_line, end_column
7127 LIMIT 11",
7128 &["sqlite_autoindex_graph_relation_occurrences_1"],
7129 ),
7130 (
7131 "occurrence path invalidation",
7132 "EXPLAIN QUERY PLAN
7133 SELECT relation_key FROM graph_relation_occurrences
7134 WHERE file_path = 'src'
7135 OR (file_path >= 'src/' AND file_path < 'src0')",
7136 &["idx_graph_occurrences_file_span"],
7137 ),
7138 (
7139 "coverage path invalidation",
7140 "EXPLAIN QUERY PLAN
7141 SELECT id FROM graph_coverage
7142 INDEXED BY idx_graph_coverage_path
7143 WHERE scope_kind = 'path'
7144 AND (scope_path = 'src'
7145 OR (scope_path >= 'src/' AND scope_path < 'src0'))",
7146 &["idx_graph_coverage_path"],
7147 ),
7148 (
7149 "entity repository-path invalidation",
7150 "EXPLAIN QUERY PLAN
7151 SELECT entity_key FROM graph_entities
7152 WHERE repository_path = 'src'
7153 OR (repository_path >= 'src/' AND repository_path < 'src0')",
7154 &["idx_graph_entities_path"],
7155 ),
7156 (
7157 "entity manifest-path invalidation",
7158 "EXPLAIN QUERY PLAN
7159 SELECT entity_key FROM graph_entities
7160 WHERE manifest_path = 'src'
7161 OR (manifest_path >= 'src/' AND manifest_path < 'src0')",
7162 &["idx_graph_entities_manifest_path"],
7163 ),
7164 (
7165 "outbound external cleanup candidate",
7166 "EXPLAIN QUERY PLAN
7167 SELECT relation.target_entity_key
7168 FROM graph_relations AS relation
7169 INDEXED BY idx_graph_relations_source_kind
7170 JOIN graph_entities AS external
7171 ON external.entity_key = relation.target_entity_key
7172 WHERE relation.source_entity_key = zeroblob(32)
7173 AND external.entity_kind = 'external'",
7174 &["idx_graph_relations_source_kind"],
7175 ),
7176 (
7177 "inbound external cleanup candidate",
7178 "EXPLAIN QUERY PLAN
7179 SELECT relation.source_entity_key
7180 FROM graph_relations AS relation
7181 INDEXED BY idx_graph_relations_target_kind
7182 JOIN graph_entities AS external
7183 ON external.entity_key = relation.source_entity_key
7184 WHERE relation.target_entity_key = zeroblob(32)
7185 AND external.entity_kind = 'external'",
7186 &["idx_graph_relations_target_kind"],
7187 ),
7188 (
7189 "candidate-bounded external cleanup",
7190 "EXPLAIN QUERY PLAN
7191 DELETE FROM graph_entities
7192 WHERE entity_key = zeroblob(32) AND entity_kind = 'external'
7193 AND NOT EXISTS (
7194 SELECT 1 FROM graph_relations
7195 INDEXED BY idx_graph_relations_source_kind
7196 WHERE source_entity_key = zeroblob(32)
7197 )
7198 AND NOT EXISTS (
7199 SELECT 1 FROM graph_relations
7200 INDEXED BY idx_graph_relations_target_kind
7201 WHERE target_entity_key = zeroblob(32)
7202 )",
7203 &[
7204 "sqlite_autoindex_graph_entities_1",
7205 "idx_graph_relations_source_kind",
7206 "idx_graph_relations_target_kind",
7207 ],
7208 ),
7209 (
7210 "coverage scope lookup",
7211 "EXPLAIN QUERY PLAN
7212 SELECT id FROM graph_coverage
7213 WHERE project_instance_id = zeroblob(16)
7214 AND scope_kind = 'path'
7215 AND scope_path IS 'src'
7216 ORDER BY relation_scope, relation_kind, state, id
7217 LIMIT 11",
7218 &["idx_graph_coverage_scope_order"],
7219 ),
7220 (
7221 "resolution witness lookup",
7222 "EXPLAIN QUERY PLAN
7223 SELECT canonical_identity FROM graph_resolution_keys
7224 WHERE project_instance_id = zeroblob(16)
7225 AND resolution_domain = 'declaration'
7226 AND key_digest = zeroblob(32)",
7227 &["sqlite_autoindex_graph_resolution_keys_1"],
7228 ),
7229 (
7230 "resolution export lookup",
7231 "EXPLAIN QUERY PLAN
7232 SELECT entity_key FROM graph_entity_exports
7233 WHERE project_instance_id = zeroblob(16)
7234 AND resolution_domain = 'declaration'
7235 AND key_digest = zeroblob(32)
7236 ORDER BY entity_key",
7237 &["idx_graph_entity_exports_key"],
7238 ),
7239 (
7240 "resolution export owner lookup",
7241 "EXPLAIN QUERY PLAN
7242 SELECT resolution_domain, key_digest, entity_key
7243 FROM graph_entity_exports
7244 WHERE project_instance_id = zeroblob(16)
7245 AND owner_path = 'src/Äuth.rs'
7246 ORDER BY resolution_domain, key_digest, entity_key",
7247 &["idx_graph_entity_exports_owner"],
7248 ),
7249 (
7250 "resolution dependency lookup",
7251 "EXPLAIN QUERY PLAN
7252 SELECT owner_path, relation_key FROM graph_relation_dependencies
7253 WHERE project_instance_id = zeroblob(16)
7254 AND resolution_domain = 'declaration'
7255 AND key_digest = zeroblob(32)
7256 ORDER BY owner_path, relation_key",
7257 &["idx_graph_relation_dependencies_key"],
7258 ),
7259 (
7260 "resolution dependency owner lookup",
7261 "EXPLAIN QUERY PLAN
7262 SELECT resolution_domain, key_digest, relation_key
7263 FROM graph_relation_dependencies
7264 WHERE project_instance_id = zeroblob(16)
7265 AND owner_path = 'src/Äuth.rs'
7266 ORDER BY resolution_domain, key_digest, relation_key",
7267 &["idx_graph_relation_dependencies_owner"],
7268 ),
7269 (
7270 "relation composite integrity lookup",
7271 "EXPLAIN QUERY PLAN
7272 SELECT relation_key FROM graph_relations
7273 WHERE project_instance_id = zeroblob(16)
7274 AND relation_key = zeroblob(32)",
7275 &["idx_graph_relations_project_key"],
7276 ),
7277 ];
7278
7279 for (context, sql, required_indexes) in cases {
7280 let mut statement = store.connection.prepare(sql)?;
7281 let details = statement
7282 .query_map([], |row| row.get::<_, String>(3))?
7283 .collect::<Result<Vec<_>, _>>()?;
7284 require(
7285 required_indexes
7286 .iter()
7287 .all(|index| details.iter().any(|detail| detail.contains(index))),
7288 &format!("{context} did not use {required_indexes:?}; query plan was {details:?}"),
7289 )?;
7290 require(
7291 details.iter().all(|detail| {
7292 !detail.contains("SCAN graph_") && !detail.contains("USE TEMP B-TREE")
7293 }),
7294 &format!("{context} was not bounded by index order: {details:?}"),
7295 )?;
7296 }
7297
7298 for key_count in [2, EXTERNAL_CANDIDATE_KEYS_PER_QUERY] {
7299 let sql = format!(
7300 "EXPLAIN QUERY PLAN {}",
7301 external_candidate_batch_sql(key_count)
7302 );
7303 let bindings = (0..key_count)
7304 .map(|_| Value::Blob(vec![0_u8; 32]))
7305 .collect::<Vec<_>>();
7306 let mut statement = store.connection.prepare(&sql)?;
7307 let details = statement
7308 .query_map(params_from_iter(bindings.iter()), |row| {
7309 row.get::<_, String>(3)
7310 })?
7311 .collect::<Result<Vec<_>, _>>()?;
7312 require(
7313 [
7314 "SEARCH relation USING INDEX idx_graph_relations_source_kind (source_entity_key=?)",
7315 "SEARCH relation USING INDEX idx_graph_relations_target_kind (target_entity_key=?)",
7316 ]
7317 .iter()
7318 .all(|seek| details.iter().any(|detail| detail.contains(seek)))
7319 && details.iter().all(|detail| {
7320 !detail.contains("SCAN relation")
7321 && !detail.contains("SCAN graph_relations")
7322 && !detail.contains("USE TEMP B-TREE")
7323 }),
7324 &format!(
7325 "{key_count}-key external cleanup batch was not driven by indexed point seeks: {details:?}"
7326 ),
7327 )?;
7328 }
7329 Ok(())
7330 }
7331
7332 fn assert_coverage_discovery_plan(
7334 connection: &Connection,
7335 sql: &str,
7336 values: &[Value],
7337 required_indexes: &[&str],
7338 allow_bounded_partial_sort: bool,
7339 context: &str,
7340 ) -> Result<(), Box<dyn Error>> {
7341 let mut statement = connection.prepare(sql)?;
7342 let details = statement
7343 .query_map(params_from_iter(values.iter()), |row| {
7344 row.get::<_, String>(3)
7345 })?
7346 .collect::<Result<Vec<_>, _>>()?;
7347 require(
7348 required_indexes
7349 .iter()
7350 .all(|index| details.iter().any(|detail| detail.contains(index))),
7351 &format!("{context} did not use {required_indexes:?}; query plan was {details:?}"),
7352 )?;
7353 require(
7354 details.iter().all(|detail| {
7355 let uses_temporary_sort = detail.contains("USE TEMP B-TREE");
7356 let bounded_partial_sort = detail.contains("USE TEMP B-TREE FOR LAST");
7357 !detail.contains("SCAN coverage")
7358 && (!uses_temporary_sort
7359 || (allow_bounded_partial_sort && bounded_partial_sort))
7360 }),
7361 &format!("{context} used an unbounded scan or sort: {details:?}"),
7362 )?;
7363 Ok(())
7364 }
7365
7366 fn publish_detailed_relation_trace_fixture(
7368 store: &mut AtlasStore,
7369 fingerprint: &str,
7370 ) -> Result<DetailedRelationTraceFixture, Box<dyn Error>> {
7371 let project = store
7372 .project_instance_id()?
7373 .ok_or_else(|| io::Error::other("bound trace fixture identity is missing"))?;
7374 let generation = IndexGeneration::new(1);
7375 let source_path = RepositoryFilePath::new(Path::new("src/trace-source.rs"))?;
7376 let source = GraphEntity::new(
7377 project,
7378 EntitySelector::File {
7379 path: source_path.clone(),
7380 },
7381 generation,
7382 )?;
7383 let mut entities = Vec::with_capacity(MAX_REPOSITORY_GRAPH_FRONTIER + 1);
7384 let mut relations = Vec::with_capacity(MAX_REPOSITORY_GRAPH_FRONTIER);
7385 let mut occurrences = Vec::with_capacity(MAX_REPOSITORY_GRAPH_FRONTIER * 2);
7386 entities.push(source.clone());
7387 for index in 0..MAX_REPOSITORY_GRAPH_FRONTIER {
7388 let name = format!("trace_target_{index:03}");
7389 let target = GraphEntity::new(
7390 project,
7391 EntitySelector::Symbol {
7392 symbol: SymbolSelector {
7393 file: source_path.clone(),
7394 name: GraphIdentityText::new(&name)?,
7395 kind: SymbolKind::Function,
7396 parent: None,
7397 signature: GraphIdentityText::new(format!("{name}()"))?,
7398 },
7399 },
7400 generation,
7401 )?;
7402 let relation = LogicalRelation::new(
7403 &source,
7404 GraphRelationKind::Legacy(RelationKind::Calls),
7405 RelationResolution::resolved(&target)?,
7406 ConfidenceClass::Exact,
7407 Completeness::Complete,
7408 generation,
7409 )?;
7410 let first_line = u32::try_from(index * 2 + 1)?;
7411 let second_line = first_line + 1;
7412 occurrences.push(RelationOccurrence::new(
7413 &relation,
7414 source_path.clone(),
7415 SourceSpan::new(first_line, 1, first_line, 2)?,
7416 generation,
7417 )?);
7418 occurrences.push(RelationOccurrence::new(
7419 &relation,
7420 source_path.clone(),
7421 SourceSpan::new(second_line, 1, second_line, 2)?,
7422 generation,
7423 )?);
7424 entities.push(target);
7425 relations.push(relation);
7426 }
7427
7428 let mut publication = store.begin_index_publication(fingerprint)?;
7429 publication.begin_scan_replacement()?;
7430 publication.upsert_scan_node_batch(&[
7431 graph_node(".", NodeKind::Folder, None),
7432 graph_node("src", NodeKind::Folder, Some(".")),
7433 graph_node("src/trace-source.rs", NodeKind::File, Some("src")),
7434 ])?;
7435 publication.finish_scan_replacement()?;
7436 publication.replace_repository_graph(project, &entities, &relations, &occurrences, &[])?;
7437 publication.complete()?;
7438 Ok(DetailedRelationTraceFixture {
7439 project,
7440 source,
7441 relations,
7442 })
7443 }
7444
7445 fn publish_fixture(
7447 store: &mut AtlasStore,
7448 fingerprint: &str,
7449 ) -> Result<GraphFixture, Box<dyn Error>> {
7450 let project = store
7451 .project_instance_id()?
7452 .ok_or_else(|| io::Error::other("bound fixture identity is missing"))?;
7453 let mut fixture = graph_fixture(project, IndexGeneration::new(1))?;
7454 fixture.coverage.push(CoverageRecord::new(
7455 CoverageScope::Path {
7456 path: RepositoryNodePath::new(Path::new("src/Äuth.rs"))?,
7457 },
7458 None,
7459 CoverageState::Complete,
7460 1,
7461 0,
7462 IndexGeneration::new(1),
7463 None,
7464 None,
7465 )?);
7466 let mut occurrences = fixture.occurrences.clone();
7467 occurrences.push(fixture.occurrences[0].clone());
7468 let mut publication = store.begin_index_publication(fingerprint)?;
7469 publication.begin_scan_replacement()?;
7470 publication.upsert_scan_node_batch(&[
7471 graph_node(".", NodeKind::Folder, None),
7472 graph_node("src", NodeKind::Folder, Some(".")),
7473 graph_node("src/Äuth.rs", NodeKind::File, Some("src")),
7474 graph_node("Cargo.toml", NodeKind::File, Some(".")),
7475 ])?;
7476 publication.finish_scan_replacement()?;
7477 publication.replace_symbol_graph(&SymbolGraph {
7478 path: "src/Äuth.rs".to_string(),
7479 language: Some("rust".to_string()),
7480 parser: ParserKind::TreeSitter,
7481 symbols: Vec::new(),
7482 relations: vec![SymbolRelation {
7483 path: "src/Äuth.rs".to_string(),
7484 source_name: "verifyToken".to_string(),
7485 target_name: "legacyTarget".to_string(),
7486 kind: RelationKind::Calls,
7487 line: 10,
7488 context: "legacyTarget()".to_string(),
7489 parser: ParserKind::TreeSitter,
7490 }],
7491 })?;
7492 publication.replace_file_texts_for_paths(
7493 &["src/Äuth.rs".to_string()],
7494 &[IndexedFileText {
7495 path: "src/Äuth.rs".to_string(),
7496 content_hash: Some("hash-old".to_string()),
7497 byte_count: 16,
7498 line_count: 1,
7499 content: "fn verifyToken()".to_string(),
7500 }],
7501 )?;
7502 publication.replace_repository_graph(
7503 fixture.project,
7504 &fixture.entities,
7505 &fixture.relations,
7506 &occurrences,
7507 &fixture.coverage,
7508 )?;
7509 publication.complete()?;
7510 Ok(fixture)
7511 }
7512
7513 #[test]
7514 fn repository_graph_staging_checkpoints_ownership_before_graph_writes()
7515 -> Result<(), Box<dyn Error>> {
7516 let temp = tempfile::tempdir()?;
7517 let project_root = temp.path().join("staging-ownership");
7518 let stage = project_root.join(".projectatlas").join("graph-stage-test");
7519 fs::create_dir_all(&stage)?;
7520 let database = stage.join("projectatlas.db");
7521 let project = ProjectInstanceId::from_bytes([7; 16])?;
7522 let _store =
7523 AtlasStore::create_repository_graph_staging(&database, &project_root, project)?;
7524
7525 let main_database_only = temp.path().join("staging-main-only.db");
7526 fs::copy(&database, &main_database_only)?;
7527 require(
7528 AtlasStore::repository_graph_staging_belongs_to(
7529 &main_database_only,
7530 &project_root,
7531 project,
7532 )?,
7533 "staging ownership depended on WAL sidecars before graph writes",
7534 )
7535 }
7536
7537 #[test]
7538 fn resolution_keys_round_trip_reopen_and_preserve_all_dependency_states()
7539 -> Result<(), Box<dyn Error>> {
7540 let temp = tempfile::tempdir()?;
7541 let project_root = temp.path().join("resolution-round-trip");
7542 let atlas_dir = project_root.join(".projectatlas");
7543 fs::create_dir_all(&atlas_dir)?;
7544 let db_path = atlas_dir.join("projectatlas.db");
7545 let mut writer = AtlasStore::open_for_project(&db_path, &project_root)?;
7546 let fixture = publish_resolution_fixture(&mut writer, "resolution-round-trip")?;
7547 assert_query_indexes(&writer)?;
7548
7549 let counts = writer.connection.query_row(
7550 "SELECT
7551 (SELECT COUNT(*) FROM graph_resolution_keys),
7552 (SELECT COUNT(*) FROM graph_entity_exports),
7553 (SELECT COUNT(*) FROM graph_relation_dependencies)",
7554 [],
7555 |row| {
7556 Ok((
7557 row.get::<_, i64>(0)?,
7558 row.get::<_, i64>(1)?,
7559 row.get::<_, i64>(2)?,
7560 ))
7561 },
7562 )?;
7563 require_eq(&counts, &(3, 1, 3), "deduplicated resolution-key rows")?;
7564 let states = writer
7565 .connection
7566 .prepare(
7567 "SELECT relation.resolution_status, COUNT(*)
7568 FROM graph_relation_dependencies AS dependency
7569 JOIN graph_relations AS relation
7570 ON relation.project_instance_id = dependency.project_instance_id
7571 AND relation.relation_key = dependency.relation_key
7572 GROUP BY relation.resolution_status
7573 ORDER BY relation.resolution_status",
7574 )?
7575 .query_map([], |row| {
7576 Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
7577 })?
7578 .collect::<Result<Vec<_>, _>>()?;
7579 require_eq(
7580 &states,
7581 &vec![
7582 ("ambiguous".to_string(), 1),
7583 ("resolved".to_string(), 1),
7584 ("unresolved".to_string(), 1),
7585 ],
7586 "dependency resolution states",
7587 )?;
7588 drop(writer);
7589
7590 let reader = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
7591 let exports = reader.repository_export_keys_for_paths(
7592 fixture.graph.project,
7593 &["src/Äuth.rs".to_string()],
7594 10,
7595 )?;
7596 require_eq(&exports.truncated, &false, "export key truncation")?;
7597 require_eq(
7598 &exports.rows,
7599 &vec![fixture.resolved.clone()],
7600 "export key round trip",
7601 )?;
7602 let candidates = reader.repository_resolution_candidates(&fixture.resolved, 10)?;
7603 require_eq(&candidates.truncated, &false, "candidate truncation")?;
7604 require_eq(
7605 &candidates.rows,
7606 &vec![fixture.graph.entities[4].clone()],
7607 "candidate export round trip",
7608 )?;
7609 let batch_candidates = reader.repository_resolution_candidates_for_keys(
7610 fixture.graph.project,
7611 &[
7612 fixture.unresolved.clone(),
7613 fixture.resolved.clone(),
7614 fixture.ambiguous.clone(),
7615 fixture.resolved.clone(),
7616 ],
7617 10,
7618 )?;
7619 require_eq(
7620 &batch_candidates.truncated,
7621 &false,
7622 "batch candidate truncation",
7623 )?;
7624 require_eq(
7625 &batch_candidates.rows.len(),
7626 &1,
7627 "batch candidate deduplication",
7628 )?;
7629 require_eq(
7630 batch_candidates.rows[0].key(),
7631 &fixture.resolved,
7632 "batch candidate selecting key",
7633 )?;
7634 require_eq(
7635 batch_candidates.rows[0].entity(),
7636 &fixture.graph.entities[4],
7637 "batch candidate entity",
7638 )?;
7639 let affected = reader.repository_affected_source_paths(
7640 fixture.graph.project,
7641 &[
7642 fixture.resolved.clone(),
7643 fixture.resolved,
7644 fixture.ambiguous,
7645 fixture.unresolved,
7646 ],
7647 10,
7648 )?;
7649 require_eq(&affected.truncated, &false, "affected path truncation")?;
7650 require_eq(
7651 &affected.rows,
7652 &vec![RepositoryFilePath::new(Path::new("src/Äuth.rs"))?],
7653 "resolved ambiguous and unresolved dependency owners",
7654 )?;
7655 reader.finish_index_read_snapshot()?;
7656 Ok(())
7657 }
7658
7659 #[test]
7660 fn high_degree_dependency_closure_is_unique_and_reports_overflow_before_mutation()
7661 -> Result<(), Box<dyn Error>> {
7662 let temp = tempfile::tempdir()?;
7663 let project_root = temp.path().join("high-degree-resolution");
7664 fs::create_dir_all(&project_root)?;
7665 let db_path = project_root.join("projectatlas.db");
7666 let mut store = AtlasStore::open_for_project(&db_path, &project_root)?;
7667 let project = store
7668 .project_instance_id()?
7669 .ok_or_else(|| io::Error::other("bound identity is missing"))?;
7670 let generation = IndexGeneration::new(1);
7671 let dependency_key = declaration_key(
7672 project,
7673 "sharedTarget",
7674 GraphRelationKind::Extended(ExtendedRelationKind::References),
7675 )?;
7676 let mut nodes = vec![
7677 graph_node(".", NodeKind::Folder, None),
7678 graph_node("src", NodeKind::Folder, Some(".")),
7679 ];
7680 let mut entities = Vec::new();
7681 let mut relations = Vec::new();
7682 let mut dependencies = Vec::new();
7683 for index in 0..5 {
7684 let path = format!("src/caller-{index}.rs");
7685 nodes.push(graph_node(&path, NodeKind::File, Some("src")));
7686 let entity = GraphEntity::new(
7687 project,
7688 EntitySelector::File {
7689 path: RepositoryFilePath::new(Path::new(&path))?,
7690 },
7691 generation,
7692 )?;
7693 let relation = LogicalRelation::new(
7694 &entity,
7695 GraphRelationKind::Extended(ExtendedRelationKind::References),
7696 RelationResolution::Unresolved {
7697 reference: GraphIdentityText::new("sharedTarget")?,
7698 },
7699 ConfidenceClass::High,
7700 Completeness::Complete,
7701 generation,
7702 )?;
7703 dependencies.push(RelationDependencyKey::new(
7704 relation.key().clone(),
7705 dependency_key.clone(),
7706 )?);
7707 entities.push(entity);
7708 relations.push(relation);
7709 }
7710 dependencies.push(dependencies[0].clone());
7711 let mut publication = store.begin_index_publication("high-degree-resolution")?;
7712 publication.begin_scan_replacement()?;
7713 publication.upsert_scan_node_batch(&nodes)?;
7714 publication.finish_scan_replacement()?;
7715 publication.replace_repository_graph_with_resolution_keys(
7716 project,
7717 &entities,
7718 &relations,
7719 &[],
7720 &[],
7721 &[],
7722 &dependencies,
7723 )?;
7724 publication.complete()?;
7725
7726 let before = store.index_publication()?;
7727 let bounded = store.repository_affected_source_paths(
7728 project,
7729 std::slice::from_ref(&dependency_key),
7730 2,
7731 )?;
7732 require_eq(&bounded.rows.len(), &2, "bounded affected path count")?;
7733 require_eq(&bounded.truncated, &true, "bounded affected path overflow")?;
7734 require_eq(
7735 &store.index_publication()?,
7736 &before,
7737 "overflow lookup mutated publication state",
7738 )?;
7739 let complete = store.repository_affected_source_paths(project, &[dependency_key], 10)?;
7740 require_eq(&complete.rows.len(), &5, "complete affected path count")?;
7741 require_eq(
7742 &complete.truncated,
7743 &false,
7744 "complete affected path overflow",
7745 )?;
7746 let unique = complete.rows.iter().collect::<BTreeSet<_>>();
7747 require_eq(&unique.len(), &5, "affected paths are unique")?;
7748 Ok(())
7749 }
7750
7751 #[test]
7752 fn affected_source_footprint_accounts_exact_owned_rows_and_uses_path_indexes()
7753 -> Result<(), Box<dyn Error>> {
7754 let temp = tempfile::tempdir()?;
7755 let project_root = temp.path().join("affected-source-footprint");
7756 fs::create_dir_all(&project_root)?;
7757 let db_path = project_root.join("projectatlas.db");
7758 let mut store = AtlasStore::open_for_project(&db_path, &project_root)?;
7759 let fixture = publish_resolution_fixture(&mut store, "affected-source-footprint")?;
7760 replace_fixture_symbol_rows(&mut store, "src/Äuth.rs", 1)?;
7761 store.connection.execute(
7762 "INSERT INTO graph_coverage(
7763 project_instance_id, scope_kind, scope_path, relation_scope,
7764 relation_kind, state, total, covered, omitted, reason, reached_limit
7765 ) VALUES(?1, 'path', 'src/Äuth.rs', NULL, NULL,
7766 'complete', 1, 1, 0, NULL, NULL)",
7767 [&fixture.graph.project.as_bytes()[..]],
7768 )?;
7769
7770 let mut paths = (0..=RESOLUTION_PATHS_PER_QUERY)
7771 .map(|index| format!("missing/{index:04}.rs"))
7772 .collect::<Vec<_>>();
7773 paths.extend(["src/Äuth.rs".to_string(), "src/Äuth.rs".to_string()]);
7774 let footprint =
7775 store.repository_affected_source_footprint(fixture.graph.project, &paths, 100)?;
7776 require_eq(&footprint.rows, &20, "exact affected persisted rows")?;
7777 require(
7778 footprint.retained_bytes > footprint.rows,
7779 "affected footprint omitted decoded bytes",
7780 )?;
7781 require_eq(&footprint.truncated, &false, "exact footprint truncation")?;
7782
7783 let sql = format!("EXPLAIN QUERY PLAN {}", affected_source_footprint_sql(1));
7784 let values = [
7785 Value::Blob(fixture.graph.project.as_bytes().to_vec()),
7786 Value::Text("src/Äuth.rs".to_string()),
7787 Value::Integer(101),
7788 ];
7789 let mut statement = store.connection.prepare(&sql)?;
7790 let details = statement
7791 .query_map(params_from_iter(values.iter()), |row| {
7792 row.get::<_, String>(3)
7793 })?
7794 .collect::<Result<Vec<_>, _>>()?;
7795 for index in [
7796 "sqlite_autoindex_source_parse_metadata_1",
7797 "idx_symbols_path",
7798 "idx_symbol_relations_path",
7799 "idx_graph_entities_path",
7800 "idx_graph_entities_manifest_path",
7801 "idx_graph_relations_source_kind",
7802 "idx_graph_occurrences_file_span",
7803 "idx_graph_coverage_path",
7804 "idx_graph_entity_exports_owner",
7805 "idx_graph_relation_dependencies_owner",
7806 "sqlite_autoindex_graph_resolution_keys_1",
7807 ] {
7808 require(
7809 details.iter().any(|detail| detail.contains(index)),
7810 &format!("affected footprint missed {index}; plan was {details:?}"),
7811 )?;
7812 }
7813 require(
7814 details.iter().all(|detail| !detail.contains("SCAN graph_")),
7815 &format!("affected footprint scanned graph storage: {details:?}"),
7816 )?;
7817 Ok(())
7818 }
7819
7820 #[test]
7821 fn affected_source_footprint_reports_high_degree_overflow_before_mutation()
7822 -> Result<(), Box<dyn Error>> {
7823 let temp = tempfile::tempdir()?;
7824 let project_root = temp.path().join("affected-source-degree");
7825 fs::create_dir_all(&project_root)?;
7826 let db_path = project_root.join("projectatlas.db");
7827 let mut store = AtlasStore::open_for_project(&db_path, &project_root)?;
7828 let fixture = publish_resolution_fixture(&mut store, "affected-source-degree")?;
7829 replace_fixture_symbol_rows(&mut store, "src/Äuth.rs", 25)?;
7830 let before = store.index_publication()?;
7831
7832 let bounded = store.repository_affected_source_footprint(
7833 fixture.graph.project,
7834 &["src/Äuth.rs".to_string()],
7835 5,
7836 )?;
7837 require_eq(&bounded.rows, &6, "footprint limit plus one sentinel")?;
7838 require_eq(&bounded.truncated, &true, "high-degree footprint overflow")?;
7839 require(
7840 bounded.retained_bytes > 0,
7841 "bounded footprint lost retained bytes",
7842 )?;
7843 require_eq(
7844 &store.index_publication()?,
7845 &before,
7846 "footprint overflow mutated publication",
7847 )?;
7848 Ok(())
7849 }
7850
7851 #[test]
7852 fn affected_source_footprint_validates_inputs_identity_and_graph_availability()
7853 -> Result<(), Box<dyn Error>> {
7854 let temp = tempfile::tempdir()?;
7855 let selected_root = temp.path().join("selected");
7856 let other_root = temp.path().join("other");
7857 fs::create_dir_all(&selected_root)?;
7858 fs::create_dir_all(&other_root)?;
7859 let mut selected =
7860 AtlasStore::open_for_project(&selected_root.join("projectatlas.db"), &selected_root)?;
7861 let fixture = publish_resolution_fixture(&mut selected, "footprint-validation")?;
7862 let other = AtlasStore::open_for_project(&other_root.join("projectatlas.db"), &other_root)?;
7863 let other_project = other
7864 .project_instance_id()?
7865 .ok_or_else(|| io::Error::other("other bound identity is missing"))?;
7866
7867 let invalid_limit = require_db_error(
7868 selected.repository_affected_source_footprint(
7869 fixture.graph.project,
7870 &["src/Äuth.rs".to_string()],
7871 0,
7872 ),
7873 "zero footprint limit was accepted",
7874 )?;
7875 require(
7876 matches!(invalid_limit, DbError::GraphContract(_)),
7877 &format!("invalid footprint limit returned {invalid_limit}"),
7878 )?;
7879 let invalid_path = require_db_error(
7880 selected.repository_affected_source_footprint(
7881 fixture.graph.project,
7882 &["../outside.rs".to_string()],
7883 10,
7884 ),
7885 "escaping footprint path was accepted",
7886 )?;
7887 require(
7888 matches!(invalid_path, DbError::GraphContract(_)),
7889 &format!("invalid footprint path returned {invalid_path}"),
7890 )?;
7891 let mismatched = require_db_error(
7892 selected.repository_affected_source_footprint(
7893 other_project,
7894 &["src/Äuth.rs".to_string()],
7895 10,
7896 ),
7897 "mismatched project footprint was accepted",
7898 )?;
7899 require(
7900 matches!(mismatched, DbError::GraphProjectIdentityMismatch { .. }),
7901 &format!("mismatched footprint returned {mismatched}"),
7902 )?;
7903 require_eq(
7904 &other.repository_affected_source_footprint(
7905 other_project,
7906 &["src/Äuth.rs".to_string()],
7907 10,
7908 )?,
7909 &empty_affected_source_footprint(),
7910 "unpublished graph footprint",
7911 )?;
7912 Ok(())
7913 }
7914
7915 #[test]
7916 fn affected_source_footprint_rejects_missing_resolution_witnesses() -> Result<(), Box<dyn Error>>
7917 {
7918 let temp = tempfile::tempdir()?;
7919 let project_root = temp.path().join("affected-source-corruption");
7920 fs::create_dir_all(&project_root)?;
7921 let db_path = project_root.join("projectatlas.db");
7922 let mut store = AtlasStore::open_for_project(&db_path, &project_root)?;
7923 let fixture = publish_resolution_fixture(&mut store, "affected-source-corruption")?;
7924 store
7925 .connection
7926 .execute_batch("PRAGMA foreign_keys = OFF")?;
7927 store.connection.execute(
7928 "DELETE FROM graph_resolution_keys
7929 WHERE project_instance_id = ?1
7930 AND resolution_domain = ?2
7931 AND key_digest = ?3",
7932 params![
7933 &fixture.graph.project.as_bytes()[..],
7934 fixture.resolved.domain().as_str(),
7935 &fixture.resolved.digest_bytes()[..],
7936 ],
7937 )?;
7938 let error = require_db_error(
7939 store.repository_affected_source_footprint(
7940 fixture.graph.project,
7941 &["src/Äuth.rs".to_string()],
7942 100,
7943 ),
7944 "missing resolution witness returned a partial footprint",
7945 )?;
7946 require(
7947 matches!(error, DbError::Sqlite(_)),
7948 &format!("missing witness returned the wrong error: {error}"),
7949 )?;
7950 Ok(())
7951 }
7952
7953 #[test]
7954 fn resolution_key_failures_roll_back_and_owner_foreign_keys_cascade()
7955 -> Result<(), Box<dyn Error>> {
7956 let temp = tempfile::tempdir()?;
7957 let project_root = temp.path().join("resolution-failures");
7958 fs::create_dir_all(&project_root)?;
7959 let db_path = project_root.join("projectatlas.db");
7960 let mut store = AtlasStore::open_for_project(&db_path, &project_root)?;
7961 let fixture = publish_resolution_fixture(&mut store, "resolution-failures")?;
7962 let before_publication = store.index_publication()?;
7963 let before_counts = store.connection.query_row(
7964 "SELECT
7965 (SELECT COUNT(*) FROM graph_resolution_keys),
7966 (SELECT COUNT(*) FROM graph_entity_exports),
7967 (SELECT COUNT(*) FROM graph_relation_dependencies)",
7968 [],
7969 |row| {
7970 Ok((
7971 row.get::<_, i64>(0)?,
7972 row.get::<_, i64>(1)?,
7973 row.get::<_, i64>(2)?,
7974 ))
7975 },
7976 )?;
7977
7978 let replacement = graph_fixture(fixture.graph.project, IndexGeneration::new(2))?;
7979 let invalid_export = EntityResolutionKey::new(
7980 replacement.entities[0].key().clone(),
7981 fixture.resolved.clone(),
7982 )?;
7983 {
7984 let mut publication = store.begin_index_publication("resolution-failures")?;
7985 let error = require_db_error(
7986 publication.replace_repository_graph_with_resolution_keys(
7987 replacement.project,
7988 &replacement.entities,
7989 &replacement.relations,
7990 &replacement.occurrences,
7991 &replacement.coverage,
7992 &[invalid_export],
7993 &[],
7994 ),
7995 "source-less export owner unexpectedly published",
7996 )?;
7997 require(
7998 matches!(error, DbError::GraphRowShape { .. }),
7999 &format!("invalid export owner returned the wrong error: {error}"),
8000 )?;
8001 }
8002 require_eq(
8003 &store.index_publication()?,
8004 &before_publication,
8005 "failed key publication generation",
8006 )?;
8007 let after_counts = store.connection.query_row(
8008 "SELECT
8009 (SELECT COUNT(*) FROM graph_resolution_keys),
8010 (SELECT COUNT(*) FROM graph_entity_exports),
8011 (SELECT COUNT(*) FROM graph_relation_dependencies)",
8012 [],
8013 |row| {
8014 Ok((
8015 row.get::<_, i64>(0)?,
8016 row.get::<_, i64>(1)?,
8017 row.get::<_, i64>(2)?,
8018 ))
8019 },
8020 )?;
8021 require_eq(
8022 &after_counts,
8023 &before_counts,
8024 "failed key publication durable rows",
8025 )?;
8026 require_eq(
8027 &store
8028 .repository_resolution_candidates(&fixture.resolved, 10)?
8029 .rows,
8030 &vec![fixture.graph.entities[4].clone()],
8031 "failed key publication previous candidates",
8032 )?;
8033
8034 store.connection.execute(
8035 "UPDATE graph_resolution_keys
8036 SET canonical_identity = 'conflicting collision witness'
8037 WHERE project_instance_id = ?1
8038 AND resolution_domain = ?2
8039 AND key_digest = ?3",
8040 params![
8041 &fixture.graph.project.as_bytes()[..],
8042 fixture.resolved.domain().as_str(),
8043 &fixture.resolved.digest_bytes()[..],
8044 ],
8045 )?;
8046 let collision = require_db_error(
8047 store.repository_resolution_candidates(&fixture.resolved, 10),
8048 "conflicting resolution witness was accepted",
8049 )?;
8050 require(
8051 matches!(collision, DbError::ResolutionKeyCollision { .. }),
8052 &format!("conflicting witness returned the wrong error: {collision}"),
8053 )?;
8054 let corrupt_page = require_db_error(
8055 store.repository_export_keys_for_paths(
8056 fixture.graph.project,
8057 &["src/Äuth.rs".to_string()],
8058 10,
8059 ),
8060 "corrupt witness returned a partial key page",
8061 )?;
8062 require(
8063 matches!(corrupt_page, DbError::GraphContract(_)),
8064 &format!("corrupt witness returned the wrong page error: {corrupt_page}"),
8065 )?;
8066 store.connection.execute(
8067 "UPDATE graph_resolution_keys
8068 SET canonical_identity = ?1
8069 WHERE project_instance_id = ?2
8070 AND resolution_domain = ?3
8071 AND key_digest = ?4",
8072 params![
8073 fixture.resolved.canonical_identity(),
8074 &fixture.graph.project.as_bytes()[..],
8075 fixture.resolved.domain().as_str(),
8076 &fixture.resolved.digest_bytes()[..],
8077 ],
8078 )?;
8079
8080 let relation_digest = fixture.graph.relations[2].key().digest_bytes()?;
8081 store.connection.execute(
8082 "DELETE FROM graph_relations
8083 WHERE project_instance_id = ?1 AND relation_key = ?2",
8084 params![&fixture.graph.project.as_bytes()[..], &relation_digest[..]],
8085 )?;
8086 let remaining_dependencies = store.connection.query_row(
8087 "SELECT COUNT(*) FROM graph_relation_dependencies",
8088 [],
8089 |row| row.get::<_, i64>(0),
8090 )?;
8091 require_eq(
8092 &remaining_dependencies,
8093 &(before_counts.2 - 1),
8094 "relation-owner dependency cascade",
8095 )?;
8096 let entity_digest = fixture.graph.entities[4].key().digest_bytes()?;
8097 store.connection.execute(
8098 "DELETE FROM graph_entities
8099 WHERE project_instance_id = ?1 AND entity_key = ?2",
8100 params![&fixture.graph.project.as_bytes()[..], &entity_digest[..]],
8101 )?;
8102 let remaining_exports =
8103 store
8104 .connection
8105 .query_row("SELECT COUNT(*) FROM graph_entity_exports", [], |row| {
8106 row.get::<_, i64>(0)
8107 })?;
8108 require_eq(&remaining_exports, &0, "entity-owner export cascade")?;
8109 Ok(())
8110 }
8111
8112 #[test]
8113 fn affected_replacement_swaps_resolution_keys_and_collects_only_touched_orphans()
8114 -> Result<(), Box<dyn Error>> {
8115 let temp = tempfile::tempdir()?;
8116 let project_root = temp.path().join("resolution-replacement");
8117 fs::create_dir_all(&project_root)?;
8118 let db_path = project_root.join("projectatlas.db");
8119 let mut store = AtlasStore::open_for_project(&db_path, &project_root)?;
8120 let first = publish_resolution_fixture(&mut store, "resolution-replacement")?;
8121 let replacement = graph_fixture(first.graph.project, IndexGeneration::new(2))?;
8122 let renamed = declaration_key(
8123 first.graph.project,
8124 "verifySession",
8125 GraphRelationKind::Legacy(RelationKind::Calls),
8126 )?;
8127 let exports = vec![EntityResolutionKey::new(
8128 replacement.entities[4].key().clone(),
8129 renamed.clone(),
8130 )?];
8131 let dependencies = vec![
8132 RelationDependencyKey::new(replacement.relations[0].key().clone(), renamed.clone())?,
8133 RelationDependencyKey::new(
8134 replacement.relations[1].key().clone(),
8135 first.ambiguous.clone(),
8136 )?,
8137 RelationDependencyKey::new(
8138 replacement.relations[2].key().clone(),
8139 first.unresolved.clone(),
8140 )?,
8141 ];
8142 let mut publication = store.begin_index_publication("resolution-replacement")?;
8143 publication.replace_repository_graph_for_paths_with_resolution_keys(
8144 replacement.project,
8145 &["src/Äuth.rs".to_string()],
8146 &replacement.entities,
8147 &replacement.relations,
8148 &replacement.occurrences,
8149 &replacement.coverage,
8150 &exports,
8151 &dependencies,
8152 )?;
8153 publication.complete()?;
8154
8155 require_eq(
8156 &store
8157 .repository_resolution_candidates(&first.resolved, 10)?
8158 .rows
8159 .len(),
8160 &0,
8161 "removed export candidates",
8162 )?;
8163 require_eq(
8164 &store
8165 .repository_affected_source_paths(
8166 first.graph.project,
8167 std::slice::from_ref(&first.resolved),
8168 10,
8169 )?
8170 .rows
8171 .len(),
8172 &0,
8173 "removed dependency owners",
8174 )?;
8175 require_eq(
8176 &store.repository_resolution_candidates(&renamed, 10)?.rows,
8177 &vec![replacement.entities[4].clone()],
8178 "renamed export candidates",
8179 )?;
8180 let exports = store.repository_export_keys_for_paths(
8181 first.graph.project,
8182 &["src/Äuth.rs".to_string()],
8183 10,
8184 )?;
8185 require_eq(&exports.rows, &vec![renamed], "replacement export keys")?;
8186 let registry_rows = store.connection.query_row(
8187 "SELECT COUNT(*) FROM graph_resolution_keys",
8188 [],
8189 |row| row.get::<_, i64>(0),
8190 )?;
8191 require_eq(®istry_rows, &3, "touched orphan witness cleanup")?;
8192 require_eq(
8193 &store
8194 .index_publication()?
8195 .ok_or_else(|| io::Error::other("replacement publication is missing"))?
8196 .generation,
8197 &IndexGeneration::new(2),
8198 "replacement generation",
8199 )?;
8200 Ok(())
8201 }
8202
8203 #[test]
8204 fn affected_graph_replacement_preserves_only_the_unaffected_closure()
8205 -> Result<(), Box<dyn Error>> {
8206 let temp = tempfile::tempdir()?;
8207 let project_root = temp.path().join("affected-closure");
8208 let atlas_dir = project_root.join(".projectatlas");
8209 fs::create_dir_all(&atlas_dir)?;
8210 let db_path = atlas_dir.join("projectatlas.db");
8211 let mut store = AtlasStore::open_for_project(&db_path, &project_root)?;
8212
8213 let project = store
8214 .project_instance_id()?
8215 .ok_or_else(|| io::Error::other("bound affected-closure identity is missing"))?;
8216 let generation_one = IndexGeneration::new(1);
8217 let project_entity = GraphEntity::new(project, EntitySelector::Project, generation_one)?;
8218 let affected_folder = GraphEntity::new(
8219 project,
8220 EntitySelector::Folder {
8221 path: RepositoryNodePath::new(Path::new("src/a"))?,
8222 },
8223 generation_one,
8224 )?;
8225 let affected_file = GraphEntity::new(
8226 project,
8227 EntitySelector::File {
8228 path: RepositoryFilePath::new(Path::new("src/a/local.rs"))?,
8229 },
8230 generation_one,
8231 )?;
8232 let case_distinct_file = GraphEntity::new(
8233 project,
8234 EntitySelector::File {
8235 path: RepositoryFilePath::new(Path::new("src/A/keep.rs"))?,
8236 },
8237 generation_one,
8238 )?;
8239 let package = GraphEntity::new(
8240 project,
8241 EntitySelector::Package {
8242 package: PackageSelector {
8243 manager: GraphIdentityText::new("cargo")?,
8244 name: GraphIdentityText::new("api")?,
8245 manifest: RepositoryFilePath::new(Path::new("packages/api/Cargo.toml"))?,
8246 },
8247 },
8248 generation_one,
8249 )?;
8250 let orphan_external = GraphEntity::new(
8251 project,
8252 EntitySelector::External {
8253 external: ExternalSelector {
8254 system: GraphIdentityText::new("crates.io")?,
8255 identity: GraphIdentityText::new("orphan@1")?,
8256 },
8257 },
8258 generation_one,
8259 )?;
8260 let retained_external = GraphEntity::new(
8261 project,
8262 EntitySelector::External {
8263 external: ExternalSelector {
8264 system: GraphIdentityText::new("crates.io")?,
8265 identity: GraphIdentityText::new("retained@1")?,
8266 },
8267 },
8268 generation_one,
8269 )?;
8270 let occurrence_owned_external = GraphEntity::new(
8271 project,
8272 EntitySelector::External {
8273 external: ExternalSelector {
8274 system: GraphIdentityText::new("crates.io")?,
8275 identity: GraphIdentityText::new("occurrence-owned@1")?,
8276 },
8277 },
8278 generation_one,
8279 )?;
8280 let affected_relation = LogicalRelation::new(
8281 &affected_file,
8282 GraphRelationKind::Legacy(RelationKind::DependsOn),
8283 RelationResolution::external(&orphan_external)?,
8284 ConfidenceClass::Exact,
8285 Completeness::Complete,
8286 generation_one,
8287 )?;
8288 let package_relation = LogicalRelation::new(
8289 &package,
8290 GraphRelationKind::Legacy(RelationKind::DependsOn),
8291 RelationResolution::external(&orphan_external)?,
8292 ConfidenceClass::Exact,
8293 Completeness::Complete,
8294 generation_one,
8295 )?;
8296 let retained_relation = LogicalRelation::new(
8297 &case_distinct_file,
8298 GraphRelationKind::Legacy(RelationKind::DependsOn),
8299 RelationResolution::external(&retained_external)?,
8300 ConfidenceClass::Exact,
8301 Completeness::Complete,
8302 generation_one,
8303 )?;
8304 let project_external_relation = LogicalRelation::new(
8305 &project_entity,
8306 GraphRelationKind::Legacy(RelationKind::DependsOn),
8307 RelationResolution::external(&retained_external)?,
8308 ConfidenceClass::Exact,
8309 Completeness::Complete,
8310 generation_one,
8311 )?;
8312 let occurrence_backed_project_relation = LogicalRelation::new(
8313 &project_entity,
8314 GraphRelationKind::Legacy(RelationKind::DependsOn),
8315 RelationResolution::external(&occurrence_owned_external)?,
8316 ConfidenceClass::Exact,
8317 Completeness::Complete,
8318 generation_one,
8319 )?;
8320 let affected_occurrence = RelationOccurrence::new(
8321 &affected_relation,
8322 RepositoryFilePath::new(Path::new("src/a/local.rs"))?,
8323 SourceSpan::new(3, 0, 3, 12)?,
8324 generation_one,
8325 )?;
8326 let retained_occurrence = RelationOccurrence::new(
8327 &retained_relation,
8328 RepositoryFilePath::new(Path::new("src/A/keep.rs"))?,
8329 SourceSpan::new(5, 0, 5, 14)?,
8330 generation_one,
8331 )?;
8332 let retained_relation_affected_occurrence = RelationOccurrence::new(
8333 &retained_relation,
8334 RepositoryFilePath::new(Path::new("src/a/local.rs"))?,
8335 SourceSpan::new(6, 0, 6, 14)?,
8336 generation_one,
8337 )?;
8338 let project_relation_occurrence = RelationOccurrence::new(
8339 &occurrence_backed_project_relation,
8340 RepositoryFilePath::new(Path::new("src/a/local.rs"))?,
8341 SourceSpan::new(9, 0, 9, 16)?,
8342 generation_one,
8343 )?;
8344 let initial_coverage = vec![
8345 CoverageRecord::new(
8346 CoverageScope::Project,
8347 None,
8348 CoverageState::Complete,
8349 4,
8350 0,
8351 generation_one,
8352 None,
8353 None,
8354 )?,
8355 CoverageRecord::new(
8356 CoverageScope::Path {
8357 path: RepositoryNodePath::new(Path::new("src/a"))?,
8358 },
8359 None,
8360 CoverageState::Partial,
8361 1,
8362 1,
8363 generation_one,
8364 Some(GraphIdentityText::new("affected coverage")?),
8365 Some(GraphLimitKind::Rows),
8366 )?,
8367 CoverageRecord::new(
8368 CoverageScope::Path {
8369 path: RepositoryNodePath::new(Path::new("src/A"))?,
8370 },
8371 None,
8372 CoverageState::Complete,
8373 1,
8374 0,
8375 generation_one,
8376 None,
8377 None,
8378 )?,
8379 ];
8380 {
8381 let mut publication = store.begin_index_publication("affected-closure")?;
8382 publication.begin_scan_replacement()?;
8383 publication.upsert_scan_node_batch(&[
8384 graph_node(".", NodeKind::Folder, None),
8385 graph_node("src", NodeKind::Folder, Some(".")),
8386 graph_node("src/a", NodeKind::Folder, Some("src")),
8387 graph_node("src/a/local.rs", NodeKind::File, Some("src/a")),
8388 graph_node("src/a/new.rs", NodeKind::File, Some("src/a")),
8389 graph_node("src/A", NodeKind::Folder, Some("src")),
8390 graph_node("src/A/keep.rs", NodeKind::File, Some("src/A")),
8391 graph_node("packages", NodeKind::Folder, Some(".")),
8392 graph_node("packages/api", NodeKind::Folder, Some("packages")),
8393 graph_node(
8394 "packages/api/Cargo.toml",
8395 NodeKind::File,
8396 Some("packages/api"),
8397 ),
8398 graph_node("README.md", NodeKind::File, Some(".")),
8399 ])?;
8400 publication.finish_scan_replacement()?;
8401 publication.replace_repository_graph(
8402 project,
8403 &[
8404 project_entity.clone(),
8405 affected_folder,
8406 affected_file.clone(),
8407 case_distinct_file.clone(),
8408 package.clone(),
8409 orphan_external.clone(),
8410 retained_external.clone(),
8411 occurrence_owned_external.clone(),
8412 ],
8413 &[
8414 affected_relation,
8415 package_relation,
8416 retained_relation,
8417 project_external_relation,
8418 occurrence_backed_project_relation,
8419 ],
8420 &[
8421 affected_occurrence,
8422 retained_occurrence,
8423 retained_relation_affected_occurrence,
8424 project_relation_occurrence,
8425 ],
8426 &initial_coverage,
8427 )?;
8428 publication.complete()?;
8429 }
8430
8431 let affected_paths = [
8432 RepositoryNodePath::new(Path::new("src/a"))?,
8433 RepositoryNodePath::new(Path::new("packages/api/Cargo.toml"))?,
8434 ];
8435 let (candidates, statements) = trace_statements(&mut store, |store| {
8436 affected_external_candidates(&store.connection, &affected_paths)
8437 })?;
8438 require(
8439 candidates.contains(&orphan_external.key().digest_bytes()?),
8440 "batched external cleanup omitted an affected candidate",
8441 )?;
8442 require_eq(
8443 &statements
8444 .iter()
8445 .filter(|statement| {
8446 statement.contains("WITH affected(entity_key)")
8447 && statement.contains("idx_graph_relations_source_kind")
8448 && statement.contains("idx_graph_relations_target_kind")
8449 })
8450 .count(),
8451 &1,
8452 "bounded external candidate adjacency batches",
8453 )?;
8454
8455 let generation_two = IndexGeneration::new(2);
8456 let replacement_folder = GraphEntity::new(
8457 project,
8458 EntitySelector::Folder {
8459 path: RepositoryNodePath::new(Path::new("src/a"))?,
8460 },
8461 generation_two,
8462 )?;
8463 let replacement_file = GraphEntity::new(
8464 project,
8465 EntitySelector::File {
8466 path: RepositoryFilePath::new(Path::new("src/a/new.rs"))?,
8467 },
8468 generation_two,
8469 )?;
8470 let retained_external_for_relation = GraphEntity::new(
8471 project,
8472 retained_external.selector().clone(),
8473 generation_two,
8474 )?;
8475 let replacement_relation = LogicalRelation::new(
8476 &replacement_file,
8477 GraphRelationKind::Legacy(RelationKind::DependsOn),
8478 RelationResolution::external(&retained_external_for_relation)?,
8479 ConfidenceClass::Exact,
8480 Completeness::Complete,
8481 generation_two,
8482 )?;
8483 let replacement_occurrence = RelationOccurrence::new(
8484 &replacement_relation,
8485 RepositoryFilePath::new(Path::new("src/a/new.rs"))?,
8486 SourceSpan::new(7, 0, 7, 10)?,
8487 generation_two,
8488 )?;
8489 let replacement_coverage = CoverageRecord::new(
8490 CoverageScope::Path {
8491 path: RepositoryNodePath::new(Path::new("src/a"))?,
8492 },
8493 None,
8494 CoverageState::Complete,
8495 1,
8496 0,
8497 generation_two,
8498 None,
8499 None,
8500 )?;
8501 {
8502 let mut publication = store.begin_index_publication("affected-closure")?;
8503 publication.replace_repository_graph_for_paths(
8504 project,
8505 &["src/a".to_string(), "packages/api/Cargo.toml".to_string()],
8506 &[replacement_folder, replacement_file.clone()],
8507 &[replacement_relation],
8508 &[replacement_occurrence],
8509 &[replacement_coverage],
8510 )?;
8511 publication.complete()?;
8512 }
8513
8514 drop(store);
8515 let store = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
8516
8517 require_eq(
8518 &store.repository_graph_entity(affected_file.key())?,
8519 &None,
8520 "affected descendant removal",
8521 )?;
8522 require_eq(
8523 &store.repository_graph_entity(package.key())?,
8524 &None,
8525 "manifest-owned package removal",
8526 )?;
8527 require_eq(
8528 &store.repository_graph_entity(orphan_external.key())?,
8529 &None,
8530 "candidate-bounded orphan external cleanup",
8531 )?;
8532 require_eq(
8533 &store.repository_graph_entity(occurrence_owned_external.key())?,
8534 &None,
8535 "final affected occurrence relation and external cleanup",
8536 )?;
8537 let preserved_case = store
8538 .repository_graph_entity(case_distinct_file.key())?
8539 .ok_or_else(|| io::Error::other("case-distinct sibling was removed"))?;
8540 require_eq(
8541 &preserved_case.generation(),
8542 &generation_two,
8543 "case-distinct sibling generation injection",
8544 )?;
8545 let preserved_external = store
8546 .repository_graph_entity(retained_external.key())?
8547 .ok_or_else(|| io::Error::other("referenced external entity was removed"))?;
8548 require_eq(
8549 &preserved_external.generation(),
8550 &generation_two,
8551 "unaffected external generation injection",
8552 )?;
8553 let retained_relations = store.repository_graph_relations(
8554 RepositoryGraphRelationQuery::Outbound {
8555 source: case_distinct_file.key().clone(),
8556 },
8557 10,
8558 )?;
8559 require_eq(
8560 &retained_relations.rows.len(),
8561 &1,
8562 "relation with one unaffected occurrence",
8563 )?;
8564 require_eq(
8565 &store
8566 .repository_graph_occurrences(&retained_relations.rows[0], 10)?
8567 .rows
8568 .len(),
8569 &1,
8570 "only the affected occurrence was removed",
8571 )?;
8572 let replacement_relations = store.repository_graph_relations(
8573 RepositoryGraphRelationQuery::Outbound {
8574 source: replacement_file.key().clone(),
8575 },
8576 10,
8577 )?;
8578 require_eq(
8579 &replacement_relations.rows.len(),
8580 &1,
8581 "replacement relation count",
8582 )?;
8583 require_eq(
8584 &store
8585 .repository_graph_occurrences(&replacement_relations.rows[0], 10)?
8586 .rows
8587 .len(),
8588 &1,
8589 "replacement source occurrence",
8590 )?;
8591 let affected_coverage = store.repository_graph_coverage(
8592 project,
8593 &CoverageScope::Path {
8594 path: RepositoryNodePath::new(Path::new("src/a"))?,
8595 },
8596 10,
8597 )?;
8598 require(
8599 affected_coverage.rows.len() == 1
8600 && affected_coverage.rows[0].state() == CoverageState::Complete,
8601 "affected path coverage was not replaced",
8602 )?;
8603 let case_coverage = store.repository_graph_coverage(
8604 project,
8605 &CoverageScope::Path {
8606 path: RepositoryNodePath::new(Path::new("src/A"))?,
8607 },
8608 10,
8609 )?;
8610 require_eq(
8611 &case_coverage.rows.len(),
8612 &1,
8613 "case-distinct coverage preservation",
8614 )?;
8615 require_eq(
8616 &store
8617 .repository_graph_coverage(project, &CoverageScope::Project, 10)?
8618 .rows
8619 .len(),
8620 &1,
8621 "unaffected project coverage preservation",
8622 )?;
8623 require_eq(
8624 &store
8625 .repository_graph_relations(
8626 RepositoryGraphRelationQuery::Outbound {
8627 source: project_entity.key().clone(),
8628 },
8629 10,
8630 )?
8631 .rows
8632 .len(),
8633 &1,
8634 "project-to-external relation preservation",
8635 )?;
8636
8637 store.finish_index_read_snapshot()?;
8638 drop(store);
8639 let mut store = AtlasStore::open_for_project(&db_path, &project_root)?;
8640 let generation_three = IndexGeneration::new(3);
8641 let root_project = GraphEntity::new(project, EntitySelector::Project, generation_three)?;
8642 let readme = GraphEntity::new(
8643 project,
8644 EntitySelector::File {
8645 path: RepositoryFilePath::new(Path::new("README.md"))?,
8646 },
8647 generation_three,
8648 )?;
8649 let root_coverage = CoverageRecord::new(
8650 CoverageScope::Project,
8651 None,
8652 CoverageState::Complete,
8653 1,
8654 0,
8655 generation_three,
8656 None,
8657 None,
8658 )?;
8659 {
8660 let mut publication = store.begin_index_publication("affected-closure")?;
8661 publication.replace_repository_graph_for_paths(
8662 project,
8663 &[".".to_string()],
8664 &[root_project.clone(), readme],
8665 &[],
8666 &[],
8667 &[root_coverage],
8668 )?;
8669 publication.complete()?;
8670 }
8671 drop(store);
8672 let store = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
8673 require_eq(
8674 &store.repository_graph_entity(case_distinct_file.key())?,
8675 &None,
8676 "root replacement stale local removal",
8677 )?;
8678 require_eq(
8679 &store.repository_graph_entity(retained_external.key())?,
8680 &None,
8681 "root replacement external removal",
8682 )?;
8683 require_eq(
8684 &store
8685 .repository_graph_relations(
8686 RepositoryGraphRelationQuery::Outbound {
8687 source: root_project.key().clone(),
8688 },
8689 10,
8690 )?
8691 .rows
8692 .len(),
8693 &0,
8694 "root replacement project relation removal",
8695 )?;
8696 require_eq(
8697 &store
8698 .repository_graph_coverage(project, &CoverageScope::Project, 10)?
8699 .rows
8700 .len(),
8701 &1,
8702 "root replacement project coverage",
8703 )?;
8704 require_eq(
8705 &store
8706 .repository_graph_coverage(
8707 project,
8708 &CoverageScope::Path {
8709 path: RepositoryNodePath::new(Path::new("src/A"))?,
8710 },
8711 10,
8712 )?
8713 .rows
8714 .len(),
8715 &0,
8716 "root replacement path coverage removal",
8717 )?;
8718
8719 store.finish_index_read_snapshot()?;
8720 drop(store);
8721 let mut store = AtlasStore::open_for_project(&db_path, &project_root)?;
8722 {
8723 let mut projection = store.begin_index_projection_refresh("affected-closure")?;
8724 projection.replace_file_texts_for_paths(
8725 &["README.md".to_string()],
8726 &[IndexedFileText {
8727 path: "README.md".to_string(),
8728 content_hash: Some("readme-hash".to_string()),
8729 byte_count: 7,
8730 line_count: 1,
8731 content: "# Atlas".to_string(),
8732 }],
8733 )?;
8734 projection.complete()?;
8735 }
8736 drop(store);
8737 let store = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
8738 let error = require_db_error(
8739 store.repository_graph_entity(root_project.key()),
8740 "non-graph publication blessed stale graph rows",
8741 )?;
8742 require(
8743 matches!(
8744 error,
8745 DbError::GraphRowShape {
8746 table: "project_identity",
8747 ..
8748 }
8749 ),
8750 &format!("unexpected stale graph generation error: {error}"),
8751 )?;
8752 store.finish_index_read_snapshot()?;
8753 Ok(())
8754 }
8755
8756 fn require_graph_projection(
8758 store: &AtlasStore,
8759 fixture: &GraphFixture,
8760 generation: IndexGeneration,
8761 lexical_content: &str,
8762 ) -> Result<(), Box<dyn Error>> {
8763 let publication = store
8764 .index_publication()?
8765 .ok_or_else(|| io::Error::other("graph publication metadata missing"))?;
8766 require_eq(
8767 &publication.state,
8768 &IndexPublicationState::Complete,
8769 "graph publication state",
8770 )?;
8771 require_eq(
8772 &publication.generation,
8773 &generation,
8774 "graph publication generation",
8775 )?;
8776 let source = fixture
8777 .entities
8778 .iter()
8779 .find(|entity| matches!(entity.selector(), EntitySelector::File { .. }))
8780 .ok_or_else(|| io::Error::other("source file fixture missing"))?;
8781 let entity = store
8782 .repository_graph_entity(source.key())?
8783 .ok_or_else(|| io::Error::other("source graph entity missing"))?;
8784 require_eq(&entity.generation(), &generation, "graph entity generation")?;
8785 let relations = store.repository_graph_relations(
8786 RepositoryGraphRelationQuery::Outbound {
8787 source: source.key().clone(),
8788 },
8789 10,
8790 )?;
8791 require_eq(&relations.rows.len(), &4, "graph relation count")?;
8792 require(
8793 relations
8794 .rows
8795 .iter()
8796 .all(|relation| relation.generation() == generation),
8797 "graph relation generation mismatch",
8798 )?;
8799 let calls = relations
8800 .rows
8801 .iter()
8802 .find(|relation| relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls))
8803 .ok_or_else(|| io::Error::other("call relation missing"))?;
8804 let occurrences = store.repository_graph_occurrences(calls, 10)?;
8805 require_eq(&occurrences.rows.len(), &2, "graph occurrence count")?;
8806 require(
8807 occurrences
8808 .rows
8809 .iter()
8810 .all(|occurrence| occurrence.generation() == generation),
8811 "graph occurrence generation mismatch",
8812 )?;
8813 let occurrence_pages = store.repository_graph_occurrence_pages(&relations.rows, 1, None)?;
8814 require_eq(
8815 &occurrence_pages.len(),
8816 &relations.rows.len(),
8817 "batched occurrence page count",
8818 )?;
8819 for (relation, page) in relations.rows.iter().zip(&occurrence_pages) {
8820 let is_calls = relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls);
8821 require_eq(
8822 &page.rows.len(),
8823 &usize::from(is_calls),
8824 "batched occurrence rows",
8825 )?;
8826 require_eq(&page.truncated, &is_calls, "batched occurrence truncation")?;
8827 }
8828 let coverage =
8829 store.repository_graph_coverage(fixture.project, &CoverageScope::Project, 10)?;
8830 require_eq(&coverage.rows.len(), &6, "graph coverage count")?;
8831 require(
8832 coverage
8833 .rows
8834 .iter()
8835 .all(|record| record.generation() == generation),
8836 "graph coverage generation mismatch",
8837 )?;
8838 let lexical = store
8839 .load_file_text("src/Äuth.rs")?
8840 .ok_or_else(|| io::Error::other("lexical source row missing"))?;
8841 require_eq(
8842 &lexical.content.as_str(),
8843 &lexical_content,
8844 "lexical source generation",
8845 )?;
8846 require_eq(
8847 &store.symbol_relation_count()?,
8848 &1,
8849 "legacy symbol relation compatibility",
8850 )?;
8851 Ok(())
8852 }
8853
8854 #[test]
8855 fn typed_graph_round_trips_through_bounded_indexed_queries() -> Result<(), Box<dyn Error>> {
8856 let temp = tempfile::tempdir()?;
8857 let project_root = temp.path().join("typed-graph");
8858 let atlas_dir = project_root.join(".projectatlas");
8859 fs::create_dir_all(&atlas_dir)?;
8860 let db_path = atlas_dir.join("projectatlas.db");
8861 let mut writer = AtlasStore::open_for_project(&db_path, &project_root)?;
8862 let fixture = publish_fixture(&mut writer, "typed-graph")?;
8863 drop(writer);
8864 let store = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
8865
8866 for expected in &fixture.entities {
8867 require_eq(
8868 &store.repository_graph_entity(expected.key())?,
8869 &Some(expected.clone()),
8870 "stable entity lookup",
8871 )?;
8872 }
8873
8874 let source_path = RepositoryNodePath::new(Path::new("src/Äuth.rs"))?;
8875 let truncated =
8876 store.repository_graph_entities_by_path(fixture.project, &source_path, 1)?;
8877 require(
8878 truncated.truncated && truncated.rows.len() == 1,
8879 "entity LIMIT + 1",
8880 )?;
8881 let path_rows =
8882 store.repository_graph_entities_by_path(fixture.project, &source_path, 10)?;
8883 require(
8884 !path_rows.truncated && path_rows.rows.len() == 2,
8885 "Unicode/case path lookup",
8886 )?;
8887 for (result, context) in [
8888 (
8889 store.repository_graph_entities_by_path(fixture.project, &source_path, 0),
8890 "zero entity page limit",
8891 ),
8892 (
8893 store.repository_graph_entities_by_path(
8894 fixture.project,
8895 &source_path,
8896 GraphLimits::MAX_ROWS + 1,
8897 ),
8898 "over-ceiling entity page limit",
8899 ),
8900 ] {
8901 let error = require_db_error(result, context)?;
8902 require(
8903 matches!(error, DbError::GraphContract(_)),
8904 &format!("unexpected {context} error: {error}"),
8905 )?;
8906 }
8907
8908 let source = fixture
8909 .entities
8910 .iter()
8911 .find(|entity| matches!(entity.selector(), EntitySelector::File { .. }))
8912 .ok_or_else(|| io::Error::other("source file fixture missing"))?;
8913 let outbound = store.repository_graph_relations(
8914 RepositoryGraphRelationQuery::Outbound {
8915 source: source.key().clone(),
8916 },
8917 10,
8918 )?;
8919 require_eq(&outbound.rows.len(), &4, "all resolution states")?;
8920 require(
8921 outbound.rows.iter().any(|relation| {
8922 matches!(relation.resolution(), RelationResolution::Resolved { .. })
8923 }) && outbound.rows.iter().any(|relation| {
8924 matches!(relation.resolution(), RelationResolution::Ambiguous { .. })
8925 }) && outbound.rows.iter().any(|relation| {
8926 matches!(relation.resolution(), RelationResolution::Unresolved { .. })
8927 }) && outbound.rows.iter().any(|relation| {
8928 matches!(relation.resolution(), RelationResolution::External { .. })
8929 }),
8930 "resolution variants did not round-trip",
8931 )?;
8932 let outbound_truncated = store.repository_graph_relations(
8933 RepositoryGraphRelationQuery::Outbound {
8934 source: source.key().clone(),
8935 },
8936 3,
8937 )?;
8938 require(
8939 outbound_truncated.truncated && outbound_truncated.rows.len() == 3,
8940 "relation LIMIT + 1",
8941 )?;
8942 for (limit, context) in [
8943 (0, "zero relation page limit"),
8944 (
8945 GraphLimits::MAX_ROWS + 1,
8946 "over-ceiling relation page limit",
8947 ),
8948 ] {
8949 let error = require_db_error(
8950 store.repository_graph_relations(
8951 RepositoryGraphRelationQuery::Outbound {
8952 source: source.key().clone(),
8953 },
8954 limit,
8955 ),
8956 context,
8957 )?;
8958 require(
8959 matches!(error, DbError::GraphContract(_)),
8960 &format!("unexpected {context} error: {error}"),
8961 )?;
8962 }
8963
8964 let symbol = fixture
8965 .entities
8966 .iter()
8967 .find(|entity| matches!(entity.selector(), EntitySelector::Symbol { .. }))
8968 .ok_or_else(|| io::Error::other("symbol fixture missing"))?;
8969 let inbound = store.repository_graph_relations(
8970 RepositoryGraphRelationQuery::Inbound {
8971 target: symbol.key().clone(),
8972 },
8973 10,
8974 )?;
8975 require_eq(&inbound.rows.len(), &1, "inbound relation lookup")?;
8976 let calls = store.repository_graph_relations(
8977 RepositoryGraphRelationQuery::Family {
8978 relation: GraphRelationKind::Legacy(RelationKind::Calls),
8979 },
8980 10,
8981 )?;
8982 require_eq(&calls.rows.len(), &1, "relation-family lookup")?;
8983 let occurrence_page = store.repository_graph_occurrences(&calls.rows[0], 1)?;
8984 require(
8985 occurrence_page.truncated && occurrence_page.rows.len() == 1,
8986 "occurrence LIMIT + 1",
8987 )?;
8988 let all_occurrences = store.repository_graph_occurrences(&calls.rows[0], 10)?;
8989 require_eq(
8990 &all_occurrences.rows.len(),
8991 &2,
8992 "logical relation occurrence retention",
8993 )?;
8994 let error = require_db_error(
8995 store.repository_graph_occurrences(&calls.rows[0], 0),
8996 "zero occurrence page limit was accepted",
8997 )?;
8998 require(
8999 matches!(error, DbError::GraphContract(_)),
9000 &format!("unexpected zero occurrence-limit error: {error}"),
9001 )?;
9002 let error = require_db_error(
9003 store.repository_graph_occurrences(&calls.rows[0], GraphLimits::MAX_OCCURRENCES + 1),
9004 "over-ceiling occurrence page was accepted",
9005 )?;
9006 require(
9007 matches!(error, DbError::GraphContract(_)),
9008 &format!("unexpected occurrence-limit error: {error}"),
9009 )?;
9010
9011 let project_coverage =
9012 store.repository_graph_coverage(fixture.project, &CoverageScope::Project, 10)?;
9013 let path_coverage = store.repository_graph_coverage(
9014 fixture.project,
9015 &CoverageScope::Path {
9016 path: RepositoryNodePath::new(Path::new("src"))?,
9017 },
9018 10,
9019 )?;
9020 require_eq(&project_coverage.rows.len(), &6, "project coverage states")?;
9021 require_eq(&path_coverage.rows.len(), &1, "path coverage state")?;
9022 require(
9023 path_coverage.rows[0].state() == CoverageState::Partial,
9024 "partial coverage did not round-trip",
9025 )?;
9026 for (limit, context) in [
9027 (0, "zero coverage page limit"),
9028 (
9029 GraphLimits::MAX_ROWS + 1,
9030 "over-ceiling coverage page limit",
9031 ),
9032 ] {
9033 let error = require_db_error(
9034 store.repository_graph_coverage(fixture.project, &CoverageScope::Project, limit),
9035 context,
9036 )?;
9037 require(
9038 matches!(error, DbError::GraphContract(_)),
9039 &format!("unexpected {context} error: {error}"),
9040 )?;
9041 }
9042
9043 let source_next = GraphEntity::new(
9044 fixture.project,
9045 source.selector().clone(),
9046 IndexGeneration::new(2),
9047 )?;
9048 let symbol_next = GraphEntity::new(
9049 fixture.project,
9050 symbol.selector().clone(),
9051 IndexGeneration::new(2),
9052 )?;
9053 let next_generation_call = LogicalRelation::new(
9054 &source_next,
9055 GraphRelationKind::Legacy(RelationKind::Calls),
9056 RelationResolution::resolved(&symbol_next)?,
9057 ConfidenceClass::Exact,
9058 Completeness::Complete,
9059 IndexGeneration::new(2),
9060 )?;
9061 let error = require_db_error(
9062 store.repository_graph_occurrences(&next_generation_call, 10),
9063 "generation-mismatched occurrence request was accepted",
9064 )?;
9065 require(
9066 matches!(error, DbError::GraphContract(_)),
9067 &format!("unexpected occurrence generation error: {error}"),
9068 )?;
9069
9070 let lexical = store
9071 .load_file_text("src/Äuth.rs")?
9072 .ok_or_else(|| io::Error::other("lexical source row missing"))?;
9073 require_eq(
9074 &lexical.content,
9075 &"fn verifyToken()".to_string(),
9076 "lexical owner",
9077 )?;
9078 require_eq(
9079 &store.symbol_relation_count()?,
9080 &1,
9081 "legacy relation projection changed",
9082 )?;
9083
9084 store.finish_index_read_snapshot()?;
9085 drop(store);
9086 let mut writer = AtlasStore::open_for_project(&db_path, &project_root)?;
9087 let mut publication = writer.begin_index_publication("typed-graph")?;
9088 publication.replace_repository_graph_for_paths(
9089 fixture.project,
9090 &["src/unrelated.rs".to_string()],
9091 &[],
9092 &[],
9093 &[],
9094 &[],
9095 )?;
9096 publication.complete()?;
9097 drop(writer);
9098 let store = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
9099 let reused = store
9100 .repository_graph_entity(source.key())?
9101 .ok_or_else(|| io::Error::other("unchanged entity disappeared"))?;
9102 require_eq(
9103 &reused.generation(),
9104 &IndexGeneration::new(2),
9105 "unchanged graph row generation injection",
9106 )?;
9107 for expected in &fixture.entities {
9108 let reused = store
9109 .repository_graph_entity(expected.key())?
9110 .ok_or_else(|| io::Error::other("unchanged graph entity disappeared"))?;
9111 require_eq(
9112 &reused.generation(),
9113 &IndexGeneration::new(2),
9114 "incremental graph row reuse",
9115 )?;
9116 }
9117 assert_query_indexes(&store)?;
9118 store.finish_index_read_snapshot()?;
9119 Ok(())
9120 }
9121
9122 #[test]
9123 fn batched_adjacency_uses_direction_owned_indexes_and_stable_keysets()
9124 -> Result<(), Box<dyn Error>> {
9125 let temp = tempfile::tempdir()?;
9126 let project_root = temp.path().join("batched-adjacency");
9127 let atlas_dir = project_root.join(".projectatlas");
9128 fs::create_dir_all(&atlas_dir)?;
9129 let db_path = atlas_dir.join("projectatlas.db");
9130 let mut writer = AtlasStore::open_for_project(&db_path, &project_root)?;
9131 let fixture = publish_fixture(&mut writer, "batched-adjacency")?;
9132 drop(writer);
9133 let store = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
9134
9135 let source = fixture
9136 .entities
9137 .iter()
9138 .find(|entity| matches!(entity.selector(), EntitySelector::File { .. }))
9139 .ok_or_else(|| io::Error::other("source file fixture missing"))?;
9140 let symbol = fixture
9141 .entities
9142 .iter()
9143 .find(|entity| matches!(entity.selector(), EntitySelector::Symbol { .. }))
9144 .ok_or_else(|| io::Error::other("symbol fixture missing"))?;
9145 let external = fixture
9146 .entities
9147 .iter()
9148 .find(|entity| matches!(entity.selector(), EntitySelector::External { .. }))
9149 .ok_or_else(|| io::Error::other("external fixture missing"))?;
9150 let outbound_frontier = vec![source.key().clone()];
9151
9152 let first = store.repository_graph_adjacency_page(
9153 &outbound_frontier,
9154 RepositoryGraphDirection::Outbound,
9155 None,
9156 2,
9157 None,
9158 )?;
9159 require(
9160 first.truncated && first.rows.len() == 2 && first.continuation.is_some(),
9161 "outbound adjacency did not retain its LIMIT + 1 keyset",
9162 )?;
9163 require(
9164 first.rows.iter().all(|row| {
9165 row.frontier_index == 0
9166 && row.frontier == source.key().clone()
9167 && row.direction == RepositoryGraphDirection::Outbound
9168 }),
9169 "outbound adjacency lost its selecting frontier",
9170 )?;
9171 let continuation = first
9172 .continuation
9173 .clone()
9174 .ok_or_else(|| io::Error::other("outbound continuation missing"))?;
9175 let second = store.repository_graph_adjacency_page(
9176 &outbound_frontier,
9177 RepositoryGraphDirection::Outbound,
9178 Some(&continuation),
9179 10,
9180 None,
9181 )?;
9182 require(
9183 !second.truncated && second.rows.len() == 2 && second.continuation.is_none(),
9184 "outbound continuation did not finish the stable relation order",
9185 )?;
9186 let ordinary = store.repository_graph_relations(
9187 RepositoryGraphRelationQuery::Outbound {
9188 source: source.key().clone(),
9189 },
9190 10,
9191 )?;
9192 let combined = first
9193 .rows
9194 .into_iter()
9195 .chain(second.rows)
9196 .map(|row| row.detail.relation)
9197 .collect::<Vec<_>>();
9198 require_eq(
9199 &combined,
9200 &ordinary.rows,
9201 "adjacency keyset order versus ordinary relation order",
9202 )?;
9203 let calls = GraphRelationKind::Legacy(RelationKind::Calls);
9204 let filtered = store.repository_graph_adjacency_page_filtered(
9205 &outbound_frontier,
9206 RepositoryGraphDirection::Outbound,
9207 Some(calls),
9208 None,
9209 10,
9210 None,
9211 )?;
9212 let expected_calls = ordinary
9213 .rows
9214 .iter()
9215 .filter(|relation| relation.kind() == calls)
9216 .cloned()
9217 .collect::<Vec<_>>();
9218 require_eq(
9219 &filtered
9220 .rows
9221 .iter()
9222 .map(|row| row.detail.relation.clone())
9223 .collect::<Vec<_>>(),
9224 &expected_calls,
9225 "filtered adjacency versus ordinary family selection",
9226 )?;
9227 require(
9228 !filtered.truncated && filtered.continuation.is_none(),
9229 "filtered adjacency unexpectedly truncated its complete family",
9230 )?;
9231
9232 let inbound_frontier = vec![symbol.key().clone(), external.key().clone()];
9233 let inbound = store.repository_graph_adjacency_page(
9234 &inbound_frontier,
9235 RepositoryGraphDirection::Inbound,
9236 None,
9237 10,
9238 None,
9239 )?;
9240 require(
9241 !inbound.truncated
9242 && inbound.rows.len() == 2
9243 && inbound.rows[0].frontier_index == 0
9244 && inbound.rows[0].frontier == symbol.key().clone()
9245 && inbound.rows[1].frontier_index == 1
9246 && inbound.rows[1].frontier == external.key().clone()
9247 && inbound
9248 .rows
9249 .iter()
9250 .all(|row| row.direction == RepositoryGraphDirection::Inbound),
9251 "inbound adjacency did not preserve bounded frontier order",
9252 )?;
9253
9254 for (direction, expected_index) in [
9255 (
9256 RepositoryGraphDirection::Outbound,
9257 "idx_graph_relations_source_kind",
9258 ),
9259 (
9260 RepositoryGraphDirection::Inbound,
9261 "idx_graph_relations_target_kind",
9262 ),
9263 ] {
9264 for continuation_index in [None, Some(0)] {
9265 let sql = format!(
9266 "EXPLAIN QUERY PLAN {}",
9267 adjacency_relation_sql(2, direction, continuation_index, false)
9268 );
9269 let mut statement = store.connection.prepare(&sql)?;
9270 let mut bindings = vec![Value::Blob(fixture.project.as_bytes().to_vec())];
9271 bindings.push(Value::Blob(source.key().digest_bytes()?.to_vec()));
9272 if continuation_index.is_some() {
9273 bindings.extend([
9274 Value::Text(String::new()),
9275 Value::Text(String::new()),
9276 Value::Blob(vec![0; 32]),
9277 ]);
9278 }
9279 bindings.extend([
9280 Value::Integer(11),
9281 Value::Blob(symbol.key().digest_bytes()?.to_vec()),
9282 Value::Integer(11),
9283 Value::Integer(11),
9284 ]);
9285 let details = statement
9286 .query_map(params_from_iter(bindings.iter()), |row| {
9287 row.get::<_, String>(3)
9288 })?
9289 .collect::<Result<Vec<_>, _>>()?;
9290 require(
9291 details.iter().any(|detail| detail.contains(expected_index))
9292 && details
9293 .iter()
9294 .all(|detail| !detail.contains("SCAN relation")),
9295 &format!(
9296 "{direction:?} adjacency plan (continuation_index={continuation_index:?}) did not own its index: {details:?}"
9297 ),
9298 )?;
9299 }
9300
9301 let (scope, kind) = relation_parts(calls);
9302 let filtered_sql = format!(
9303 "EXPLAIN QUERY PLAN {}",
9304 adjacency_relation_sql(2, direction, None, true)
9305 );
9306 let mut filtered_statement = store.connection.prepare(&filtered_sql)?;
9307 let filtered_bindings = [
9308 Value::Blob(fixture.project.as_bytes().to_vec()),
9309 Value::Blob(source.key().digest_bytes()?.to_vec()),
9310 Value::Text(scope.to_string()),
9311 Value::Text(kind.to_string()),
9312 Value::Integer(11),
9313 Value::Blob(symbol.key().digest_bytes()?.to_vec()),
9314 Value::Text(scope.to_string()),
9315 Value::Text(kind.to_string()),
9316 Value::Integer(11),
9317 Value::Integer(11),
9318 ];
9319 let filtered_details = filtered_statement
9320 .query_map(params_from_iter(filtered_bindings.iter()), |row| {
9321 row.get::<_, String>(3)
9322 })?
9323 .collect::<Result<Vec<_>, _>>()?;
9324 require(
9325 filtered_details
9326 .iter()
9327 .any(|detail| detail.contains(expected_index))
9328 && filtered_details
9329 .iter()
9330 .all(|detail| !detail.contains("SCAN relation")),
9331 &format!(
9332 "filtered {direction:?} adjacency plan did not own its index: {filtered_details:?}"
9333 ),
9334 )?;
9335 }
9336
9337 let duplicate_error = require_db_error(
9338 store.repository_graph_adjacency_page(
9339 &[source.key().clone(), source.key().clone()],
9340 RepositoryGraphDirection::Outbound,
9341 None,
9342 1,
9343 None,
9344 ),
9345 "duplicate adjacency frontier was accepted",
9346 )?;
9347 require(
9348 matches!(duplicate_error, DbError::GraphContract(_)),
9349 &format!("unexpected duplicate-frontier error: {duplicate_error}"),
9350 )?;
9351 let oversized = vec![source.key().clone(); MAX_REPOSITORY_GRAPH_FRONTIER + 1];
9352 let oversized_error = require_db_error(
9353 store.repository_graph_adjacency_page(
9354 &oversized,
9355 RepositoryGraphDirection::Outbound,
9356 None,
9357 1,
9358 None,
9359 ),
9360 "oversized adjacency frontier was accepted",
9361 )?;
9362 require(
9363 matches!(oversized_error, DbError::GraphContract(_)),
9364 &format!("unexpected oversized-frontier error: {oversized_error}"),
9365 )?;
9366 let foreign = GraphEntity::new(
9367 ProjectInstanceId::from_bytes([0x7f; 16])?,
9368 EntitySelector::Project,
9369 IndexGeneration::new(1),
9370 )?;
9371 let project_error = require_db_error(
9372 store.repository_graph_adjacency_page(
9373 &[source.key().clone(), foreign.key().clone()],
9374 RepositoryGraphDirection::Outbound,
9375 None,
9376 1,
9377 None,
9378 ),
9379 "mixed-project adjacency frontier was accepted",
9380 )?;
9381 require(
9382 matches!(project_error, DbError::GraphProjectIdentityMismatch { .. }),
9383 &format!("unexpected mixed-project error: {project_error}"),
9384 )?;
9385 let direction_error = require_db_error(
9386 store.repository_graph_adjacency_page(
9387 &outbound_frontier,
9388 RepositoryGraphDirection::Inbound,
9389 Some(&continuation),
9390 1,
9391 None,
9392 ),
9393 "cross-direction adjacency continuation was accepted",
9394 )?;
9395 require(
9396 matches!(direction_error, DbError::GraphContract(_)),
9397 &format!("unexpected continuation-direction error: {direction_error}"),
9398 )?;
9399 let mut filtered_continuation = continuation.clone();
9400 filtered_continuation.relation = Some(calls);
9401 let family_error = require_db_error(
9402 store.repository_graph_adjacency_page_filtered(
9403 &outbound_frontier,
9404 RepositoryGraphDirection::Outbound,
9405 Some(GraphRelationKind::Extended(
9406 ExtendedRelationKind::Configures,
9407 )),
9408 Some(&filtered_continuation),
9409 1,
9410 None,
9411 ),
9412 "cross-family adjacency continuation was accepted",
9413 )?;
9414 require(
9415 matches!(family_error, DbError::GraphContract(_)),
9416 &format!("unexpected continuation-family error: {family_error}"),
9417 )?;
9418 let frontier_error = require_db_error(
9419 store.repository_graph_adjacency_page(
9420 &[external.key().clone()],
9421 RepositoryGraphDirection::Outbound,
9422 Some(&continuation),
9423 1,
9424 None,
9425 ),
9426 "cross-frontier adjacency continuation was accepted",
9427 )?;
9428 require(
9429 matches!(frontier_error, DbError::GraphContract(_)),
9430 &format!("unexpected continuation-frontier error: {frontier_error}"),
9431 )?;
9432
9433 let inbound_first = store.repository_graph_adjacency_page(
9434 &inbound_frontier,
9435 RepositoryGraphDirection::Inbound,
9436 None,
9437 1,
9438 None,
9439 )?;
9440 let inbound_continuation = inbound_first
9441 .continuation
9442 .ok_or_else(|| io::Error::other("inbound continuation missing"))?;
9443 let reordered_frontier = vec![external.key().clone(), symbol.key().clone()];
9444 let reordered_error = require_db_error(
9445 store.repository_graph_adjacency_page(
9446 &reordered_frontier,
9447 RepositoryGraphDirection::Inbound,
9448 Some(&inbound_continuation),
9449 1,
9450 None,
9451 ),
9452 "reordered adjacency continuation frontier was accepted",
9453 )?;
9454 require(
9455 matches!(reordered_error, DbError::GraphContract(_)),
9456 &format!("unexpected reordered-frontier error: {reordered_error}"),
9457 )?;
9458 let empty_frontier_error = require_db_error(
9459 store.repository_graph_adjacency_page(
9460 &[],
9461 RepositoryGraphDirection::Inbound,
9462 Some(&inbound_continuation),
9463 1,
9464 None,
9465 ),
9466 "adjacency continuation without a frontier was accepted",
9467 )?;
9468 require(
9469 matches!(empty_frontier_error, DbError::GraphContract(_)),
9470 &format!("unexpected empty-frontier error: {empty_frontier_error}"),
9471 )?;
9472
9473 let mut foreign_project_continuation = continuation.clone();
9474 foreign_project_continuation.project = foreign.key().project();
9475 let continuation_project_error = require_db_error(
9476 store.repository_graph_adjacency_page(
9477 &outbound_frontier,
9478 RepositoryGraphDirection::Outbound,
9479 Some(&foreign_project_continuation),
9480 1,
9481 None,
9482 ),
9483 "cross-project adjacency continuation was accepted",
9484 )?;
9485 require(
9486 matches!(continuation_project_error, DbError::GraphContract(_)),
9487 &format!("unexpected continuation-project error: {continuation_project_error}"),
9488 )?;
9489 let mut stale_generation_continuation = continuation.clone();
9490 stale_generation_continuation.generation = stale_generation_continuation
9491 .generation
9492 .checked_next()
9493 .ok_or_else(|| io::Error::other("fixture generation overflowed"))?;
9494 let continuation_generation_error = require_db_error(
9495 store.repository_graph_adjacency_page(
9496 &outbound_frontier,
9497 RepositoryGraphDirection::Outbound,
9498 Some(&stale_generation_continuation),
9499 1,
9500 None,
9501 ),
9502 "cross-generation adjacency continuation was accepted",
9503 )?;
9504 require(
9505 matches!(continuation_generation_error, DbError::GraphContract(_)),
9506 &format!("unexpected continuation-generation error: {continuation_generation_error}"),
9507 )?;
9508
9509 let mut maximum_frontier = Vec::with_capacity(MAX_REPOSITORY_GRAPH_FRONTIER);
9510 for index in 0..MAX_REPOSITORY_GRAPH_FRONTIER {
9511 let entity = GraphEntity::new(
9512 fixture.project,
9513 EntitySelector::External {
9514 external: ExternalSelector {
9515 system: GraphIdentityText::new("work-envelope")?,
9516 identity: GraphIdentityText::new(format!("candidate-{index}"))?,
9517 },
9518 },
9519 continuation.generation,
9520 )?;
9521 maximum_frontier.push(entity.key().clone());
9522 }
9523 let bounded_work = store.repository_graph_adjacency_page(
9524 &maximum_frontier,
9525 RepositoryGraphDirection::Outbound,
9526 None,
9527 38,
9528 None,
9529 )?;
9530 require(
9531 bounded_work.rows.is_empty() && !bounded_work.truncated,
9532 "maximum adjacency frontier rejected work below the intermediate ceiling",
9533 )?;
9534 let excessive_work_error = require_db_error(
9535 store.repository_graph_adjacency_page(
9536 &maximum_frontier,
9537 RepositoryGraphDirection::Outbound,
9538 None,
9539 39,
9540 None,
9541 ),
9542 "adjacency intermediate work above the ceiling was accepted",
9543 )?;
9544 require(
9545 matches!(excessive_work_error, DbError::GraphContract(_)),
9546 &format!("unexpected intermediate-work error: {excessive_work_error}"),
9547 )?;
9548
9549 let successful_cancellation = projectatlas_core::IndexCancellation::new();
9550 let successful_control = IndexWorkControl::new(successful_cancellation.clone(), None);
9551 store.repository_graph_adjacency_page(
9552 &outbound_frontier,
9553 RepositoryGraphDirection::Outbound,
9554 None,
9555 10,
9556 Some(&successful_control),
9557 )?;
9558 successful_cancellation.cancel();
9559 require_eq(
9560 &store
9561 .connection
9562 .query_row("SELECT 1", [], |row| row.get::<_, i64>(0))?,
9563 &1,
9564 "cleared adjacency progress handler",
9565 )?;
9566
9567 let cancellation = projectatlas_core::IndexCancellation::new();
9568 cancellation.cancel();
9569 let control = IndexWorkControl::new(cancellation, None);
9570 let cancelled = store.repository_graph_adjacency_page(
9571 &outbound_frontier,
9572 RepositoryGraphDirection::Outbound,
9573 None,
9574 10,
9575 Some(&control),
9576 );
9577 require(
9578 matches!(
9579 cancelled,
9580 Err(DbError::IndexWork(
9581 projectatlas_core::IndexWorkFailure::Cancelled {
9582 stage: IndexWorkStage::RepositoryTraversal
9583 }
9584 ))
9585 ),
9586 "adjacency cancellation was not typed",
9587 )?;
9588 store.finish_index_read_snapshot()?;
9589 Ok(())
9590 }
9591
9592 #[test]
9593 fn detailed_relation_storage_statements_are_indexed_and_batch_bounded()
9594 -> Result<(), Box<dyn Error>> {
9595 use DetailedRelationTraceStatement::{
9596 ActiveGraphGeneration, AdjacencyRelations, ProjectIdentity, PublicationMetadata,
9597 PurposeOwners, RelationEntities, RelationOccurrences, RelationsByDigest,
9598 };
9599
9600 let temp = tempfile::tempdir()?;
9601 let project_root = temp.path().join("detailed-relation-storage");
9602 let atlas_dir = project_root.join(".projectatlas");
9603 fs::create_dir_all(&atlas_dir)?;
9604 let db_path = atlas_dir.join("projectatlas.db");
9605 let mut writer = AtlasStore::open_for_project(&db_path, &project_root)?;
9606 let fixture =
9607 publish_detailed_relation_trace_fixture(&mut writer, "detailed-relation-storage")?;
9608 drop(writer);
9609 let mut store = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
9610 let generation = store
9611 .repository_graph_generation()?
9612 .ok_or_else(|| io::Error::other("graph generation missing"))?;
9613 let budget = maximum_repository_graph_read_budget()?;
9614
9615 assert_cursor_hydration_indexes(&store)?;
9616
9617 let one_frontier = vec![fixture.source.key().clone()];
9618 let many_frontier = std::iter::once(fixture.source.key().clone())
9619 .chain(
9620 fixture
9621 .relations
9622 .iter()
9623 .filter_map(|relation| relation.resolution().resolved_target().cloned())
9624 .take(3),
9625 )
9626 .collect::<Vec<_>>();
9627 require_eq(
9628 &many_frontier.len(),
9629 &4,
9630 "multi-frontier trace fixture cardinality",
9631 )?;
9632 for (context, frontier) in [
9633 ("one-frontier adjacency", one_frontier),
9634 ("many-frontier adjacency", many_frontier),
9635 ] {
9636 let (page, statements) = trace_statements(&mut store, |store| {
9637 store.repository_graph_adjacency_page_bounded(
9638 &frontier,
9639 RepositoryGraphDirection::Outbound,
9640 None,
9641 4,
9642 budget,
9643 None,
9644 )
9645 })?;
9646 require(
9647 page.page.rows.len() == 4 && page.page.truncated,
9648 &format!("{context} did not exercise its bounded sentinel"),
9649 )?;
9650 require_traced_statement_multiset(
9651 &statements,
9652 &[
9653 (ProjectIdentity, 1),
9654 (PublicationMetadata, 1),
9655 (ActiveGraphGeneration, 1),
9656 (AdjacencyRelations, 1),
9657 (RelationEntities, 1),
9658 ],
9659 context,
9660 )?;
9661 }
9662
9663 let relation_digests = fixture
9664 .relations
9665 .iter()
9666 .map(|relation| relation.key().digest_bytes())
9667 .collect::<Result<Vec<_>, _>>()?;
9668 for (context, digests, endpoint_chunks) in [
9669 ("one-relation hydration", &relation_digests[..1], 1),
9670 (
9671 "128 unique endpoint hydration",
9672 &relation_digests[..GRAPH_ENTITY_HYDRATION_CHUNK - 1],
9673 1,
9674 ),
9675 (
9676 "129 unique endpoint hydration",
9677 &relation_digests[..GRAPH_ENTITY_HYDRATION_CHUNK],
9678 2,
9679 ),
9680 ("maximum relation hydration", relation_digests.as_slice(), 3),
9681 ] {
9682 let (relations, statements) = trace_statements(&mut store, |store| {
9683 store.repository_graph_relation_rows_by_digest(
9684 fixture.project,
9685 generation,
9686 digests,
9687 budget,
9688 None,
9689 )
9690 })?;
9691 require_eq(
9692 &relations.rows.len(),
9693 &digests.len(),
9694 &format!("{context} returned relation count"),
9695 )?;
9696 require_traced_statement_multiset(
9697 &statements,
9698 &[
9699 (ProjectIdentity, 1),
9700 (PublicationMetadata, 1),
9701 (ActiveGraphGeneration, 1),
9702 (RelationsByDigest, 1),
9703 (RelationEntities, endpoint_chunks),
9704 ],
9705 context,
9706 )?;
9707 }
9708
9709 for (context, relations) in [
9710 ("one-relation occurrences", &fixture.relations[..1]),
9711 (
9712 "maximum relation occurrence batch",
9713 fixture.relations.as_slice(),
9714 ),
9715 ] {
9716 let (pages, statements) = trace_statements(&mut store, |store| {
9717 store.repository_graph_occurrence_pages_bounded(relations, 1, budget, None)
9718 })?;
9719 require(
9720 pages.pages.len() == relations.len()
9721 && pages
9722 .pages
9723 .iter()
9724 .all(|page| page.rows.len() == 1 && page.truncated),
9725 &format!("{context} did not retain one row plus its occurrence sentinel"),
9726 )?;
9727 require_traced_statement_multiset(
9728 &statements,
9729 &[
9730 (ProjectIdentity, 1),
9731 (PublicationMetadata, 1),
9732 (ActiveGraphGeneration, 1),
9733 (RelationOccurrences, 1),
9734 ],
9735 context,
9736 )?;
9737 }
9738 let mut oversized_occurrence_batch = fixture.relations.clone();
9739 oversized_occurrence_batch.push(fixture.relations[0].clone());
9740 let oversized_occurrences = require_db_error(
9741 store.repository_graph_occurrence_pages_bounded(
9742 &oversized_occurrence_batch,
9743 1,
9744 budget,
9745 None,
9746 ),
9747 "oversized relation occurrence batch was accepted",
9748 )?;
9749 require(
9750 matches!(oversized_occurrences, DbError::GraphContract(_)),
9751 &format!("unexpected oversized occurrence error: {oversized_occurrences}"),
9752 )?;
9753
9754 let one_purpose_path = vec![".".to_string()];
9755 let one_purpose_chunk = std::iter::once(".".to_string())
9756 .chain(
9757 (1..crate::MAX_PURPOSE_CURATION_BATCH_ROWS)
9758 .map(|index| format!("missing/purpose-{index}.rs")),
9759 )
9760 .collect::<Vec<_>>();
9761 let two_purpose_chunks = one_purpose_chunk
9762 .iter()
9763 .cloned()
9764 .chain(std::iter::once("missing/purpose-overflow.rs".to_string()))
9765 .collect::<Vec<_>>();
9766 for (context, paths, expected) in [
9767 ("one purpose owner", one_purpose_path.as_slice(), 1),
9768 (
9769 "one full purpose-owner chunk",
9770 one_purpose_chunk.as_slice(),
9771 1,
9772 ),
9773 ("two purpose-owner chunks", two_purpose_chunks.as_slice(), 2),
9774 ] {
9775 let (_purposes, statements) = trace_statements(&mut store, |store| {
9776 store.load_purpose_owner_nodes_by_paths_controlled(
9777 fixture.project,
9778 generation,
9779 paths,
9780 budget,
9781 None,
9782 )
9783 })?;
9784 require_traced_statement_multiset(
9785 &statements,
9786 &[
9787 (ProjectIdentity, 1),
9788 (PublicationMetadata, 1),
9789 (ActiveGraphGeneration, 1),
9790 (PurposeOwners, expected),
9791 ],
9792 context,
9793 )?;
9794 }
9795
9796 store.finish_index_read_snapshot()?;
9797 Ok(())
9798 }
9799
9800 #[test]
9801 fn cursor_hydration_is_ordered_bounded_and_fail_closed() -> Result<(), Box<dyn Error>> {
9802 let temp = tempfile::tempdir()?;
9803 let project_root = temp.path().join("cursor-hydration");
9804 let atlas_dir = project_root.join(".projectatlas");
9805 fs::create_dir_all(&atlas_dir)?;
9806 let db_path = atlas_dir.join("projectatlas.db");
9807 let mut writer = AtlasStore::open_for_project(&db_path, &project_root)?;
9808 let fixture = publish_fixture(&mut writer, "cursor-hydration")?;
9809 writer.connection.execute(
9810 "INSERT INTO graph_relation_occurrences(
9811 relation_key, file_path, start_line, start_column, end_line, end_column
9812 ) VALUES(?1, 'Cargo.toml', 1, 0, 1, 1)",
9813 params![&fixture.relations[0].key().digest_bytes()?[..]],
9814 )?;
9815 writer.connection.execute(
9816 "INSERT INTO graph_coverage(
9817 project_instance_id, scope_kind, scope_path, relation_scope,
9818 relation_kind, state, total, covered, omitted, reason, reached_limit
9819 ) VALUES(?1, 'path', 'Cargo.toml', NULL, NULL,
9820 'complete', 1, 1, 0, NULL, NULL)",
9821 params![&fixture.project.as_bytes()[..]],
9822 )?;
9823 drop(writer);
9824 let store = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
9825 let generation = store
9826 .repository_graph_generation()?
9827 .ok_or_else(|| io::Error::other("graph generation missing"))?;
9828 let full_budget = RepositoryGraphReadBudget::new(
9829 MAX_REPOSITORY_GRAPH_FRONTIER as u32,
9830 MAX_REPOSITORY_GRAPH_FRONTIER as u32,
9831 RepositoryGraphReadBudget::MAX_DECODED_BYTES,
9832 RepositoryGraphReadBudget::MAX_HYDRATED_ENTITIES,
9833 RepositoryGraphReadBudget::MAX_HYDRATED_PATHS,
9834 )?;
9835
9836 let expected_entities = fixture
9837 .entities
9838 .iter()
9839 .rev()
9840 .take(3)
9841 .cloned()
9842 .collect::<Vec<_>>();
9843 let entity_digests = expected_entities
9844 .iter()
9845 .map(|entity| entity.key().digest_bytes())
9846 .collect::<Result<Vec<_>, _>>()?;
9847 let entity_batch = store.repository_graph_entities_by_digest(
9848 fixture.project,
9849 generation,
9850 &entity_digests,
9851 full_budget,
9852 None,
9853 )?;
9854 require_eq(
9855 &entity_batch.rows,
9856 &expected_entities,
9857 "ordered entity cursor hydration",
9858 )?;
9859 require_eq(
9860 &entity_batch.work,
9861 &RepositoryGraphReadWork {
9862 requested_rows: 3,
9863 returned_rows: 3,
9864 decoded_bytes: entity_batch.work.decoded_bytes,
9865 hydrated_entities: 3,
9866 hydrated_paths: 2,
9867 },
9868 "exact entity cursor work",
9869 )?;
9870 require(
9871 entity_batch.work.decoded_bytes > 0,
9872 "entity cursor decoded no SQLite payload bytes",
9873 )?;
9874
9875 let file_entity = fixture
9876 .entities
9877 .iter()
9878 .find(|entity| matches!(entity.selector(), EntitySelector::File { .. }))
9879 .ok_or_else(|| io::Error::other("file anchor fixture missing"))?;
9880 let file_anchor = store.repository_graph_entity_bounded(
9881 file_entity.key(),
9882 generation,
9883 full_budget,
9884 None,
9885 )?;
9886 require_eq(
9887 &file_anchor.rows,
9888 &vec![file_entity.clone()],
9889 "exact file anchor hydration",
9890 )?;
9891 require_eq(
9892 &file_anchor.work,
9893 &RepositoryGraphReadWork {
9894 requested_rows: 1,
9895 returned_rows: 1,
9896 decoded_bytes: file_anchor.work.decoded_bytes,
9897 hydrated_entities: 1,
9898 hydrated_paths: 1,
9899 },
9900 "exact file anchor work",
9901 )?;
9902 let exact_file_budget =
9903 RepositoryGraphReadBudget::new(1, 1, file_anchor.work.decoded_bytes, 1, 1)?;
9904 require_eq(
9905 &store.repository_graph_entity_bounded(
9906 file_entity.key(),
9907 generation,
9908 exact_file_budget,
9909 None,
9910 )?,
9911 &file_anchor,
9912 "exact file anchor envelope",
9913 )?;
9914 require_eq(
9915 &store.repository_graph_entity(file_entity.key())?,
9916 &Some(file_entity.clone()),
9917 "legacy file anchor wrapper compatibility",
9918 )?;
9919 let file_decode_overrun = require_db_error(
9920 store.repository_graph_entity_bounded(
9921 file_entity.key(),
9922 generation,
9923 RepositoryGraphReadBudget::new(1, 1, file_anchor.work.decoded_bytes - 1, 1, 1)?,
9924 None,
9925 ),
9926 "file anchor decoded-byte overrun was accepted",
9927 )?;
9928 require(
9929 matches!(file_decode_overrun, DbError::GraphContract(_)),
9930 &format!("unexpected file anchor envelope error: {file_decode_overrun}"),
9931 )?;
9932 let missing_file_selector = EntitySelector::File {
9933 path: RepositoryFilePath::new(Path::new("missing-anchor.rs"))?,
9934 };
9935 let missing_file_key = GraphEntityKey::new(fixture.project, &missing_file_selector);
9936 let missing_file = store.repository_graph_entity_bounded(
9937 &missing_file_key,
9938 generation,
9939 full_budget,
9940 None,
9941 )?;
9942 require(
9943 missing_file.rows.is_empty()
9944 && missing_file.work
9945 == (RepositoryGraphReadWork {
9946 requested_rows: 1,
9947 returned_rows: 0,
9948 decoded_bytes: 0,
9949 hydrated_entities: 0,
9950 hydrated_paths: 0,
9951 }),
9952 "missing file anchor did not return exact empty work",
9953 )?;
9954
9955 let anchor_path = RepositoryNodePath::new(Path::new("src/Äuth.rs"))?;
9956 let path_anchors = store.repository_graph_entities_by_path_bounded(
9957 fixture.project,
9958 generation,
9959 &anchor_path,
9960 1,
9961 full_budget,
9962 None,
9963 )?;
9964 require(
9965 path_anchors.page.rows.len() == 1
9966 && path_anchors.page.truncated
9967 && matches!(
9968 path_anchors.page.rows[0].selector(),
9969 EntitySelector::File { .. }
9970 )
9971 && path_anchors.work.requested_rows == 1
9972 && path_anchors.work.returned_rows == 1
9973 && path_anchors.work.decoded_bytes > 0
9974 && path_anchors.work.hydrated_entities == 2
9975 && path_anchors.work.hydrated_paths == 1,
9976 "path anchor page lost stable order or sentinel work",
9977 )?;
9978 let exact_path_anchor_budget =
9979 RepositoryGraphReadBudget::new(1, 1, path_anchors.work.decoded_bytes, 2, 1)?;
9980 require_eq(
9981 &store.repository_graph_entities_by_path_bounded(
9982 fixture.project,
9983 generation,
9984 &anchor_path,
9985 1,
9986 exact_path_anchor_budget,
9987 None,
9988 )?,
9989 &path_anchors,
9990 "exact path anchor envelope",
9991 )?;
9992 require_eq(
9993 &store.repository_graph_entities_by_path(fixture.project, &anchor_path, 1)?,
9994 &path_anchors.page,
9995 "legacy path anchor wrapper compatibility",
9996 )?;
9997 for (budget, limit, context) in [
9998 (
9999 RepositoryGraphReadBudget::new(1, 1, path_anchors.work.decoded_bytes - 1, 2, 1)?,
10000 1,
10001 "path anchor decoded-byte overrun was accepted",
10002 ),
10003 (
10004 RepositoryGraphReadBudget::new(1, 1, path_anchors.work.decoded_bytes, 1, 1)?,
10005 1,
10006 "path anchor sentinel entity overrun was accepted",
10007 ),
10008 (
10009 exact_path_anchor_budget,
10010 2,
10011 "path anchor returned-row overrun was accepted",
10012 ),
10013 ] {
10014 let error = require_db_error(
10015 store.repository_graph_entities_by_path_bounded(
10016 fixture.project,
10017 generation,
10018 &anchor_path,
10019 limit,
10020 budget,
10021 None,
10022 ),
10023 context,
10024 )?;
10025 require(
10026 matches!(error, DbError::GraphContract(_)),
10027 &format!("unexpected path anchor envelope error: {error}"),
10028 )?;
10029 }
10030 let anchor_cancellation = projectatlas_core::IndexCancellation::new();
10031 anchor_cancellation.cancel();
10032 let anchor_control = IndexWorkControl::new(anchor_cancellation, None);
10033 let cancelled_anchor = store.repository_graph_entities_by_path_bounded(
10034 fixture.project,
10035 generation,
10036 &anchor_path,
10037 1,
10038 full_budget,
10039 Some(&anchor_control),
10040 );
10041 require(
10042 matches!(
10043 cancelled_anchor,
10044 Err(DbError::IndexWork(
10045 projectatlas_core::IndexWorkFailure::Cancelled {
10046 stage: IndexWorkStage::RepositoryTraversal
10047 }
10048 ))
10049 ),
10050 "path anchor cancellation was not typed",
10051 )?;
10052 let expired_anchor = IndexWorkControl::with_deadline(
10053 projectatlas_core::IndexCancellation::new(),
10054 Instant::now(),
10055 );
10056 let anchor_deadline = store.repository_graph_entity_bounded(
10057 file_entity.key(),
10058 generation,
10059 full_budget,
10060 Some(&expired_anchor),
10061 );
10062 require(
10063 matches!(
10064 anchor_deadline,
10065 Err(DbError::IndexWork(
10066 projectatlas_core::IndexWorkFailure::DeadlineExceeded {
10067 stage: IndexWorkStage::RepositoryTraversal
10068 }
10069 ))
10070 ),
10071 "file anchor deadline was not typed",
10072 )?;
10073
10074 let expected_relations = fixture.relations.iter().rev().cloned().collect::<Vec<_>>();
10075 let relation_digests = expected_relations
10076 .iter()
10077 .map(|relation| relation.key().digest_bytes())
10078 .collect::<Result<Vec<_>, _>>()?;
10079 let relation_rows = store.repository_graph_relation_rows_by_digest(
10080 fixture.project,
10081 generation,
10082 &relation_digests,
10083 full_budget,
10084 None,
10085 )?;
10086 require_eq(
10087 &relation_rows
10088 .rows
10089 .iter()
10090 .map(|row| row.relation.clone())
10091 .collect::<Vec<_>>(),
10092 &expected_relations,
10093 "ordered relation cursor hydration",
10094 )?;
10095 require(
10096 relation_rows.rows.iter().all(|row| {
10097 row.source.key().project() == fixture.project
10098 && row
10099 .target
10100 .as_ref()
10101 .is_none_or(|target| target.key().project() == fixture.project)
10102 }),
10103 "relation cursor hydration returned an unvalidated endpoint",
10104 )?;
10105 require_eq(
10106 &relation_rows.work,
10107 &RepositoryGraphReadWork {
10108 requested_rows: 4,
10109 returned_rows: 4,
10110 decoded_bytes: relation_rows.work.decoded_bytes,
10111 hydrated_entities: 3,
10112 hydrated_paths: 1,
10113 },
10114 "exact relation cursor work",
10115 )?;
10116 require(
10117 relation_rows.work.decoded_bytes > entity_batch.work.decoded_bytes,
10118 "relation cursor did not meter relation and endpoint payload bytes",
10119 )?;
10120
10121 let exact_entity_budget =
10122 RepositoryGraphReadBudget::new(3, 3, entity_batch.work.decoded_bytes, 3, 2)?;
10123 require_eq(
10124 &store
10125 .repository_graph_entities_by_digest(
10126 fixture.project,
10127 generation,
10128 &entity_digests,
10129 exact_entity_budget,
10130 None,
10131 )?
10132 .work,
10133 &entity_batch.work,
10134 "exact entity envelope",
10135 )?;
10136 let exact_relation_budget =
10137 RepositoryGraphReadBudget::new(4, 4, relation_rows.work.decoded_bytes, 3, 1)?;
10138 require_eq(
10139 &store
10140 .repository_graph_relation_rows_by_digest(
10141 fixture.project,
10142 generation,
10143 &relation_digests,
10144 exact_relation_budget,
10145 None,
10146 )?
10147 .work,
10148 &relation_rows.work,
10149 "exact relation envelope",
10150 )?;
10151
10152 for (budget, context) in [
10153 (
10154 RepositoryGraphReadBudget::new(3, 3, entity_batch.work.decoded_bytes - 1, 3, 2)?,
10155 "decoded-byte envelope overrun was accepted",
10156 ),
10157 (
10158 RepositoryGraphReadBudget::new(3, 3, entity_batch.work.decoded_bytes, 3, 1)?,
10159 "purpose-path envelope overrun was accepted",
10160 ),
10161 ] {
10162 let error = require_db_error(
10163 store.repository_graph_entities_by_digest(
10164 fixture.project,
10165 generation,
10166 &entity_digests,
10167 budget,
10168 None,
10169 ),
10170 context,
10171 )?;
10172 require(
10173 matches!(error, DbError::GraphContract(_)),
10174 &format!("unexpected entity envelope error: {error}"),
10175 )?;
10176 }
10177 let endpoint_budget =
10178 RepositoryGraphReadBudget::new(4, 4, relation_rows.work.decoded_bytes, 2, 1)?;
10179 let endpoint_error = require_db_error(
10180 store.repository_graph_relation_rows_by_digest(
10181 fixture.project,
10182 generation,
10183 &relation_digests,
10184 endpoint_budget,
10185 None,
10186 ),
10187 "endpoint entity envelope overrun was accepted",
10188 )?;
10189 require(
10190 matches!(endpoint_error, DbError::GraphContract(_)),
10191 &format!("unexpected endpoint envelope error: {endpoint_error}"),
10192 )?;
10193
10194 for (budget, context) in [
10195 (
10196 RepositoryGraphReadBudget::new(
10197 2,
10198 3,
10199 RepositoryGraphReadBudget::MAX_DECODED_BYTES,
10200 3,
10201 2,
10202 )?,
10203 "requested-row envelope overrun was accepted",
10204 ),
10205 (
10206 RepositoryGraphReadBudget::new(
10207 3,
10208 2,
10209 RepositoryGraphReadBudget::MAX_DECODED_BYTES,
10210 3,
10211 2,
10212 )?,
10213 "returned-row envelope overrun was accepted",
10214 ),
10215 ] {
10216 let error = require_db_error(
10217 store.repository_graph_entities_by_digest(
10218 fixture.project,
10219 generation,
10220 &entity_digests,
10221 budget,
10222 None,
10223 ),
10224 context,
10225 )?;
10226 require(
10227 matches!(error, DbError::GraphContract(_)),
10228 &format!("unexpected row-envelope error: {error}"),
10229 )?;
10230 }
10231
10232 for invalid in [
10233 RepositoryGraphReadBudget::new(0, 1, 1, 1, 1),
10234 RepositoryGraphReadBudget::new(
10235 RepositoryGraphReadBudget::MAX_REQUESTED_ROWS + 1,
10236 1,
10237 1,
10238 1,
10239 1,
10240 ),
10241 RepositoryGraphReadBudget::new(
10242 1,
10243 RepositoryGraphReadBudget::MAX_RETURNED_ROWS + 1,
10244 1,
10245 1,
10246 1,
10247 ),
10248 RepositoryGraphReadBudget::new(
10249 1,
10250 1,
10251 RepositoryGraphReadBudget::MAX_DECODED_BYTES + 1,
10252 1,
10253 1,
10254 ),
10255 RepositoryGraphReadBudget::new(
10256 1,
10257 1,
10258 1,
10259 RepositoryGraphReadBudget::MAX_HYDRATED_ENTITIES + 1,
10260 1,
10261 ),
10262 RepositoryGraphReadBudget::new(
10263 1,
10264 1,
10265 1,
10266 1,
10267 RepositoryGraphReadBudget::MAX_HYDRATED_PATHS + 1,
10268 ),
10269 ] {
10270 require(
10271 matches!(invalid, Err(GraphContractError::InvalidLimits { .. })),
10272 "invalid graph read budget was accepted",
10273 )?;
10274 }
10275
10276 let empty_entities = store.repository_graph_entities_by_digest(
10277 fixture.project,
10278 generation,
10279 &[],
10280 full_budget,
10281 None,
10282 )?;
10283 let empty_relations = store.repository_graph_relation_rows_by_digest(
10284 fixture.project,
10285 generation,
10286 &[],
10287 full_budget,
10288 None,
10289 )?;
10290 require(
10291 empty_entities.rows.is_empty()
10292 && empty_relations.rows.is_empty()
10293 && empty_entities.work
10294 == (RepositoryGraphReadWork {
10295 requested_rows: 0,
10296 returned_rows: 0,
10297 decoded_bytes: 0,
10298 hydrated_entities: 0,
10299 hydrated_paths: 0,
10300 })
10301 && empty_relations.work == empty_entities.work,
10302 "empty cursor hydration was not stable",
10303 )?;
10304
10305 let purpose_paths = vec![
10306 "Cargo.toml".to_string(),
10307 ".".to_string(),
10308 "src/Äuth.rs".to_string(),
10309 "src".to_string(),
10310 ];
10311 let purpose_batch = store.load_purpose_owner_nodes_by_paths_controlled(
10312 fixture.project,
10313 generation,
10314 &purpose_paths,
10315 full_budget,
10316 None,
10317 )?;
10318 require_eq(
10319 &purpose_batch
10320 .rows
10321 .iter()
10322 .map(|node| node.node.path.clone())
10323 .collect::<Vec<_>>(),
10324 &purpose_paths,
10325 "ordered purpose-owner hydration",
10326 )?;
10327 require_eq(
10328 &purpose_batch.work,
10329 &RepositoryGraphReadWork {
10330 requested_rows: 4,
10331 returned_rows: 4,
10332 decoded_bytes: purpose_batch.work.decoded_bytes,
10333 hydrated_entities: 0,
10334 hydrated_paths: 4,
10335 },
10336 "exact purpose-owner work",
10337 )?;
10338 require(
10339 purpose_batch.work.decoded_bytes > 0,
10340 "purpose-owner hydration decoded no SQLite payload bytes",
10341 )?;
10342 let exact_purpose_budget =
10343 RepositoryGraphReadBudget::new(4, 4, purpose_batch.work.decoded_bytes, 1, 4)?;
10344 require_eq(
10345 &store
10346 .load_purpose_owner_nodes_by_paths_controlled(
10347 fixture.project,
10348 generation,
10349 &purpose_paths,
10350 exact_purpose_budget,
10351 None,
10352 )?
10353 .work,
10354 &purpose_batch.work,
10355 "exact purpose-owner envelope",
10356 )?;
10357 for (budget, context) in [
10358 (
10359 RepositoryGraphReadBudget::new(4, 4, purpose_batch.work.decoded_bytes - 1, 1, 4)?,
10360 "purpose-owner decoded-byte overrun was accepted",
10361 ),
10362 (
10363 RepositoryGraphReadBudget::new(4, 4, purpose_batch.work.decoded_bytes, 1, 3)?,
10364 "purpose-owner path overrun was accepted",
10365 ),
10366 ] {
10367 let error = require_db_error(
10368 store.load_purpose_owner_nodes_by_paths_controlled(
10369 fixture.project,
10370 generation,
10371 &purpose_paths,
10372 budget,
10373 None,
10374 ),
10375 context,
10376 )?;
10377 require(
10378 matches!(error, DbError::GraphContract(_)),
10379 &format!("unexpected purpose-owner envelope error: {error}"),
10380 )?;
10381 }
10382 let mut missing_purpose_paths = purpose_paths.clone();
10383 missing_purpose_paths.push("missing/purpose-owner.rs".to_string());
10384 let missing_purpose = store.load_purpose_owner_nodes_by_paths_controlled(
10385 fixture.project,
10386 generation,
10387 &missing_purpose_paths,
10388 full_budget,
10389 None,
10390 )?;
10391 require_eq(
10392 &missing_purpose
10393 .rows
10394 .iter()
10395 .map(|node| node.node.path.clone())
10396 .collect::<Vec<_>>(),
10397 &purpose_paths,
10398 "absent purpose-owner candidate ordering",
10399 )?;
10400 require(
10401 missing_purpose.work.requested_rows == 5
10402 && missing_purpose.work.returned_rows == 4
10403 && missing_purpose.work.hydrated_paths == 4,
10404 "absent purpose-owner candidate work was not exact",
10405 )?;
10406 let duplicate_purpose = require_db_error(
10407 store.load_purpose_owner_nodes_by_paths_controlled(
10408 fixture.project,
10409 generation,
10410 &[purpose_paths[0].clone(), purpose_paths[0].clone()],
10411 full_budget,
10412 None,
10413 ),
10414 "duplicate purpose-owner paths were accepted",
10415 )?;
10416 require(
10417 matches!(duplicate_purpose, DbError::GraphContract(_)),
10418 &format!("unexpected duplicate purpose-owner error: {duplicate_purpose}"),
10419 )?;
10420 let purpose_cancellation = projectatlas_core::IndexCancellation::new();
10421 purpose_cancellation.cancel();
10422 let purpose_control = IndexWorkControl::new(purpose_cancellation, None);
10423 let cancelled_purpose = store.load_purpose_owner_nodes_by_paths_controlled(
10424 fixture.project,
10425 generation,
10426 &purpose_paths,
10427 full_budget,
10428 Some(&purpose_control),
10429 );
10430 require(
10431 matches!(
10432 cancelled_purpose,
10433 Err(DbError::IndexWork(
10434 projectatlas_core::IndexWorkFailure::Cancelled {
10435 stage: IndexWorkStage::RepositoryTraversal
10436 }
10437 ))
10438 ),
10439 "purpose-owner cancellation was not typed",
10440 )?;
10441
10442 let occurrence_batch = store.repository_graph_occurrence_pages_bounded(
10443 &fixture.relations,
10444 1,
10445 full_budget,
10446 None,
10447 )?;
10448 require(
10449 occurrence_batch.pages.len() == fixture.relations.len()
10450 && occurrence_batch.pages[0].rows.len() == 1
10451 && occurrence_batch.pages[0].truncated
10452 && occurrence_batch.pages[1..]
10453 .iter()
10454 .all(|page| page.rows.is_empty() && !page.truncated),
10455 "batched occurrence pages lost owner order or truncation state",
10456 )?;
10457 require_eq(
10458 &occurrence_batch.work,
10459 &RepositoryGraphReadWork {
10460 requested_rows: 4,
10461 returned_rows: 1,
10462 decoded_bytes: occurrence_batch.work.decoded_bytes,
10463 hydrated_entities: 0,
10464 hydrated_paths: 2,
10465 },
10466 "exact occurrence batch work including sentinel path",
10467 )?;
10468 let exact_occurrence_budget =
10469 RepositoryGraphReadBudget::new(4, 1, occurrence_batch.work.decoded_bytes, 1, 2)?;
10470 require_eq(
10471 &store.repository_graph_occurrence_pages_bounded(
10472 &fixture.relations,
10473 1,
10474 exact_occurrence_budget,
10475 None,
10476 )?,
10477 &occurrence_batch,
10478 "exact occurrence envelope",
10479 )?;
10480 require_eq(
10481 &store.repository_graph_occurrence_pages(&fixture.relations, 1, None)?,
10482 &occurrence_batch.pages,
10483 "legacy occurrence wrapper compatibility",
10484 )?;
10485 for (budget, limit, context) in [
10486 (
10487 RepositoryGraphReadBudget::new(
10488 4,
10489 1,
10490 occurrence_batch.work.decoded_bytes - 1,
10491 1,
10492 2,
10493 )?,
10494 1,
10495 "occurrence decoded-byte overrun was accepted",
10496 ),
10497 (
10498 RepositoryGraphReadBudget::new(4, 1, occurrence_batch.work.decoded_bytes, 1, 1)?,
10499 1,
10500 "occurrence sentinel path overrun was accepted",
10501 ),
10502 (
10503 RepositoryGraphReadBudget::new(
10504 4,
10505 1,
10506 RepositoryGraphReadBudget::MAX_DECODED_BYTES,
10507 1,
10508 RepositoryGraphReadBudget::MAX_HYDRATED_PATHS,
10509 )?,
10510 3,
10511 "occurrence returned-row overrun was accepted",
10512 ),
10513 ] {
10514 let error = require_db_error(
10515 store.repository_graph_occurrence_pages_bounded(
10516 &fixture.relations,
10517 limit,
10518 budget,
10519 None,
10520 ),
10521 context,
10522 )?;
10523 require(
10524 matches!(error, DbError::GraphContract(_)),
10525 &format!("unexpected occurrence envelope error: {error}"),
10526 )?;
10527 }
10528 let occurrence_cancellation = projectatlas_core::IndexCancellation::new();
10529 occurrence_cancellation.cancel();
10530 let occurrence_control = IndexWorkControl::new(occurrence_cancellation, None);
10531 let cancelled_occurrences = store.repository_graph_occurrence_pages_bounded(
10532 &fixture.relations,
10533 1,
10534 full_budget,
10535 Some(&occurrence_control),
10536 );
10537 require(
10538 matches!(
10539 cancelled_occurrences,
10540 Err(DbError::IndexWork(
10541 projectatlas_core::IndexWorkFailure::Cancelled {
10542 stage: IndexWorkStage::RepositoryTraversal
10543 }
10544 ))
10545 ),
10546 "occurrence batch cancellation was not typed",
10547 )?;
10548 let expired_occurrences = IndexWorkControl::with_deadline(
10549 projectatlas_core::IndexCancellation::new(),
10550 Instant::now(),
10551 );
10552 let occurrence_deadline = store.repository_graph_occurrence_pages_bounded(
10553 &fixture.relations,
10554 1,
10555 full_budget,
10556 Some(&expired_occurrences),
10557 );
10558 require(
10559 matches!(
10560 occurrence_deadline,
10561 Err(DbError::IndexWork(
10562 projectatlas_core::IndexWorkFailure::DeadlineExceeded {
10563 stage: IndexWorkStage::RepositoryTraversal
10564 }
10565 ))
10566 ),
10567 "occurrence batch deadline was not typed",
10568 )?;
10569
10570 let coverage_paths = vec![
10571 RepositoryNodePath::new(Path::new("src/Äuth.rs"))?,
10572 RepositoryNodePath::new(Path::new("Cargo.toml"))?,
10573 ];
10574 let coverage_batch = store.repository_graph_path_coverage_bounded(
10575 fixture.project,
10576 generation,
10577 &coverage_paths,
10578 full_budget,
10579 None,
10580 )?;
10581 require_eq(
10582 &coverage_batch
10583 .page
10584 .rows
10585 .iter()
10586 .map(|coverage| match coverage.scope() {
10587 CoverageScope::Path { path } => path.as_str().to_string(),
10588 CoverageScope::Project => "project".to_string(),
10589 })
10590 .collect::<Vec<_>>(),
10591 &vec!["Cargo.toml".to_string(), "src/Äuth.rs".to_string()],
10592 "stable path coverage order",
10593 )?;
10594 require(
10595 !coverage_batch.page.truncated
10596 && coverage_batch.work
10597 == (RepositoryGraphReadWork {
10598 requested_rows: 2,
10599 returned_rows: 2,
10600 decoded_bytes: coverage_batch.work.decoded_bytes,
10601 hydrated_entities: 0,
10602 hydrated_paths: 2,
10603 })
10604 && coverage_batch.work.decoded_bytes > 0,
10605 "exact coverage batch work was incomplete",
10606 )?;
10607 let exact_coverage_budget =
10608 RepositoryGraphReadBudget::new(2, 2, coverage_batch.work.decoded_bytes, 1, 2)?;
10609 require_eq(
10610 &store.repository_graph_path_coverage_bounded(
10611 fixture.project,
10612 generation,
10613 &coverage_paths,
10614 exact_coverage_budget,
10615 None,
10616 )?,
10617 &coverage_batch,
10618 "exact coverage envelope",
10619 )?;
10620 require_eq(
10621 &store.repository_graph_path_coverage(fixture.project, &coverage_paths, None)?,
10622 &coverage_batch.page,
10623 "legacy coverage wrapper compatibility",
10624 )?;
10625 for (budget, context) in [
10626 (
10627 RepositoryGraphReadBudget::new(2, 2, coverage_batch.work.decoded_bytes - 1, 1, 2)?,
10628 "coverage decoded-byte overrun was accepted",
10629 ),
10630 (
10631 RepositoryGraphReadBudget::new(2, 1, coverage_batch.work.decoded_bytes, 1, 2)?,
10632 "coverage returned-row overrun was accepted",
10633 ),
10634 (
10635 RepositoryGraphReadBudget::new(2, 2, coverage_batch.work.decoded_bytes, 1, 1)?,
10636 "coverage hydrated-path overrun was accepted",
10637 ),
10638 ] {
10639 let error = require_db_error(
10640 store.repository_graph_path_coverage_bounded(
10641 fixture.project,
10642 generation,
10643 &coverage_paths,
10644 budget,
10645 None,
10646 ),
10647 context,
10648 )?;
10649 require(
10650 matches!(error, DbError::GraphContract(_)),
10651 &format!("unexpected coverage envelope error: {error}"),
10652 )?;
10653 }
10654 let coverage_cancellation = projectatlas_core::IndexCancellation::new();
10655 coverage_cancellation.cancel();
10656 let coverage_control = IndexWorkControl::new(coverage_cancellation, None);
10657 let cancelled_coverage = store.repository_graph_path_coverage_bounded(
10658 fixture.project,
10659 generation,
10660 &coverage_paths,
10661 full_budget,
10662 Some(&coverage_control),
10663 );
10664 require(
10665 matches!(
10666 cancelled_coverage,
10667 Err(DbError::IndexWork(
10668 projectatlas_core::IndexWorkFailure::Cancelled {
10669 stage: IndexWorkStage::RepositoryTraversal
10670 }
10671 ))
10672 ),
10673 "coverage batch cancellation was not typed",
10674 )?;
10675 let expired_coverage = IndexWorkControl::with_deadline(
10676 projectatlas_core::IndexCancellation::new(),
10677 Instant::now(),
10678 );
10679 let coverage_deadline = store.repository_graph_path_coverage_bounded(
10680 fixture.project,
10681 generation,
10682 &coverage_paths,
10683 full_budget,
10684 Some(&expired_coverage),
10685 );
10686 require(
10687 matches!(
10688 coverage_deadline,
10689 Err(DbError::IndexWork(
10690 projectatlas_core::IndexWorkFailure::DeadlineExceeded {
10691 stage: IndexWorkStage::RepositoryTraversal
10692 }
10693 ))
10694 ),
10695 "coverage batch deadline was not typed",
10696 )?;
10697
10698 let missing = [0xff; 32];
10699 let missing_entity = require_db_error(
10700 store.repository_graph_entities_by_digest(
10701 fixture.project,
10702 generation,
10703 &[entity_digests[0], missing],
10704 full_budget,
10705 None,
10706 ),
10707 "missing entity cursor key returned a partial set",
10708 )?;
10709 require(
10710 matches!(
10711 missing_entity,
10712 DbError::GraphRowShape {
10713 table: "graph_entities",
10714 ..
10715 }
10716 ),
10717 &format!("unexpected missing-entity error: {missing_entity}"),
10718 )?;
10719 let missing_relation = require_db_error(
10720 store.repository_graph_relation_rows_by_digest(
10721 fixture.project,
10722 generation,
10723 &[relation_digests[0], missing],
10724 full_budget,
10725 None,
10726 ),
10727 "missing relation cursor key returned a partial set",
10728 )?;
10729 require(
10730 matches!(
10731 missing_relation,
10732 DbError::GraphRowShape {
10733 table: "graph_relations",
10734 ..
10735 }
10736 ),
10737 &format!("unexpected missing-relation error: {missing_relation}"),
10738 )?;
10739
10740 let duplicate = require_db_error(
10741 store.repository_graph_entities_by_digest(
10742 fixture.project,
10743 generation,
10744 &[entity_digests[0], entity_digests[0]],
10745 full_budget,
10746 None,
10747 ),
10748 "duplicate entity cursor keys were accepted",
10749 )?;
10750 require(
10751 matches!(duplicate, DbError::GraphContract(_)),
10752 &format!("unexpected duplicate hydration error: {duplicate}"),
10753 )?;
10754 let oversized = vec![[0; 32]; MAX_REPOSITORY_GRAPH_FRONTIER + 1];
10755 let oversized = require_db_error(
10756 store.repository_graph_relation_rows_by_digest(
10757 fixture.project,
10758 generation,
10759 &oversized,
10760 full_budget,
10761 None,
10762 ),
10763 "oversized relation cursor key set was accepted",
10764 )?;
10765 require(
10766 matches!(oversized, DbError::GraphContract(_)),
10767 &format!("unexpected oversized hydration error: {oversized}"),
10768 )?;
10769
10770 let foreign_project = ProjectInstanceId::from_bytes([0x7f; 16])?;
10771 let foreign = require_db_error(
10772 store.repository_graph_entities_by_digest(
10773 foreign_project,
10774 generation,
10775 &entity_digests,
10776 full_budget,
10777 None,
10778 ),
10779 "cross-project entity cursor hydration was accepted",
10780 )?;
10781 require(
10782 matches!(foreign, DbError::GraphProjectIdentityMismatch { .. }),
10783 &format!("unexpected cross-project hydration error: {foreign}"),
10784 )?;
10785 let stale_generation = generation
10786 .checked_next()
10787 .ok_or_else(|| io::Error::other("fixture generation overflowed"))?;
10788 let stale = require_db_error(
10789 store.repository_graph_relation_rows_by_digest(
10790 fixture.project,
10791 stale_generation,
10792 &relation_digests,
10793 full_budget,
10794 None,
10795 ),
10796 "stale-generation relation cursor hydration was accepted",
10797 )?;
10798 require(
10799 matches!(stale, DbError::GraphContract(_)),
10800 &format!("unexpected stale-generation hydration error: {stale}"),
10801 )?;
10802 for entities in [true, false] {
10803 let cancellation = projectatlas_core::IndexCancellation::new();
10804 cancellation.cancel();
10805 let control = IndexWorkControl::new(cancellation, None);
10806 let cancelled = if entities {
10807 store
10808 .repository_graph_entities_by_digest(
10809 fixture.project,
10810 generation,
10811 &entity_digests,
10812 full_budget,
10813 Some(&control),
10814 )
10815 .map(|_| ())
10816 } else {
10817 store
10818 .repository_graph_relation_rows_by_digest(
10819 fixture.project,
10820 generation,
10821 &relation_digests,
10822 full_budget,
10823 Some(&control),
10824 )
10825 .map(|_| ())
10826 };
10827 require(
10828 matches!(
10829 cancelled,
10830 Err(DbError::IndexWork(
10831 projectatlas_core::IndexWorkFailure::Cancelled {
10832 stage: IndexWorkStage::RepositoryTraversal
10833 }
10834 ))
10835 ),
10836 "cursor hydration cancellation was not typed",
10837 )?;
10838 }
10839 let expired = IndexWorkControl::with_deadline(
10840 projectatlas_core::IndexCancellation::new(),
10841 Instant::now(),
10842 );
10843 let deadline = store.repository_graph_entities_by_digest(
10844 fixture.project,
10845 generation,
10846 &entity_digests,
10847 full_budget,
10848 Some(&expired),
10849 );
10850 require(
10851 matches!(
10852 deadline,
10853 Err(DbError::IndexWork(
10854 projectatlas_core::IndexWorkFailure::DeadlineExceeded {
10855 stage: IndexWorkStage::RepositoryTraversal
10856 }
10857 ))
10858 ),
10859 "cursor hydration deadline was not typed",
10860 )?;
10861
10862 let source = fixture
10863 .entities
10864 .iter()
10865 .find(|entity| matches!(entity.selector(), EntitySelector::File { .. }))
10866 .ok_or_else(|| io::Error::other("source file fixture missing"))?;
10867 let adjacency = store.repository_graph_adjacency_page_bounded(
10868 &[source.key().clone()],
10869 RepositoryGraphDirection::Outbound,
10870 None,
10871 1,
10872 full_budget,
10873 None,
10874 )?;
10875 require(
10876 adjacency.page.rows.len() == 1
10877 && adjacency.page.truncated
10878 && adjacency.work.requested_rows == 1
10879 && adjacency.work.returned_rows == 1
10880 && adjacency.work.decoded_bytes > 0
10881 && adjacency.work.hydrated_entities > 0
10882 && adjacency.work.hydrated_paths > 0,
10883 "bounded adjacency page omitted exact raw or endpoint work",
10884 )?;
10885 let exact_adjacency_budget = RepositoryGraphReadBudget::new(
10886 1,
10887 1,
10888 adjacency.work.decoded_bytes,
10889 adjacency.work.hydrated_entities,
10890 adjacency.work.hydrated_paths,
10891 )?;
10892 require_eq(
10893 &store.repository_graph_adjacency_page_bounded(
10894 &[source.key().clone()],
10895 RepositoryGraphDirection::Outbound,
10896 None,
10897 1,
10898 exact_adjacency_budget,
10899 None,
10900 )?,
10901 &adjacency,
10902 "exact adjacency envelope",
10903 )?;
10904 let adjacency_overrun = require_db_error(
10905 store.repository_graph_adjacency_page_bounded(
10906 &[source.key().clone()],
10907 RepositoryGraphDirection::Outbound,
10908 None,
10909 1,
10910 RepositoryGraphReadBudget::new(
10911 1,
10912 1,
10913 adjacency.work.decoded_bytes - 1,
10914 adjacency.work.hydrated_entities,
10915 adjacency.work.hydrated_paths,
10916 )?,
10917 None,
10918 ),
10919 "adjacency decoded-byte overrun was accepted",
10920 )?;
10921 require(
10922 matches!(adjacency_overrun, DbError::GraphContract(_)),
10923 &format!("unexpected adjacency envelope error: {adjacency_overrun}"),
10924 )?;
10925 let adjacency_return_limit = require_db_error(
10926 store.repository_graph_adjacency_page_bounded(
10927 &[source.key().clone()],
10928 RepositoryGraphDirection::Outbound,
10929 None,
10930 2,
10931 exact_adjacency_budget,
10932 None,
10933 ),
10934 "adjacency page limit exceeded the return budget",
10935 )?;
10936 require(
10937 matches!(adjacency_return_limit, DbError::GraphContract(_)),
10938 &format!("unexpected adjacency return-budget error: {adjacency_return_limit}"),
10939 )?;
10940 let continuation = adjacency
10941 .page
10942 .continuation
10943 .ok_or_else(|| io::Error::other("adjacency continuation missing"))?;
10944 let encoded =
10945 serde_json::to_vec(&(RepositoryGraphDirection::Outbound, continuation.clone()))?;
10946 let decoded: (
10947 RepositoryGraphDirection,
10948 RepositoryGraphAdjacencyContinuation,
10949 ) = serde_json::from_slice(&encoded)?;
10950 require_eq(
10951 &decoded,
10952 &(RepositoryGraphDirection::Outbound, continuation),
10953 "opaque relation cursor serde round trip",
10954 )?;
10955
10956 assert_cursor_hydration_indexes(&store)?;
10957 store.finish_index_read_snapshot()?;
10958 Ok(())
10959 }
10960
10961 #[test]
10962 fn continued_adjacency_seeks_past_high_degree_prefixes() -> Result<(), Box<dyn Error>> {
10963 let temp = tempfile::tempdir()?;
10964 let project_root = temp.path().join("continued-adjacency-work");
10965 let atlas_dir = project_root.join(".projectatlas");
10966 fs::create_dir_all(&atlas_dir)?;
10967 let db_path = atlas_dir.join("projectatlas.db");
10968 let mut writer = AtlasStore::open_for_project(&db_path, &project_root)?;
10969 let fixture = publish_fixture(&mut writer, "continued-adjacency-work")?;
10970 let source = fixture
10971 .entities
10972 .iter()
10973 .find(|entity| matches!(entity.selector(), EntitySelector::File { .. }))
10974 .ok_or_else(|| io::Error::other("source file fixture missing"))?;
10975 let external = fixture
10976 .entities
10977 .iter()
10978 .find(|entity| matches!(entity.selector(), EntitySelector::External { .. }))
10979 .ok_or_else(|| io::Error::other("external fixture missing"))?;
10980 writer.connection.execute(
10981 "WITH RECURSIVE sequence(value) AS (
10982 VALUES(1)
10983 UNION ALL
10984 SELECT value + 1 FROM sequence WHERE value < 100000
10985 )
10986 INSERT INTO graph_relations(
10987 relation_key, project_instance_id, canonical_identity,
10988 source_entity_key, relation_scope, relation_kind,
10989 resolution_status, target_entity_key, reference_text,
10990 candidate_count, confidence, completeness
10991 )
10992 SELECT CAST(printf('%032d', 1000000 + value) AS BLOB),
10993 ?1, printf('perf-%06d', value), ?2,
10994 'legacy', 'calls', 'resolved', ?3, NULL, NULL,
10995 'exact', 'complete'
10996 FROM sequence",
10997 params![
10998 fixture.project.as_bytes().as_slice(),
10999 source.key().digest_bytes()?.as_slice(),
11000 external.key().digest_bytes()?.as_slice(),
11001 ],
11002 )?;
11003 drop(writer);
11004
11005 let store = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
11006 let generation = store
11007 .repository_graph_generation()?
11008 .ok_or_else(|| io::Error::other("graph generation missing"))?;
11009 let source_digest = source.key().digest_bytes()?;
11010 let external_digest = external.key().digest_bytes()?;
11011 let skipped_frontier = vec![source.key().clone(), external.key().clone()];
11012 let skipped_continuation = RepositoryGraphAdjacencyContinuation {
11013 project: fixture.project,
11014 generation,
11015 direction: RepositoryGraphDirection::Outbound,
11016 relation: None,
11017 frontier: vec![source_digest, external_digest],
11018 frontier_index: 1,
11019 relation_scope: String::new(),
11020 relation_kind: String::new(),
11021 relation_key: [0; 32],
11022 };
11023 let skipped_steps = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
11024 let skipped_counter = std::sync::Arc::clone(&skipped_steps);
11025 store.connection.progress_handler(
11026 1_000,
11027 Some(move || {
11028 skipped_counter.fetch_add(1_000, std::sync::atomic::Ordering::Relaxed);
11029 false
11030 }),
11031 );
11032 let skipped_result = store.repository_graph_adjacency_page(
11033 &skipped_frontier,
11034 RepositoryGraphDirection::Outbound,
11035 Some(&skipped_continuation),
11036 2,
11037 None,
11038 );
11039 store.connection.progress_handler(0, None::<fn() -> bool>);
11040 skipped_result?;
11041 require(
11042 skipped_steps.load(std::sync::atomic::Ordering::Relaxed) < 100_000,
11043 "continued adjacency scanned a completed high-degree frontier branch",
11044 )?;
11045
11046 let last_key: [u8; 32] = format!("{:032}", 1_100_000)
11047 .into_bytes()
11048 .try_into()
11049 .map_err(|_source| io::Error::other("high-degree cursor key width changed"))?;
11050 let deep_continuation = RepositoryGraphAdjacencyContinuation {
11051 project: fixture.project,
11052 generation,
11053 direction: RepositoryGraphDirection::Outbound,
11054 relation: None,
11055 frontier: vec![source_digest],
11056 frontier_index: 0,
11057 relation_scope: "legacy".to_string(),
11058 relation_kind: "calls".to_string(),
11059 relation_key: last_key,
11060 };
11061 let deep_steps = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
11062 let deep_counter = std::sync::Arc::clone(&deep_steps);
11063 store.connection.progress_handler(
11064 1_000,
11065 Some(move || {
11066 deep_counter.fetch_add(1_000, std::sync::atomic::Ordering::Relaxed);
11067 false
11068 }),
11069 );
11070 let deep_result = store.repository_graph_adjacency_page(
11071 &[source.key().clone()],
11072 RepositoryGraphDirection::Outbound,
11073 Some(&deep_continuation),
11074 2,
11075 None,
11076 );
11077 store.connection.progress_handler(0, None::<fn() -> bool>);
11078 deep_result?;
11079 require(
11080 deep_steps.load(std::sync::atomic::Ordering::Relaxed) < 100_000,
11081 "continued adjacency rescanned a high-degree keyset prefix",
11082 )?;
11083 store.finish_index_read_snapshot()?;
11084 Ok(())
11085 }
11086
11087 #[test]
11088 fn bounded_rust_frontier_matches_indexed_recursive_cte_on_cycles_and_high_degree()
11089 -> Result<(), Box<dyn Error>> {
11090 const HIGH_DEGREE: usize = 4_096;
11091 const RECURSIVE_CTE: &str = "WITH RECURSIVE walk(entity_key) AS (
11092 VALUES(?2)
11093 UNION
11094 SELECT relation.target_entity_key
11095 FROM graph_relations AS relation INDEXED BY idx_graph_relations_source_kind
11096 JOIN walk ON relation.source_entity_key = walk.entity_key
11097 WHERE relation.project_instance_id = ?1
11098 AND relation.relation_scope = 'legacy'
11099 AND relation.relation_kind = 'calls'
11100 AND relation.target_entity_key IS NOT NULL
11101 )
11102 SELECT entity_key FROM walk ORDER BY entity_key";
11103
11104 let temp = tempfile::tempdir()?;
11105 let project_root = temp.path().join("frontier-cte-comparison");
11106 let atlas_dir = project_root.join(".projectatlas");
11107 fs::create_dir_all(&atlas_dir)?;
11108 let db_path = atlas_dir.join("projectatlas.db");
11109 let mut writer = AtlasStore::open_for_project(&db_path, &project_root)?;
11110 let fixture = publish_fixture(&mut writer, "frontier-cte-comparison")?;
11111 let source = fixture
11112 .entities
11113 .iter()
11114 .find(|entity| matches!(entity.selector(), EntitySelector::File { .. }))
11115 .ok_or_else(|| io::Error::other("source file fixture missing"))?;
11116 let generation = source.generation();
11117 let mut high_degree_entities = Vec::with_capacity(HIGH_DEGREE);
11118 let mut cyclic_relations = Vec::with_capacity(HIGH_DEGREE * 2);
11119 for index in 0..HIGH_DEGREE {
11120 let target = GraphEntity::new(
11121 fixture.project,
11122 EntitySelector::External {
11123 external: ExternalSelector {
11124 system: GraphIdentityText::new("frontier-measurement")?,
11125 identity: GraphIdentityText::new(format!("node-{index:05}"))?,
11126 },
11127 },
11128 generation,
11129 )?;
11130 cyclic_relations.push(LogicalRelation::new(
11131 source,
11132 GraphRelationKind::Legacy(RelationKind::Calls),
11133 RelationResolution::external(&target)?,
11134 ConfidenceClass::Exact,
11135 Completeness::Complete,
11136 generation,
11137 )?);
11138 cyclic_relations.push(LogicalRelation::new(
11139 &target,
11140 GraphRelationKind::Legacy(RelationKind::Calls),
11141 RelationResolution::resolved(source)?,
11142 ConfidenceClass::Exact,
11143 Completeness::Complete,
11144 generation,
11145 )?);
11146 high_degree_entities.push(target);
11147 }
11148 let transaction = writer.connection.transaction()?;
11149 insert_entities(&transaction, fixture.project, &high_degree_entities)?;
11150 insert_relations(&transaction, fixture.project, &cyclic_relations)?;
11151 transaction.commit()?;
11152 drop(writer);
11153
11154 let store = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
11155 let project = fixture.project.as_bytes();
11156 let source_key = source.key().digest_bytes()?;
11157 let plan_sql = format!("EXPLAIN QUERY PLAN {RECURSIVE_CTE}");
11158 let cte_plan = store
11159 .connection
11160 .prepare(&plan_sql)?
11161 .query_map(params![&project[..], &source_key[..]], |row| {
11162 row.get::<_, String>(3)
11163 })?
11164 .collect::<Result<Vec<_>, _>>()?;
11165 require(
11166 cte_plan
11167 .iter()
11168 .any(|detail| detail.contains("idx_graph_relations_source_kind"))
11169 && cte_plan
11170 .iter()
11171 .all(|detail| !detail.contains("SCAN relation")),
11172 &format!("recursive CTE did not retain the source-owned index: {cte_plan:?}"),
11173 )?;
11174
11175 let cte_steps = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
11176 let cte_counter = std::sync::Arc::clone(&cte_steps);
11177 store.connection.progress_handler(
11178 1_000,
11179 Some(move || {
11180 cte_counter.fetch_add(1_000, std::sync::atomic::Ordering::Relaxed);
11181 false
11182 }),
11183 );
11184 let cte_started = Instant::now();
11185 let cte_result = store
11186 .connection
11187 .prepare(RECURSIVE_CTE)?
11188 .query_map(params![&project[..], &source_key[..]], |row| {
11189 row.get::<_, Vec<u8>>(0)
11190 })?
11191 .map(|row| fixed_bytes::<32>("recursive_cte.entity_key", row?))
11192 .collect::<DbResult<Vec<_>>>();
11193 let cte_elapsed = cte_started.elapsed();
11194 store.connection.progress_handler(0, None::<fn() -> bool>);
11195 let cte_nodes = cte_result?;
11196
11197 let rust_steps = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
11198 let rust_counter = std::sync::Arc::clone(&rust_steps);
11199 store.connection.progress_handler(
11200 1_000,
11201 Some(move || {
11202 rust_counter.fetch_add(1_000, std::sync::atomic::Ordering::Relaxed);
11203 false
11204 }),
11205 );
11206 let rust_started = Instant::now();
11207 let rust_result = collect_bounded_outbound_calls(&store, source.key());
11208 let rust_elapsed = rust_started.elapsed();
11209 store.connection.progress_handler(0, None::<fn() -> bool>);
11210 let (rust_nodes, inspected_edges, peak_frontier) = rust_result?;
11211 let (repeated_nodes, repeated_edges, repeated_peak) =
11212 collect_bounded_outbound_calls(&store, source.key())?;
11213
11214 require_eq(
11215 &rust_nodes,
11216 &cte_nodes,
11217 "Rust frontier versus recursive CTE topology",
11218 )?;
11219 require_eq(
11220 &repeated_nodes,
11221 &rust_nodes,
11222 "deterministic Rust frontier order",
11223 )?;
11224 require_eq(
11225 &repeated_edges,
11226 &inspected_edges,
11227 "deterministic inspected edges",
11228 )?;
11229 require_eq(
11230 &repeated_peak,
11231 &peak_frontier,
11232 "deterministic peak frontier",
11233 )?;
11234 require_eq(
11235 &rust_nodes.len(),
11236 &(HIGH_DEGREE + 2),
11237 "cycle-safe high-degree topology",
11238 )?;
11239 require_eq(
11240 &inspected_edges,
11241 &(HIGH_DEGREE * 2 + 1),
11242 "bounded frontier inspected every call edge once",
11243 )?;
11244 require(
11245 cte_steps.load(std::sync::atomic::Ordering::Relaxed) > 0
11246 && rust_steps.load(std::sync::atomic::Ordering::Relaxed) > 0
11247 && cte_elapsed > Duration::ZERO
11248 && rust_elapsed > Duration::ZERO,
11249 "frontier comparison did not record VM work and elapsed time",
11250 )?;
11251 let retained_key_bytes = rust_nodes
11252 .len()
11253 .checked_add(peak_frontier)
11254 .and_then(|keys| keys.checked_mul(std::mem::size_of::<[u8; 32]>()))
11255 .ok_or_else(|| io::Error::other("retained key measurement overflowed"))?;
11256 let output_key_bytes = rust_nodes
11257 .len()
11258 .checked_mul(std::mem::size_of::<[u8; 32]>())
11259 .ok_or_else(|| io::Error::other("output key measurement overflowed"))?;
11260 require(
11261 retained_key_bytes <= GraphLimits::MAX_OUTPUT_BYTES as usize
11262 && output_key_bytes <= GraphLimits::MAX_OUTPUT_BYTES as usize,
11263 "bounded Rust frontier exceeded the shared compact byte ceiling",
11264 )?;
11265
11266 store.connection.progress_handler(1, Some(|| true));
11267 let cancelled_cte = (|| -> Result<Vec<Vec<u8>>, rusqlite::Error> {
11268 let mut statement = store.connection.prepare(RECURSIVE_CTE)?;
11269 statement
11270 .query_map(params![&project[..], &source_key[..]], |row| {
11271 row.get::<_, Vec<u8>>(0)
11272 })?
11273 .collect()
11274 })();
11275 store.connection.progress_handler(0, None::<fn() -> bool>);
11276 require(
11277 matches!(
11278 cancelled_cte,
11279 Err(rusqlite::Error::SqliteFailure(ref failure, _))
11280 if failure.code == rusqlite::ErrorCode::OperationInterrupted
11281 ),
11282 "recursive CTE did not stop through the SQLite progress handler",
11283 )?;
11284 let cancellation = projectatlas_core::IndexCancellation::new();
11285 cancellation.cancel();
11286 let control = IndexWorkControl::new(cancellation, None);
11287 let cancelled_rust = store.repository_graph_adjacency_page_filtered(
11288 &[source.key().clone()],
11289 RepositoryGraphDirection::Outbound,
11290 Some(GraphRelationKind::Legacy(RelationKind::Calls)),
11291 None,
11292 1,
11293 Some(&control),
11294 );
11295 require(
11296 matches!(
11297 cancelled_rust,
11298 Err(DbError::IndexWork(
11299 projectatlas_core::IndexWorkFailure::Cancelled {
11300 stage: IndexWorkStage::RepositoryTraversal
11301 }
11302 ))
11303 ),
11304 "bounded Rust frontier did not retain typed cancellation",
11305 )?;
11306 store.finish_index_read_snapshot()?;
11307 Ok(())
11308 }
11309
11310 #[test]
11311 fn coverage_discovery_filters_provenance_and_fails_closed_after_reopen()
11312 -> Result<(), Box<dyn Error>> {
11313 let temp = tempfile::tempdir()?;
11314 let project_root = temp.path().join("coverage-discovery");
11315 fs::create_dir_all(&project_root)?;
11316 let db_path = project_root.join("projectatlas.db");
11317 let mut writer = AtlasStore::open_for_project(&db_path, &project_root)?;
11318 let fixture = publish_fixture(&mut writer, "coverage-discovery")?;
11319 let sibling_path = "src/Äuth.rs.backup";
11320 let sibling_coverage = CoverageRecord::new(
11321 CoverageScope::Path {
11322 path: RepositoryNodePath::new(Path::new(sibling_path))?,
11323 },
11324 None,
11325 CoverageState::Complete,
11326 1,
11327 0,
11328 IndexGeneration::new(2),
11329 None,
11330 None,
11331 )?;
11332 let mut publication = writer.begin_index_publication("coverage-discovery-sibling")?;
11333 publication.replace_repository_graph_for_paths(
11334 fixture.project,
11335 &[sibling_path.to_string()],
11336 &[],
11337 &[],
11338 &[],
11339 &[sibling_coverage],
11340 )?;
11341 publication.complete()?;
11342 drop(writer);
11343
11344 let store = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
11345 let project_value = Value::Blob(fixture.project.as_bytes().to_vec());
11346 assert_coverage_discovery_plan(
11347 &store.connection,
11348 "EXPLAIN QUERY PLAN
11349 SELECT coverage.id FROM graph_coverage AS coverage
11350 WHERE coverage.project_instance_id = ?
11351 AND coverage.scope_kind = 'path'
11352 AND coverage.scope_path >= ? AND coverage.scope_path < ?
11353 AND (coverage.scope_path = ? OR coverage.scope_path >= ?)
11354 ORDER BY coverage.scope_path, coverage.relation_scope, coverage.relation_kind,
11355 coverage.state, coverage.id LIMIT 11",
11356 &[
11357 project_value.clone(),
11358 Value::Text("src/Äuth.rs".to_string()),
11359 Value::Text("src/Äuth.rs0".to_string()),
11360 Value::Text("src/Äuth.rs".to_string()),
11361 Value::Text("src/Äuth.rs/".to_string()),
11362 ],
11363 &["idx_graph_coverage_scope_order"],
11364 false,
11365 "coverage path filter",
11366 )?;
11367 assert_coverage_discovery_plan(
11368 &store.connection,
11369 "EXPLAIN QUERY PLAN
11370 SELECT coverage.id FROM graph_coverage AS coverage
11371 WHERE coverage.project_instance_id = ? AND coverage.state = ?
11372 ORDER BY coverage.state, coverage.scope_path, coverage.id LIMIT 11",
11373 &[project_value.clone(), Value::Text("failed".to_string())],
11374 &["idx_graph_coverage_discovery_state"],
11375 false,
11376 "coverage state filter",
11377 )?;
11378 assert_coverage_discovery_plan(
11379 &store.connection,
11380 "EXPLAIN QUERY PLAN
11381 SELECT coverage.id FROM graph_coverage AS coverage
11382 WHERE coverage.project_instance_id = ? AND coverage.reason = ?
11383 ORDER BY coverage.reason, coverage.scope_path, coverage.id LIMIT 11",
11384 &[
11385 project_value.clone(),
11386 Value::Text("parser failed".to_string()),
11387 ],
11388 &["idx_graph_coverage_discovery_reason"],
11389 false,
11390 "coverage reason filter",
11391 )?;
11392 for (column, index, context) in [
11393 (
11394 "source_parser",
11395 "idx_source_parse_metadata_source_parser_path",
11396 "coverage parser filter",
11397 ),
11398 (
11399 "fact_parser",
11400 "idx_source_parse_metadata_fact_parser_path",
11401 "coverage provider filter",
11402 ),
11403 ] {
11404 assert_coverage_discovery_plan(
11405 &store.connection,
11406 &format!(
11407 "EXPLAIN QUERY PLAN
11408 SELECT coverage.id
11409 FROM source_parse_metadata AS metadata
11410 CROSS JOIN graph_coverage AS coverage
11411 ON coverage.scope_kind = 'path'
11412 AND coverage.scope_path = metadata.path
11413 WHERE coverage.project_instance_id = ?
11414 AND metadata.{column} = ?
11415 ORDER BY metadata.path, coverage.id LIMIT 11"
11416 ),
11417 &[
11418 project_value.clone(),
11419 Value::Text("tree-sitter".to_string()),
11420 ],
11421 &[index, "idx_graph_coverage_scope_order"],
11422 true,
11423 context,
11424 )?;
11425 }
11426 let all = store.repository_coverage_page(
11427 fixture.project,
11428 &RepositoryCoverageQuery {
11429 start_index: 0,
11430 limit: 2,
11431 path_prefix: None,
11432 parser: None,
11433 provider: None,
11434 relation: None,
11435 state: None,
11436 reason: None,
11437 },
11438 )?;
11439 require(
11440 all.truncated && all.rows.len() == 2,
11441 "coverage discovery did not use LIMIT + 1",
11442 )?;
11443 let all_states = store.repository_coverage_page(
11444 fixture.project,
11445 &RepositoryCoverageQuery {
11446 start_index: 0,
11447 limit: 10,
11448 path_prefix: None,
11449 parser: None,
11450 provider: None,
11451 relation: None,
11452 state: None,
11453 reason: None,
11454 },
11455 )?;
11456 for state in [
11457 CoverageState::Complete,
11458 CoverageState::Partial,
11459 CoverageState::Failed,
11460 CoverageState::Ignored,
11461 CoverageState::Oversized,
11462 CoverageState::Quarantined,
11463 CoverageState::Stale,
11464 ] {
11465 require(
11466 all_states
11467 .rows
11468 .iter()
11469 .any(|row| row.coverage.state() == state),
11470 &format!("coverage discovery omitted {state:?}"),
11471 )?;
11472 }
11473
11474 let exact_path = store.repository_coverage_page(
11475 fixture.project,
11476 &RepositoryCoverageQuery {
11477 start_index: 0,
11478 limit: 1,
11479 path_prefix: Some("src/Äuth.rs".to_string()),
11480 parser: None,
11481 provider: None,
11482 relation: None,
11483 state: None,
11484 reason: None,
11485 },
11486 )?;
11487 require(
11488 !exact_path.truncated && exact_path.rows.len() == 1,
11489 "exact coverage path admitted a lexical sibling",
11490 )?;
11491 require_eq(
11492 exact_path.rows[0].coverage.scope(),
11493 &CoverageScope::Path {
11494 path: RepositoryNodePath::new(Path::new("src/Äuth.rs"))?,
11495 },
11496 "exact coverage path scope",
11497 )?;
11498
11499 let parsed = store.repository_coverage_page(
11500 fixture.project,
11501 &RepositoryCoverageQuery {
11502 start_index: 0,
11503 limit: 10,
11504 path_prefix: Some("src/Äuth.rs".to_string()),
11505 parser: Some(ParserKind::TreeSitter),
11506 provider: Some(ParserKind::TreeSitter),
11507 relation: None,
11508 state: Some(CoverageState::Complete),
11509 reason: None,
11510 },
11511 )?;
11512 require_eq(&parsed.rows.len(), &1, "parser/provider filtered coverage")?;
11513 require_eq(
11514 &parsed.rows[0].parser,
11515 &Some(ParserKind::TreeSitter),
11516 "source parser provenance",
11517 )?;
11518 require_eq(
11519 &parsed.rows[0].provider,
11520 &Some(ParserKind::TreeSitter),
11521 "fact provider provenance",
11522 )?;
11523
11524 let failed_calls = store.repository_coverage_page(
11525 fixture.project,
11526 &RepositoryCoverageQuery {
11527 start_index: 0,
11528 limit: 10,
11529 path_prefix: None,
11530 parser: None,
11531 provider: None,
11532 relation: Some(GraphRelationKind::Legacy(RelationKind::Calls)),
11533 state: Some(CoverageState::Failed),
11534 reason: Some("parser failed".to_string()),
11535 },
11536 )?;
11537 require_eq(&failed_calls.rows.len(), &1, "combined coverage filters")?;
11538 require_eq(
11539 &failed_calls.rows[0].coverage.state(),
11540 &CoverageState::Failed,
11541 "failed coverage state",
11542 )?;
11543
11544 let absent = store.repository_coverage_page(
11545 fixture.project,
11546 &RepositoryCoverageQuery {
11547 start_index: 0,
11548 limit: 10,
11549 path_prefix: None,
11550 parser: None,
11551 provider: Some(ParserKind::Manifest),
11552 relation: None,
11553 state: None,
11554 reason: None,
11555 },
11556 )?;
11557 require(
11558 absent.rows.is_empty(),
11559 "provider filter returned a false match",
11560 )?;
11561 store.finish_index_read_snapshot()?;
11562 drop(store);
11563
11564 let writer = AtlasStore::open_for_project(&db_path, &project_root)?;
11565 writer.connection.execute(
11566 "UPDATE source_parse_metadata SET source_parser = 'corrupt-parser'
11567 WHERE path = 'src/Äuth.rs'",
11568 [],
11569 )?;
11570 drop(writer);
11571 let store = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
11572 let error = require_db_error(
11573 store.repository_coverage_page(
11574 fixture.project,
11575 &RepositoryCoverageQuery {
11576 start_index: 0,
11577 limit: 10,
11578 path_prefix: Some("src/Äuth.rs".to_string()),
11579 parser: None,
11580 provider: None,
11581 relation: None,
11582 state: None,
11583 reason: None,
11584 },
11585 ),
11586 "corrupt parser provenance was accepted",
11587 )?;
11588 require(
11589 matches!(error, DbError::InvalidEnum { .. }),
11590 &format!("unexpected parser corruption error: {error}"),
11591 )?;
11592 Ok(())
11593 }
11594
11595 #[test]
11596 fn navigation_connections_cover_families_prefixes_truncation_and_reopen()
11597 -> Result<(), Box<dyn Error>> {
11598 let temp = tempfile::tempdir()?;
11599 let root = temp.path().join("navigation-connections");
11600 fs::create_dir_all(&root)?;
11601 let db_path = root.join("projectatlas.db");
11602 let mut store = AtlasStore::open_for_project(&db_path, &root)?;
11603 let fixture = publish_navigation_fixture(&mut store, "navigation-connections")?;
11604 let owners = vec![
11605 RepositoryNavigationNode {
11606 path: fixture.api_path.clone(),
11607 kind: NodeKind::File,
11608 },
11609 RepositoryNavigationNode {
11610 path: "src/auth".to_string(),
11611 kind: NodeKind::Folder,
11612 },
11613 RepositoryNavigationNode {
11614 path: fixture.manifest_path.clone(),
11615 kind: NodeKind::File,
11616 },
11617 RepositoryNavigationNode {
11618 path: ".".to_string(),
11619 kind: NodeKind::Folder,
11620 },
11621 ];
11622 let pages = store.repository_navigation_connections(&owners, 2, 20)?;
11623 require_eq(&pages.len(), &owners.len(), "navigation owner count")?;
11624 let api = &pages[0];
11625 let families = api
11626 .counts
11627 .iter()
11628 .map(|count| count.kind)
11629 .collect::<Vec<_>>();
11630 require_eq(
11631 &families,
11632 &NAVIGATION_CONNECTION_FAMILIES
11633 .iter()
11634 .map(|&(kind, _, _)| kind)
11635 .collect::<Vec<_>>(),
11636 "all navigation families",
11637 )?;
11638 let calls = api
11639 .counts
11640 .iter()
11641 .find(|count| count.kind == RankedConnectionKind::Call)
11642 .ok_or_else(|| io::Error::other("call navigation count is missing"))?;
11643 require_eq(&calls.count, &2, "bounded high-degree call count")?;
11644 require_eq(&calls.truncated, &true, "high-degree call truncation")?;
11645 require_eq(&api.truncated, &true, "file aggregate truncation")?;
11646 require(
11647 api.connections.iter().any(|connection| {
11648 connection.kind == RankedConnectionKind::Test
11649 && connection.direction == RankedConnectionDirection::Inbound
11650 }),
11651 "inbound test connection was not projected",
11652 )?;
11653
11654 let folder = &pages[1];
11655 let folder_imports = folder
11656 .counts
11657 .iter()
11658 .find(|count| count.kind == RankedConnectionKind::Import)
11659 .ok_or_else(|| io::Error::other("folder import count is missing"))?;
11660 require_eq(
11661 &folder_imports.count,
11662 &1,
11663 "folder prefix import count excluding sibling authz.rs",
11664 )?;
11665 let manifest = &pages[2];
11666 require(
11667 manifest
11668 .counts
11669 .iter()
11670 .any(|count| count.kind == RankedConnectionKind::Package && count.count == 1),
11671 "manifest-owned package context is missing",
11672 )?;
11673 require(
11674 pages[3]
11675 .counts
11676 .iter()
11677 .any(|count| count.kind == RankedConnectionKind::Call),
11678 "root aggregate omitted bounded call context",
11679 )?;
11680
11681 let globally_sampled = store.repository_navigation_connections(&owners[..1], 10, 3)?;
11682 require_eq(
11683 &globally_sampled[0].connections.len(),
11684 &3,
11685 "global connection sample limit",
11686 )?;
11687 require_eq(
11688 &globally_sampled[0].counts.len(),
11689 &NAVIGATION_CONNECTION_FAMILIES.len(),
11690 "global sample retained all family counts",
11691 )?;
11692 require(
11693 globally_sampled[0]
11694 .counts
11695 .iter()
11696 .all(|count| !count.truncated),
11697 "global sample incorrectly reported family overflow",
11698 )?;
11699 require_eq(
11700 &globally_sampled[0].truncated,
11701 &true,
11702 "global sample truncation",
11703 )?;
11704
11705 drop(store);
11706 let reader = AtlasStore::open_read_only_for_project(&db_path, &root)?;
11707 require_eq(
11708 &reader.project_instance_id()?,
11709 &Some(fixture.project),
11710 "reopened navigation identity",
11711 )?;
11712 let reopened = reader.repository_navigation_connections(&owners[..1], 2, 20)?;
11713 require_eq(
11714 &reopened[0].counts,
11715 &api.counts,
11716 "reopened navigation counts",
11717 )?;
11718 Ok(())
11719 }
11720
11721 #[test]
11722 fn navigation_connections_use_owned_indexes_and_fail_all_or_error_on_corruption()
11723 -> Result<(), Box<dyn Error>> {
11724 let temp = tempfile::tempdir()?;
11725 let root = temp.path().join("navigation-plan-corruption");
11726 fs::create_dir_all(&root)?;
11727 let db_path = root.join("projectatlas.db");
11728 let mut store = AtlasStore::open_for_project(&db_path, &root)?;
11729 let fixture = publish_navigation_fixture(&mut store, "navigation-plan-corruption")?;
11730 let owner = RepositoryNavigationNode {
11731 path: fixture.api_path,
11732 kind: NodeKind::File,
11733 };
11734 for (direction, expected_index) in [
11735 (
11736 RankedConnectionDirection::Outbound,
11737 "idx_graph_relations_source_kind",
11738 ),
11739 (
11740 RankedConnectionDirection::Inbound,
11741 "idx_graph_relations_target_kind",
11742 ),
11743 ] {
11744 let mut values = Vec::new();
11745 let sql = navigation_connection_branch(
11746 0,
11747 &owner,
11748 RankedConnectionKind::Call,
11749 "legacy",
11750 "calls",
11751 direction,
11752 3,
11753 &mut values,
11754 );
11755 let mut statement = store
11756 .connection
11757 .prepare(&format!("EXPLAIN QUERY PLAN {sql}"))?;
11758 let details = statement
11759 .query_map(params_from_iter(values.iter()), |row| {
11760 row.get::<_, String>(3)
11761 })?
11762 .collect::<Result<Vec<_>, _>>()?;
11763 for expected in [
11764 expected_index,
11765 "idx_graph_entities_path",
11766 "idx_graph_entities_manifest_path",
11767 ] {
11768 require(
11769 details.iter().any(|detail| detail.contains(expected)),
11770 &format!("navigation plan missed {expected}: {details:?}"),
11771 )?;
11772 }
11773 require(
11774 details.iter().all(|detail| !detail.contains("SCAN graph_")),
11775 &format!("navigation plan scanned graph storage: {details:?}"),
11776 )?;
11777 }
11778 let folder_owner = RepositoryNavigationNode {
11779 path: "src/auth".to_string(),
11780 kind: NodeKind::Folder,
11781 };
11782 for (direction, expected_index) in [
11783 (
11784 RankedConnectionDirection::Outbound,
11785 "idx_graph_relations_source_kind",
11786 ),
11787 (
11788 RankedConnectionDirection::Inbound,
11789 "idx_graph_relations_target_kind",
11790 ),
11791 ] {
11792 let mut values = Vec::new();
11793 let sql = navigation_connection_branch(
11794 0,
11795 &folder_owner,
11796 RankedConnectionKind::Call,
11797 "legacy",
11798 "calls",
11799 direction,
11800 3,
11801 &mut values,
11802 );
11803 let details = store
11804 .connection
11805 .prepare(&format!("EXPLAIN QUERY PLAN {sql}"))?
11806 .query_map(params_from_iter(values.iter()), |row| {
11807 row.get::<_, String>(3)
11808 })?
11809 .collect::<Result<Vec<_>, _>>()?;
11810 for expected in [
11811 expected_index,
11812 "idx_graph_entities_path",
11813 "idx_graph_entities_manifest_path",
11814 ] {
11815 require(
11816 details.iter().any(|detail| detail.contains(expected)),
11817 &format!("folder navigation plan missed {expected}: {details:?}"),
11818 )?;
11819 }
11820 require(
11821 details.iter().all(|detail| !detail.contains("SCAN graph_")),
11822 &format!("folder navigation plan scanned graph storage: {details:?}"),
11823 )?;
11824 }
11825 let root_owner = RepositoryNavigationNode {
11826 path: ".".to_string(),
11827 kind: NodeKind::Folder,
11828 };
11829 let mut root_values = Vec::new();
11830 let root_sql = navigation_connection_branch(
11831 0,
11832 &root_owner,
11833 RankedConnectionKind::Call,
11834 "legacy",
11835 "calls",
11836 RankedConnectionDirection::Outbound,
11837 2,
11838 &mut root_values,
11839 );
11840 let root_details = store
11841 .connection
11842 .prepare(&format!("EXPLAIN QUERY PLAN {root_sql}"))?
11843 .query_map(params_from_iter(root_values.iter()), |row| {
11844 row.get::<_, String>(3)
11845 })?
11846 .collect::<Result<Vec<_>, _>>()?;
11847 require(
11848 root_details
11849 .iter()
11850 .any(|detail| detail.contains("idx_graph_relations_kind_order")),
11851 &format!("root navigation plan missed family index: {root_details:?}"),
11852 )?;
11853 require(
11854 root_details
11855 .iter()
11856 .all(|detail| !detail.contains("SCAN graph_")),
11857 &format!("root navigation plan scanned graph storage: {root_details:?}"),
11858 )?;
11859
11860 store
11861 .connection
11862 .execute_batch("PRAGMA ignore_check_constraints = ON")?;
11863 store.connection.execute(
11864 "UPDATE graph_relations
11865 SET resolution_status = 'unresolved', reference_text = 'broken-route'
11866 WHERE relation_scope = 'extended' AND relation_kind = 'routes-to'",
11867 [],
11868 )?;
11869 let error = require_db_error(
11870 store.repository_navigation_connections(&[owner], 4, 20),
11871 "corrupt navigation relation returned a partial page",
11872 )?;
11873 require(
11874 matches!(error, DbError::GraphRowShape { .. }),
11875 &format!("corrupt navigation relation returned {error}"),
11876 )?;
11877 Ok(())
11878 }
11879
11880 #[test]
11881 fn graph_queries_fail_closed_on_corrupt_normalized_rows() -> Result<(), Box<dyn Error>> {
11882 let temp = tempfile::tempdir()?;
11883 let project_root = temp.path().join("graph-corruption");
11884 let atlas_dir = project_root.join(".projectatlas");
11885 fs::create_dir_all(&atlas_dir)?;
11886 let db_path = atlas_dir.join("projectatlas.db");
11887 let mut store = AtlasStore::open_for_project(&db_path, &project_root)?;
11888 let fixture = publish_fixture(&mut store, "graph-corruption")?;
11889 drop(store);
11890 let store = AtlasStore::open_for_project(&db_path, &project_root)?;
11891 let source = fixture
11892 .entities
11893 .iter()
11894 .find(|entity| matches!(entity.selector(), EntitySelector::File { .. }))
11895 .ok_or_else(|| io::Error::other("source file fixture missing"))?;
11896 let folder = fixture
11897 .entities
11898 .iter()
11899 .find(|entity| matches!(entity.selector(), EntitySelector::Folder { .. }))
11900 .ok_or_else(|| io::Error::other("folder fixture missing"))?;
11901 let symbol = fixture
11902 .entities
11903 .iter()
11904 .find(|entity| matches!(entity.selector(), EntitySelector::Symbol { .. }))
11905 .ok_or_else(|| io::Error::other("symbol fixture missing"))?;
11906 let ambiguous = fixture
11907 .relations
11908 .iter()
11909 .find(|relation| matches!(relation.resolution(), RelationResolution::Ambiguous { .. }))
11910 .ok_or_else(|| io::Error::other("ambiguous relation fixture missing"))?;
11911 let source_digest = source.key().digest_bytes()?;
11912 let folder_digest = folder.key().digest_bytes()?;
11913 let symbol_digest = symbol.key().digest_bytes()?;
11914 let ambiguous_digest = ambiguous.key().digest_bytes()?;
11915 let source_canonical = store.connection.query_row(
11916 "SELECT canonical_identity FROM graph_entities WHERE entity_key = ?1",
11917 [&source_digest[..]],
11918 |row| row.get::<_, String>(0),
11919 )?;
11920 let symbol_canonical = store.connection.query_row(
11921 "SELECT canonical_identity FROM graph_entities WHERE entity_key = ?1",
11922 [&symbol_digest[..]],
11923 |row| row.get::<_, String>(0),
11924 )?;
11925 store
11926 .connection
11927 .execute_batch("PRAGMA ignore_check_constraints = ON")?;
11928
11929 store.connection.execute(
11930 "UPDATE graph_entities SET entity_kind = 'corrupt' WHERE entity_key = ?1",
11931 [&source_digest[..]],
11932 )?;
11933 {
11934 let reader = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
11935 let error = require_db_error(
11936 reader.repository_graph_entity(source.key()),
11937 "malformed graph enum was accepted",
11938 )?;
11939 require(
11940 matches!(error, DbError::InvalidEnum { .. }),
11941 &format!("unexpected malformed-enum error: {error}"),
11942 )?;
11943 reader.finish_index_read_snapshot()?;
11944 }
11945 store.connection.execute(
11946 "UPDATE graph_entities SET entity_kind = 'file' WHERE entity_key = ?1",
11947 [&source_digest[..]],
11948 )?;
11949
11950 store.connection.execute(
11951 "UPDATE graph_relations SET candidate_count = 0 WHERE relation_key = ?1",
11952 [&ambiguous_digest[..]],
11953 )?;
11954 {
11955 let reader = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
11956 let error = require_db_error(
11957 reader.repository_graph_relations(
11958 RepositoryGraphRelationQuery::Outbound {
11959 source: source.key().clone(),
11960 },
11961 10,
11962 ),
11963 "zero ambiguity count was accepted",
11964 )?;
11965 require(
11966 matches!(error, DbError::GraphRowShape { .. }),
11967 &format!("unexpected candidate-count error: {error}"),
11968 )?;
11969 let adjacency_error = require_db_error(
11970 reader.repository_graph_adjacency_page(
11971 &[source.key().clone()],
11972 RepositoryGraphDirection::Outbound,
11973 None,
11974 10,
11975 None,
11976 ),
11977 "corrupt adjacency relation returned a partial page",
11978 )?;
11979 require(
11980 matches!(adjacency_error, DbError::GraphRowShape { .. }),
11981 &format!("unexpected adjacency row-shape error: {adjacency_error}"),
11982 )?;
11983 reader.finish_index_read_snapshot()?;
11984 }
11985 store.connection.execute(
11986 "UPDATE graph_relations SET candidate_count = 2 WHERE relation_key = ?1",
11987 [&ambiguous_digest[..]],
11988 )?;
11989
11990 store.connection.execute(
11991 "UPDATE graph_relations SET resolution_status = 'resolved'
11992 WHERE relation_key = ?1",
11993 [&ambiguous_digest[..]],
11994 )?;
11995 {
11996 let reader = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
11997 let error = require_db_error(
11998 reader.repository_graph_relations(
11999 RepositoryGraphRelationQuery::Outbound {
12000 source: source.key().clone(),
12001 },
12002 10,
12003 ),
12004 "contradictory resolution columns were accepted",
12005 )?;
12006 require(
12007 matches!(error, DbError::GraphRowShape { .. }),
12008 &format!("unexpected resolution-shape error: {error}"),
12009 )?;
12010 reader.finish_index_read_snapshot()?;
12011 }
12012 store.connection.execute(
12013 "UPDATE graph_relations SET resolution_status = 'ambiguous'
12014 WHERE relation_key = ?1",
12015 [&ambiguous_digest[..]],
12016 )?;
12017
12018 store.connection.execute(
12019 "UPDATE graph_coverage SET total = 999
12020 WHERE scope_kind = 'project' AND relation_scope IS NULL",
12021 [],
12022 )?;
12023 {
12024 let reader = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
12025 let error = require_db_error(
12026 reader.repository_graph_coverage(fixture.project, &CoverageScope::Project, 10),
12027 "contradictory coverage total was accepted",
12028 )?;
12029 require(
12030 matches!(error, DbError::GraphRowShape { .. }),
12031 &format!("unexpected coverage-total error: {error}"),
12032 )?;
12033 reader.finish_index_read_snapshot()?;
12034 }
12035 store.connection.execute(
12036 "UPDATE graph_coverage SET total = covered + omitted
12037 WHERE scope_kind = 'project' AND relation_scope IS NULL",
12038 [],
12039 )?;
12040
12041 store.connection.execute(
12042 "UPDATE project_identity SET active_generation = 99 WHERE singleton = 1",
12043 [],
12044 )?;
12045 {
12046 let reader = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
12047 let error = require_db_error(
12048 reader.repository_graph_entity(source.key()),
12049 "mismatched typed graph generation was accepted",
12050 )?;
12051 require(
12052 matches!(error, DbError::GraphRowShape { .. }),
12053 &format!("unexpected typed-generation error: {error}"),
12054 )?;
12055 reader.finish_index_read_snapshot()?;
12056 }
12057 store.connection.execute(
12058 "UPDATE project_identity SET active_generation = 1 WHERE singleton = 1",
12059 [],
12060 )?;
12061
12062 store.connection.execute(
12063 "UPDATE graph_entities SET canonical_identity = 'different-collision-witness'
12064 WHERE entity_key = ?1",
12065 [&source_digest[..]],
12066 )?;
12067 {
12068 let reader = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
12069 let error = require_db_error(
12070 reader.repository_graph_entity(source.key()),
12071 "canonical collision witness was accepted",
12072 )?;
12073 require(
12074 matches!(error, DbError::GraphContract(_)),
12075 &format!("unexpected collision-witness error: {error}"),
12076 )?;
12077 reader.finish_index_read_snapshot()?;
12078 }
12079 store.connection.execute(
12080 "UPDATE graph_entities SET canonical_identity = ?1 WHERE entity_key = ?2",
12081 params![source_canonical, &source_digest[..]],
12082 )?;
12083
12084 store.connection.execute(
12085 "UPDATE graph_entities SET entity_key = zeroblob(32) WHERE entity_key = ?1",
12086 [&folder_digest[..]],
12087 )?;
12088 let folder_path = RepositoryNodePath::new(Path::new("src"))?;
12089 {
12090 let reader = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
12091 let error = require_db_error(
12092 reader.repository_graph_entities_by_path(fixture.project, &folder_path, 10),
12093 "invalid stable digest was accepted",
12094 )?;
12095 require(
12096 matches!(error, DbError::GraphContract(_)),
12097 &format!("unexpected stable-digest error: {error}"),
12098 )?;
12099 reader.finish_index_read_snapshot()?;
12100 }
12101 store.connection.execute(
12102 "UPDATE graph_entities SET entity_key = ?1 WHERE entity_key = zeroblob(32)",
12103 [&folder_digest[..]],
12104 )?;
12105
12106 store.connection.execute(
12107 "UPDATE graph_entities SET entity_key = X'01' WHERE entity_key = ?1",
12108 [&folder_digest[..]],
12109 )?;
12110 {
12111 let reader = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
12112 let error = require_db_error(
12113 reader.repository_graph_entities_by_path(fixture.project, &folder_path, 10),
12114 "short graph key blob was accepted",
12115 )?;
12116 require(
12117 matches!(
12118 error,
12119 DbError::InvalidBlobLength {
12120 field: "graph_entities.entity_key",
12121 expected: 32,
12122 found: 1
12123 }
12124 ),
12125 &format!("unexpected graph-key length error: {error}"),
12126 )?;
12127 reader.finish_index_read_snapshot()?;
12128 }
12129 store.connection.execute(
12130 "UPDATE graph_entities SET entity_key = ?1 WHERE entity_key = X'01'",
12131 [&folder_digest[..]],
12132 )?;
12133
12134 store.connection.execute(
12135 "UPDATE graph_entities SET canonical_identity = X'00' WHERE entity_key = ?1",
12136 [&symbol_digest[..]],
12137 )?;
12138 let source_path = RepositoryNodePath::new(Path::new("src/Äuth.rs"))?;
12139 {
12140 let reader = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
12141 let error = require_db_error(
12142 reader.repository_graph_entities_by_path(fixture.project, &source_path, 10),
12143 "later row conversion failure returned a successful partial page",
12144 )?;
12145 require(
12146 matches!(error, DbError::Sqlite(_)),
12147 &format!("unexpected later-row conversion error: {error}"),
12148 )?;
12149 reader.finish_index_read_snapshot()?;
12150 }
12151 store.connection.execute(
12152 "UPDATE graph_entities SET canonical_identity = ?1 WHERE entity_key = ?2",
12153 params![symbol_canonical, &symbol_digest[..]],
12154 )?;
12155 store
12156 .connection
12157 .execute_batch("PRAGMA ignore_check_constraints = OFF")?;
12158 Ok(())
12159 }
12160
12161 #[test]
12162 fn graph_publication_failure_rolls_back_text_graph_and_generation_for_readers()
12163 -> Result<(), Box<dyn Error>> {
12164 let temp = tempfile::tempdir()?;
12165 let project_root = temp.path().join("graph-publication");
12166 let atlas_dir = project_root.join(".projectatlas");
12167 fs::create_dir_all(&atlas_dir)?;
12168 let db_path = atlas_dir.join("projectatlas.db");
12169 let mut writer = AtlasStore::open_for_project(&db_path, &project_root)?;
12170 let fixture_v1 = publish_fixture(&mut writer, "graph-publication")?;
12171 let old_reader = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
12172 require_graph_projection(
12173 &old_reader,
12174 &fixture_v1,
12175 IndexGeneration::new(1),
12176 "fn verifyToken()",
12177 )?;
12178
12179 let missing_entity = GraphEntity::new(
12180 fixture_v1.project,
12181 EntitySelector::File {
12182 path: RepositoryFilePath::new(Path::new("src/missing.rs"))?,
12183 },
12184 IndexGeneration::new(2),
12185 )?;
12186 {
12187 let mut publication = writer.begin_index_publication("graph-publication")?;
12188 publication.replace_file_texts_for_paths(
12189 &["src/Äuth.rs".to_string()],
12190 &[IndexedFileText {
12191 path: "src/Äuth.rs".to_string(),
12192 content_hash: Some("hash-new".to_string()),
12193 byte_count: "fn verifyTokenUpdated()".len(),
12194 line_count: 1,
12195 content: "fn verifyTokenUpdated()".to_string(),
12196 }],
12197 )?;
12198 let error = require_db_error(
12199 publication.replace_repository_graph_for_paths(
12200 fixture_v1.project,
12201 &["src/Äuth.rs".to_string(), "src/missing.rs".to_string()],
12202 &[missing_entity],
12203 &[],
12204 &[],
12205 &[],
12206 ),
12207 "missing-node graph publication unexpectedly succeeded",
12208 )?;
12209 require(
12210 matches!(error, DbError::Sqlite(_)),
12211 &format!("unexpected late graph publication error: {error}"),
12212 )?;
12213 }
12214
12215 require_graph_projection(
12216 &writer,
12217 &fixture_v1,
12218 IndexGeneration::new(1),
12219 "fn verifyToken()",
12220 )?;
12221 let rolled_back_reader = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
12222 require_graph_projection(
12223 &rolled_back_reader,
12224 &fixture_v1,
12225 IndexGeneration::new(1),
12226 "fn verifyToken()",
12227 )?;
12228 require_graph_projection(
12229 &old_reader,
12230 &fixture_v1,
12231 IndexGeneration::new(1),
12232 "fn verifyToken()",
12233 )?;
12234 rolled_back_reader.finish_index_read_snapshot()?;
12235
12236 let project = writer
12237 .project_instance_id()?
12238 .ok_or_else(|| io::Error::other("bound writer identity is missing"))?;
12239 let fixture_v2 = graph_fixture(project, IndexGeneration::new(2))?;
12240 {
12241 let mut publication = writer.begin_index_publication("graph-publication")?;
12242 publication.replace_file_texts_for_paths(
12243 &["src/Äuth.rs".to_string()],
12244 &[IndexedFileText {
12245 path: "src/Äuth.rs".to_string(),
12246 content_hash: Some("hash-new".to_string()),
12247 byte_count: "fn verifyTokenUpdated()".len(),
12248 line_count: 1,
12249 content: "fn verifyTokenUpdated()".to_string(),
12250 }],
12251 )?;
12252 publication.replace_repository_graph(
12253 fixture_v2.project,
12254 &fixture_v2.entities,
12255 &fixture_v2.relations,
12256 &fixture_v2.occurrences,
12257 &fixture_v2.coverage,
12258 )?;
12259 publication.complete()?;
12260 }
12261
12262 require_graph_projection(
12263 &old_reader,
12264 &fixture_v1,
12265 IndexGeneration::new(1),
12266 "fn verifyToken()",
12267 )?;
12268 let new_reader = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
12269 require_graph_projection(
12270 &new_reader,
12271 &fixture_v2,
12272 IndexGeneration::new(2),
12273 "fn verifyTokenUpdated()",
12274 )?;
12275 new_reader.finish_index_read_snapshot()?;
12276 old_reader.finish_index_read_snapshot()?;
12277 Ok(())
12278 }
12279}