1use super::{
4 AtlasStore, DbError, DbResult, IndexPublicationGuard, IndexPublicationState, count_to_usize,
5 numbered_placeholders, with_sqlite_read_progress,
6};
7use crate::content_classification::parse_classification;
8use crate::derived_snapshot::{CapturedGraph, SnapshotBudget};
9use crate::project_identity::{
10 load_graph_generation, load_project_identity, load_project_root_identity,
11 prove_existing_root_equivalence, require_bound_project_identity, set_graph_generation,
12 set_project_identity, verify_project_identity,
13};
14use crate::schema;
15use projectatlas_core::graph::{
16 CanonicalResolutionKey, Completeness, ConfidenceClass, CoverageRecord, CoverageScope,
17 CoverageState, DocumentTargetUnresolvedReason, EntityResolutionKey, EntitySelector,
18 ExtendedRelationKind, ExternalSelector, GraphContractError, GraphEntity, GraphEntityKey,
19 GraphIdentityField, GraphIdentityRejection, GraphIdentityRejectionReason, GraphIdentityText,
20 GraphLimitKind, GraphLimits, GraphRelationKind, LogicalRelation, LogicalRelationKey,
21 PackageSelector, ProjectInstanceId, RelationDependencyKey, RelationOccurrence,
22 RelationResolution, RepositoryFilePath, RepositoryNodePath, ResolutionKeyDomain, SourceSpan,
23 SymbolSelector,
24};
25use projectatlas_core::language::{ContentClassification, ContentSelection};
26use projectatlas_core::symbols::{ParserKind, RelationKind, SymbolKind};
27use projectatlas_core::{
28 IndexGeneration, IndexWorkControl, IndexWorkStage, NodeKind, RankedConnection,
29 RankedConnectionCount, RankedConnectionDirection, RankedConnectionKind, RankedConnectionTarget,
30};
31use rusqlite::types::Value;
32use rusqlite::{
33 Connection, OpenFlags, OptionalExtension, Row, Transaction, params, params_from_iter,
34};
35use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
36use std::num::{NonZeroU32, NonZeroU64};
37use std::path::Path;
38
39pub struct RepositoryGraphStagingGuard<'store> {
41 transaction: Transaction<'store>,
43 project: ProjectInstanceId,
45 generation: IndexGeneration,
47}
48
49const GRAPH_STAGING_MARKER_KEY: &str = "repository_graph_staging";
51const GRAPH_STAGING_MARKER_VALUE: &str = "v1";
53const RESOLUTION_STATUS_RESOLVED: &str = "resolved";
55
56#[derive(Clone, Debug, Eq, PartialEq)]
58pub struct RepositoryGraphPage<T> {
59 pub rows: Vec<T>,
61 pub truncated: bool,
63}
64
65#[derive(Clone, Copy, Debug, Eq, PartialEq)]
67pub struct RepositoryGraphReadBudget {
68 requested_rows: NonZeroU32,
70 returned_rows: NonZeroU32,
72 decoded_bytes: NonZeroU64,
74 hydrated_entities: NonZeroU32,
76 hydrated_paths: NonZeroU32,
78}
79
80impl RepositoryGraphReadBudget {
81 pub const MAX_REQUESTED_ROWS: u32 = GraphLimits::MAX_ROWS;
83 pub const MAX_RETURNED_ROWS: u32 = GraphLimits::MAX_ROWS;
85 pub const MAX_DECODED_BYTES: u64 = 32 * 1_024 * 1_024;
87 pub const MAX_HYDRATED_ENTITIES: u32 = 2 * (GraphLimits::MAX_ROWS + 1);
89 pub const MAX_HYDRATED_PATHS: u32 = 2 * (GraphLimits::MAX_ROWS + 1);
91
92 pub fn new(
99 requested_rows: u32,
100 returned_rows: u32,
101 decoded_bytes: u64,
102 hydrated_entities: u32,
103 hydrated_paths: u32,
104 ) -> Result<Self, GraphContractError> {
105 if requested_rows == 0 || requested_rows > Self::MAX_REQUESTED_ROWS {
106 return Err(GraphContractError::InvalidLimits {
107 reason: "graph read requested-row budget is zero or above the batch ceiling",
108 });
109 }
110 if returned_rows == 0 || returned_rows > Self::MAX_RETURNED_ROWS {
111 return Err(GraphContractError::InvalidLimits {
112 reason: "graph read returned-row budget is zero or above the batch ceiling",
113 });
114 }
115 if decoded_bytes == 0 || decoded_bytes > Self::MAX_DECODED_BYTES {
116 return Err(GraphContractError::InvalidLimits {
117 reason: "graph read decoded-byte budget is zero or above the batch ceiling",
118 });
119 }
120 if hydrated_entities == 0 || hydrated_entities > Self::MAX_HYDRATED_ENTITIES {
121 return Err(GraphContractError::InvalidLimits {
122 reason: "graph read entity budget is zero or above the batch ceiling",
123 });
124 }
125 if hydrated_paths == 0 || hydrated_paths > Self::MAX_HYDRATED_PATHS {
126 return Err(GraphContractError::InvalidLimits {
127 reason: "graph read path budget is zero or above the batch ceiling",
128 });
129 }
130 Ok(Self {
131 requested_rows: NonZeroU32::new(requested_rows).ok_or(
132 GraphContractError::InvalidLimits {
133 reason: "graph read requested-row budget must be nonzero",
134 },
135 )?,
136 returned_rows: NonZeroU32::new(returned_rows).ok_or(
137 GraphContractError::InvalidLimits {
138 reason: "graph read returned-row budget must be nonzero",
139 },
140 )?,
141 decoded_bytes: NonZeroU64::new(decoded_bytes).ok_or(
142 GraphContractError::InvalidLimits {
143 reason: "graph read decoded-byte budget must be nonzero",
144 },
145 )?,
146 hydrated_entities: NonZeroU32::new(hydrated_entities).ok_or(
147 GraphContractError::InvalidLimits {
148 reason: "graph read entity budget must be nonzero",
149 },
150 )?,
151 hydrated_paths: NonZeroU32::new(hydrated_paths).ok_or(
152 GraphContractError::InvalidLimits {
153 reason: "graph read path budget must be nonzero",
154 },
155 )?,
156 })
157 }
158
159 #[must_use]
161 pub const fn requested_rows(self) -> u32 {
162 self.requested_rows.get()
163 }
164
165 #[must_use]
167 pub const fn returned_rows(self) -> u32 {
168 self.returned_rows.get()
169 }
170
171 #[must_use]
173 pub const fn decoded_bytes(self) -> u64 {
174 self.decoded_bytes.get()
175 }
176
177 #[must_use]
179 pub const fn hydrated_entities(self) -> u32 {
180 self.hydrated_entities.get()
181 }
182
183 #[must_use]
185 pub const fn hydrated_paths(self) -> u32 {
186 self.hydrated_paths.get()
187 }
188}
189
190#[derive(Clone, Copy, Debug, Eq, PartialEq)]
192pub struct RepositoryGraphReadWork {
193 pub requested_rows: u32,
195 pub returned_rows: u32,
197 pub decoded_bytes: u64,
199 pub hydrated_entities: u32,
201 pub hydrated_paths: u32,
203}
204
205#[derive(Clone, Debug, Eq, PartialEq)]
207pub struct RepositoryGraphReadBatch<T> {
208 pub rows: Vec<T>,
210 pub work: RepositoryGraphReadWork,
212}
213
214#[derive(Clone, Debug, Eq, PartialEq)]
216pub struct RepositoryGraphReadPage<T> {
217 pub page: RepositoryGraphPage<T>,
219 pub work: RepositoryGraphReadWork,
221}
222
223#[derive(Clone, Debug, Eq, PartialEq)]
225pub struct RepositoryGraphReadPages<T> {
226 pub pages: Vec<RepositoryGraphPage<T>>,
228 pub work: RepositoryGraphReadWork,
230}
231
232#[derive(Clone, Debug, Eq, PartialEq)]
234pub struct RepositoryCoverageQuery {
235 pub start_index: u32,
237 pub limit: u32,
239 pub path_prefix: Option<String>,
241 pub parser: Option<ParserKind>,
243 pub provider: Option<ParserKind>,
245 pub relation: Option<GraphRelationKind>,
247 pub state: Option<CoverageState>,
249 pub reason: Option<String>,
251}
252
253#[derive(Clone, Debug, Eq, PartialEq)]
255pub struct RepositoryCoverageRow {
256 pub coverage: CoverageRecord,
258 pub parser: Option<ParserKind>,
260 pub provider: Option<ParserKind>,
262}
263
264#[derive(Clone, Debug, Eq, PartialEq)]
266pub struct RepositoryNavigationNode {
267 pub path: String,
269 pub kind: NodeKind,
271}
272
273#[derive(Clone, Debug, Eq, PartialEq)]
275pub struct RepositoryNavigationConnections {
276 pub path: String,
278 pub counts: Vec<RankedConnectionCount>,
280 pub connections: Vec<RankedConnection>,
282 pub truncated: bool,
284}
285
286const NAVIGATION_CONNECTION_OWNER_CHUNK: usize = 8;
288
289const NAVIGATION_CONNECTION_FAMILIES: &[(RankedConnectionKind, &str, &str)] = &[
291 (RankedConnectionKind::Package, "legacy", "depends-on"),
292 (RankedConnectionKind::Import, "legacy", "imports"),
293 (RankedConnectionKind::Call, "legacy", "calls"),
294 (RankedConnectionKind::Reference, "extended", "references"),
295 (RankedConnectionKind::Test, "extended", "tests"),
296 (RankedConnectionKind::Route, "extended", "routes-to"),
297 (RankedConnectionKind::Config, "extended", "configures"),
298];
299
300#[derive(Clone, Copy, Debug, Eq, PartialEq)]
302pub struct RepositoryAffectedSourceFootprint {
303 pub rows: u64,
305 pub retained_bytes: u64,
307 pub truncated: bool,
309}
310
311#[derive(Clone, Debug, Eq, PartialEq)]
313pub struct RepositoryResolutionCandidate {
314 key: CanonicalResolutionKey,
316 entity: GraphEntity,
318}
319
320impl RepositoryResolutionCandidate {
321 #[must_use]
323 pub const fn key(&self) -> &CanonicalResolutionKey {
324 &self.key
325 }
326
327 #[must_use]
329 pub const fn entity(&self) -> &GraphEntity {
330 &self.entity
331 }
332}
333
334#[derive(Clone, Debug, Eq, PartialEq)]
336pub enum RepositoryGraphRelationQuery {
337 Outbound {
339 source: GraphEntityKey,
341 },
342 Inbound {
344 target: GraphEntityKey,
346 },
347 Family {
349 relation: GraphRelationKind,
351 },
352}
353
354#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
356pub enum RepositoryGraphDirection {
357 Outbound,
359 Inbound,
361}
362
363#[derive(Clone, Debug, Eq, PartialEq)]
365pub struct RepositoryGraphRelationRow {
366 pub relation: LogicalRelation,
368 pub source: GraphEntity,
370 pub target: Option<GraphEntity>,
372 pub document_unresolved_reason: Option<DocumentTargetUnresolvedReason>,
374}
375
376#[derive(Clone, Debug, Eq, PartialEq)]
378pub struct RepositoryGraphClassifiedRelationRow {
379 pub detail: RepositoryGraphRelationRow,
381 pub source_classification: Option<ContentClassification>,
383}
384
385#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
387pub struct RepositoryGraphAdjacencyContinuation {
388 project: ProjectInstanceId,
390 generation: IndexGeneration,
392 direction: RepositoryGraphDirection,
394 relation: Option<GraphRelationKind>,
396 #[serde(default, skip_serializing_if = "is_default")]
398 resolved_only: bool,
399 #[serde(default, skip_serializing_if = "is_default")]
401 include_documents: bool,
402 frontier: Vec<[u8; 32]>,
404 frontier_index: u32,
406 relation_scope: String,
408 relation_kind: String,
410 relation_key: [u8; 32],
412}
413
414#[derive(Clone, Debug, Eq, PartialEq)]
416pub struct RepositoryGraphAdjacencyRow {
417 pub frontier_index: u32,
419 pub frontier: GraphEntityKey,
421 pub direction: RepositoryGraphDirection,
423 pub detail: RepositoryGraphRelationRow,
425}
426
427#[derive(Clone, Debug, Eq, PartialEq)]
429pub struct RepositoryGraphAdjacencyPage {
430 pub rows: Vec<RepositoryGraphAdjacencyRow>,
432 pub truncated: bool,
434 pub continuation: Option<RepositoryGraphAdjacencyContinuation>,
436}
437
438#[derive(Clone, Debug, Eq, PartialEq)]
440pub struct RepositoryGraphAdjacencyReadPage {
441 pub page: RepositoryGraphAdjacencyPage,
443 pub work: RepositoryGraphReadWork,
445}
446
447pub const MAX_REPOSITORY_GRAPH_FRONTIER: usize = 256;
449
450const MAX_REPOSITORY_GRAPH_ADJACENCY_WORK_ROWS: usize = GraphLimits::MAX_ROWS as usize + 1;
452
453const GRAPH_ENTITY_HYDRATION_CHUNK: usize = 128;
455
456struct EntityRow {
458 key: Vec<u8>,
460 project: Vec<u8>,
462 canonical: String,
464 kind: String,
466 repository_path: Option<String>,
468 package_manager: Option<String>,
470 package_name: Option<String>,
472 manifest_path: Option<String>,
474 symbol_name: Option<String>,
476 symbol_kind: Option<String>,
478 symbol_parent: Option<String>,
480 symbol_signature: Option<String>,
482 external_system: Option<String>,
484 external_identity: Option<String>,
486}
487
488struct RelationRow {
490 key: Vec<u8>,
492 project: Vec<u8>,
494 canonical: String,
496 source: Vec<u8>,
498 relation_scope: String,
500 relation_kind: String,
502 resolution_status: String,
504 target: Option<Vec<u8>>,
506 reference: Option<String>,
508 candidate_count: Option<i64>,
510 document_unresolved_reason: Option<String>,
512 confidence: String,
514 completeness: String,
516}
517
518struct ClassifiedRelationRow {
520 relation: RelationRow,
522 source_classification: Option<String>,
524}
525
526struct AdjacencyRelationRow {
528 frontier_index: u32,
530 relation: RelationRow,
532}
533
534struct OccurrenceRow {
536 relation: Vec<u8>,
538 file_path: String,
540 start_line: i64,
542 start_column: i64,
544 end_line: i64,
546 end_column: i64,
548}
549
550struct CoverageRow {
552 project: Vec<u8>,
554 scope_kind: String,
556 scope_path: Option<String>,
558 relation_scope: Option<String>,
560 relation_kind: Option<String>,
562 state: String,
564 total: i64,
566 covered: i64,
568 omitted: i64,
570 reason: Option<String>,
572 reached_limit: Option<String>,
574 parser: Option<String>,
576 provider: Option<String>,
578}
579
580pub(crate) struct RepositoryGraphReadMeter {
582 budget: RepositoryGraphReadBudget,
584 requested_rows: u32,
586 decoded_bytes: u64,
588 hydrated_entities: u32,
590 hydrated_paths: HashSet<String>,
592}
593
594impl RepositoryGraphReadMeter {
595 pub(crate) fn new(budget: RepositoryGraphReadBudget, requested_rows: usize) -> DbResult<Self> {
597 let requested_rows =
598 u32::try_from(requested_rows).map_err(|_source| GraphContractError::InvalidLimits {
599 reason: "graph read requested-row count overflowed",
600 })?;
601 if requested_rows > budget.requested_rows() {
602 return Err(GraphContractError::InvalidLimits {
603 reason: "graph read requested rows exceed the batch budget",
604 }
605 .into());
606 }
607 Ok(Self {
608 budget,
609 requested_rows,
610 decoded_bytes: 0,
611 hydrated_entities: 0,
612 hydrated_paths: HashSet::new(),
613 })
614 }
615
616 pub(crate) fn record_decoded_bytes(&mut self, bytes: u64) -> DbResult<()> {
618 let decoded_bytes =
619 self.decoded_bytes
620 .checked_add(bytes)
621 .ok_or(GraphContractError::InvalidLimits {
622 reason: "graph read decoded-byte accounting overflowed",
623 })?;
624 if decoded_bytes > self.budget.decoded_bytes() {
625 return Err(GraphContractError::InvalidLimits {
626 reason: "graph read decoded bytes exceed the batch budget",
627 }
628 .into());
629 }
630 self.decoded_bytes = decoded_bytes;
631 Ok(())
632 }
633
634 fn record_entity(&mut self, entity: &GraphEntity) -> DbResult<()> {
636 let hydrated_entities =
637 self.hydrated_entities
638 .checked_add(1)
639 .ok_or(GraphContractError::InvalidLimits {
640 reason: "graph read entity accounting overflowed",
641 })?;
642 if hydrated_entities > self.budget.hydrated_entities() {
643 return Err(GraphContractError::InvalidLimits {
644 reason: "graph read hydrated entities exceed the batch budget",
645 }
646 .into());
647 }
648 if let Some(path) = graph_entity_purpose_owner(entity) {
649 self.record_hydrated_path(path)?;
650 }
651 self.hydrated_entities = hydrated_entities;
652 Ok(())
653 }
654
655 pub(crate) fn record_hydrated_path(&mut self, path: &str) -> DbResult<()> {
657 if self.hydrated_paths.contains(path) {
658 return Ok(());
659 }
660 let hydrated_paths = u32::try_from(self.hydrated_paths.len()).map_err(|_source| {
661 GraphContractError::InvalidLimits {
662 reason: "graph read path accounting overflowed",
663 }
664 })?;
665 if hydrated_paths >= self.budget.hydrated_paths() {
666 return Err(GraphContractError::InvalidLimits {
667 reason: "graph read hydrated paths exceed the batch budget",
668 }
669 .into());
670 }
671 self.hydrated_paths.insert(path.to_string());
672 Ok(())
673 }
674
675 pub(crate) fn finish(self, returned_rows: usize) -> DbResult<RepositoryGraphReadWork> {
677 let returned_rows =
678 u32::try_from(returned_rows).map_err(|_source| GraphContractError::InvalidLimits {
679 reason: "graph read returned-row count overflowed",
680 })?;
681 if returned_rows > self.budget.returned_rows() {
682 return Err(GraphContractError::InvalidLimits {
683 reason: "graph read returned rows exceed the batch budget",
684 }
685 .into());
686 }
687 let hydrated_paths = u32::try_from(self.hydrated_paths.len()).map_err(|_source| {
688 GraphContractError::InvalidLimits {
689 reason: "graph read path accounting overflowed",
690 }
691 })?;
692 Ok(RepositoryGraphReadWork {
693 requested_rows: self.requested_rows,
694 returned_rows,
695 decoded_bytes: self.decoded_bytes,
696 hydrated_entities: self.hydrated_entities,
697 hydrated_paths,
698 })
699 }
700}
701
702struct NavigationConnectionRow {
704 owner_index: usize,
706 kind: RankedConnectionKind,
708 direction: RankedConnectionDirection,
710 relation_key: Vec<u8>,
712 resolution_status: String,
714 reference: Option<String>,
716 entity_kind: Option<String>,
718 repository_path: Option<String>,
720 package_manager: Option<String>,
722 package_name: Option<String>,
724 manifest_path: Option<String>,
726 symbol_name: Option<String>,
728 external_system: Option<String>,
730 external_identity: Option<String>,
732}
733
734fn empty_navigation_connections(path: &str) -> RepositoryNavigationConnections {
736 RepositoryNavigationConnections {
737 path: path.to_string(),
738 counts: Vec::new(),
739 connections: Vec::new(),
740 truncated: false,
741 }
742}
743
744fn collect_navigation_connection_rows(
746 connection: &Connection,
747 owners: &[RepositoryNavigationNode],
748 family_limit_plus_one: i64,
749) -> DbResult<Vec<NavigationConnectionRow>> {
750 let mut branches = Vec::with_capacity(owners.len() * NAVIGATION_CONNECTION_FAMILIES.len() * 2);
751 let mut values = Vec::new();
752 for (owner_index, owner) in owners.iter().enumerate() {
753 for &(kind, scope, relation) in NAVIGATION_CONNECTION_FAMILIES {
754 branches.push(navigation_connection_branch(
755 owner_index,
756 owner,
757 kind,
758 scope,
759 relation,
760 RankedConnectionDirection::Outbound,
761 family_limit_plus_one,
762 &mut values,
763 ));
764 if owner.kind != NodeKind::Folder || owner.path != "." {
765 branches.push(navigation_connection_branch(
766 owner_index,
767 owner,
768 kind,
769 scope,
770 relation,
771 RankedConnectionDirection::Inbound,
772 family_limit_plus_one,
773 &mut values,
774 ));
775 }
776 }
777 }
778 let sql = branches.join(" UNION ALL ");
779 let mut statement = connection.prepare(&sql)?;
780 let mut rows = statement.query(params_from_iter(values))?;
781 let mut collected = Vec::new();
782 while let Some(row) = rows.next()? {
783 collected.push(navigation_connection_row(row)?);
784 }
785 Ok(collected)
786}
787
788fn navigation_connection_branch(
790 owner_index: usize,
791 owner: &RepositoryNavigationNode,
792 kind: RankedConnectionKind,
793 scope: &'static str,
794 relation: &'static str,
795 direction: RankedConnectionDirection,
796 family_limit_plus_one: i64,
797 values: &mut Vec<Value>,
798) -> String {
799 values.push(Value::Integer(owner_index as i64));
800 values.push(Value::Text(
801 navigation_connection_kind_name(kind).to_string(),
802 ));
803 if owner.kind == NodeKind::Folder
804 && owner.path == "."
805 && direction == RankedConnectionDirection::Outbound
806 {
807 values.push(Value::Text(scope.to_string()));
808 values.push(Value::Text(relation.to_string()));
809 values.push(Value::Integer(family_limit_plus_one));
810 return "SELECT * FROM (
811 SELECT ? AS owner_index, ? AS expected_kind, 'outbound' AS direction,
812 r.relation_key, r.relation_scope, r.relation_kind,
813 r.resolution_status, r.reference_text,
814 related.entity_kind, related.repository_path,
815 related.package_manager, related.package_name, related.manifest_path,
816 related.symbol_name, related.external_system, related.external_identity
817 FROM graph_relations r INDEXED BY idx_graph_relations_kind_order
818 LEFT JOIN graph_entities related
819 ON related.entity_key = r.target_entity_key
820 WHERE r.relation_scope = ? AND r.relation_kind = ?
821 ORDER BY r.relation_key
822 LIMIT ?
823 )"
824 .to_string();
825 }
826 let (relation_key, related_key, index, direction_name) = match direction {
827 RankedConnectionDirection::Outbound => (
828 "source_entity_key",
829 "target_entity_key",
830 "idx_graph_relations_source_kind",
831 "outbound",
832 ),
833 RankedConnectionDirection::Inbound => (
834 "target_entity_key",
835 "source_entity_key",
836 "idx_graph_relations_target_kind",
837 "inbound",
838 ),
839 };
840 let owned = navigation_owned_entity_sql(owner, values);
841 let exclude_internal = if direction == RankedConnectionDirection::Inbound {
842 let owned_sources = navigation_owned_entity_sql(owner, values);
843 format!(" AND r.source_entity_key NOT IN ({owned_sources})")
844 } else {
845 String::new()
846 };
847 values.push(Value::Text(scope.to_string()));
848 values.push(Value::Text(relation.to_string()));
849 values.push(Value::Integer(family_limit_plus_one));
850 format!(
851 "SELECT * FROM (
852 SELECT ? AS owner_index, ? AS expected_kind, '{direction_name}' AS direction,
853 r.relation_key, r.relation_scope, r.relation_kind,
854 r.resolution_status, r.reference_text,
855 related.entity_kind, related.repository_path,
856 related.package_manager, related.package_name, related.manifest_path,
857 related.symbol_name, related.external_system, related.external_identity
858 FROM graph_relations r INDEXED BY {index}
859 LEFT JOIN graph_entities related ON related.entity_key = r.{related_key}
860 WHERE r.{relation_key} IN ({owned})
861 {exclude_internal}
862 AND r.relation_scope = ? AND r.relation_kind = ?
863 ORDER BY r.relation_key
864 LIMIT ?
865 )"
866 )
867}
868
869fn navigation_owned_entity_sql(
871 owner: &RepositoryNavigationNode,
872 values: &mut Vec<Value>,
873) -> String {
874 match owner.kind {
875 NodeKind::File => {
876 values.push(Value::Text(owner.path.clone()));
877 values.push(Value::Text(owner.path.clone()));
878 "SELECT entity_key
879 FROM graph_entities INDEXED BY idx_graph_entities_path
880 WHERE repository_path = ?
881 UNION
882 SELECT entity_key
883 FROM graph_entities INDEXED BY idx_graph_entities_manifest_path
884 WHERE manifest_path = ?"
885 .to_string()
886 }
887 NodeKind::Folder if owner.path == "." => "SELECT entity_key
888 FROM graph_entities INDEXED BY idx_graph_entities_path
889 WHERE repository_path IS NOT NULL
890 UNION
891 SELECT entity_key
892 FROM graph_entities INDEXED BY idx_graph_entities_manifest_path
893 WHERE manifest_path IS NOT NULL"
894 .to_string(),
895 NodeKind::Folder => {
896 let lower = format!("{}/", owner.path);
897 let upper = format!("{}0", owner.path);
898 values.push(Value::Text(owner.path.clone()));
899 values.push(Value::Text(lower.clone()));
900 values.push(Value::Text(upper.clone()));
901 values.push(Value::Text(owner.path.clone()));
902 values.push(Value::Text(lower));
903 values.push(Value::Text(upper));
904 "SELECT entity_key
905 FROM graph_entities INDEXED BY idx_graph_entities_path
906 WHERE repository_path = ?
907 UNION
908 SELECT entity_key
909 FROM graph_entities INDEXED BY idx_graph_entities_path
910 WHERE repository_path >= ? AND repository_path < ?
911 UNION
912 SELECT entity_key
913 FROM graph_entities INDEXED BY idx_graph_entities_manifest_path
914 WHERE manifest_path = ?
915 UNION
916 SELECT entity_key
917 FROM graph_entities INDEXED BY idx_graph_entities_manifest_path
918 WHERE manifest_path >= ? AND manifest_path < ?"
919 .to_string()
920 }
921 }
922}
923
924fn navigation_connection_row(row: &Row<'_>) -> DbResult<NavigationConnectionRow> {
926 let owner_index = count_to_usize("navigation_connection.owner_index", row.get(0)?)?;
927 let expected_kind: String = row.get(1)?;
928 let direction = match row.get::<_, String>(2)?.as_str() {
929 "outbound" => RankedConnectionDirection::Outbound,
930 "inbound" => RankedConnectionDirection::Inbound,
931 _ => {
932 return Err(DbError::GraphRowShape {
933 table: "graph_relations",
934 reason: "navigation relation direction is invalid",
935 });
936 }
937 };
938 let relation_key: Vec<u8> = row.get(3)?;
939 if relation_key.len() != 32 {
940 return Err(DbError::InvalidBlobLength {
941 field: "graph_relations.relation_key",
942 expected: 32,
943 found: relation_key.len(),
944 });
945 }
946 let relation_scope: String = row.get(4)?;
947 let relation_kind: String = row.get(5)?;
948 let kind = navigation_connection_kind(&relation_scope, &relation_kind)?;
949 if navigation_connection_kind_name(kind) != expected_kind {
950 return Err(DbError::GraphRowShape {
951 table: "graph_relations",
952 reason: "navigation relation family does not match its query branch",
953 });
954 }
955 let resolution_status: String = row.get(6)?;
956 if !matches!(
957 resolution_status.as_str(),
958 "resolved" | "ambiguous" | "unresolved" | "external"
959 ) {
960 return Err(DbError::InvalidEnum {
961 field: "graph_relations.resolution_status",
962 value: resolution_status,
963 });
964 }
965 Ok(NavigationConnectionRow {
966 owner_index,
967 kind,
968 direction,
969 relation_key,
970 resolution_status,
971 reference: row.get(7)?,
972 entity_kind: row.get(8)?,
973 repository_path: row.get(9)?,
974 package_manager: row.get(10)?,
975 package_name: row.get(11)?,
976 manifest_path: row.get(12)?,
977 symbol_name: row.get(13)?,
978 external_system: row.get(14)?,
979 external_identity: row.get(15)?,
980 })
981}
982
983fn navigation_connection_pages(
985 owners: &[RepositoryNavigationNode],
986 mut rows: Vec<NavigationConnectionRow>,
987 family_limit: usize,
988 sample_limit: usize,
989) -> DbResult<Vec<RepositoryNavigationConnections>> {
990 rows.sort_by(|left, right| {
991 left.owner_index
992 .cmp(&right.owner_index)
993 .then_with(|| left.kind.cmp(&right.kind))
994 .then_with(|| {
995 navigation_direction_order(left.direction)
996 .cmp(&navigation_direction_order(right.direction))
997 })
998 .then_with(|| left.relation_key.cmp(&right.relation_key))
999 });
1000 let mut grouped =
1001 BTreeMap::<(usize, RankedConnectionKind), Vec<NavigationConnectionRow>>::new();
1002 for row in rows {
1003 if row.owner_index >= owners.len() {
1004 return Err(DbError::GraphRowShape {
1005 table: "graph_relations",
1006 reason: "navigation relation owner index is outside its request chunk",
1007 });
1008 }
1009 grouped
1010 .entry((row.owner_index, row.kind))
1011 .or_default()
1012 .push(row);
1013 }
1014
1015 owners
1016 .iter()
1017 .enumerate()
1018 .map(|(owner_index, owner)| {
1019 let mut page = empty_navigation_connections(&owner.path);
1020 for &(kind, _, _) in NAVIGATION_CONNECTION_FAMILIES {
1021 let Some(rows) = grouped.remove(&(owner_index, kind)) else {
1022 continue;
1023 };
1024 let mut seen = HashSet::new();
1025 let unique = rows
1026 .into_iter()
1027 .filter(|row| seen.insert(row.relation_key.clone()))
1028 .collect::<Vec<_>>();
1029 let truncated = unique.len() > family_limit;
1030 let count = unique.len().min(family_limit);
1031 page.counts.push(RankedConnectionCount {
1032 kind,
1033 count,
1034 truncated,
1035 });
1036 for (family_index, row) in unique.into_iter().enumerate() {
1037 let target = navigation_connection_target(&row)?;
1038 if family_index < family_limit && page.connections.len() < sample_limit {
1039 page.connections.push(RankedConnection {
1040 kind,
1041 direction: row.direction,
1042 target,
1043 });
1044 } else if family_index < family_limit {
1045 page.truncated = true;
1046 }
1047 }
1048 page.truncated |= truncated;
1049 }
1050 Ok(page)
1051 })
1052 .collect()
1053}
1054
1055fn navigation_connection_target(row: &NavigationConnectionRow) -> DbResult<RankedConnectionTarget> {
1057 if row.direction == RankedConnectionDirection::Outbound
1058 && matches!(row.resolution_status.as_str(), "ambiguous" | "unresolved")
1059 {
1060 let reference = row.reference.clone().ok_or(DbError::GraphRowShape {
1061 table: "graph_relations",
1062 reason: "unresolved navigation relation is missing its reference",
1063 })?;
1064 if row.entity_kind.is_some() {
1065 return Err(DbError::GraphRowShape {
1066 table: "graph_relations",
1067 reason: "unresolved navigation relation retained a target entity",
1068 });
1069 }
1070 return Ok(RankedConnectionTarget::Unresolved { reference });
1071 }
1072 if !matches!(row.resolution_status.as_str(), "resolved" | "external") {
1073 return Err(DbError::GraphRowShape {
1074 table: "graph_relations",
1075 reason: "inbound navigation relation has no resolved target",
1076 });
1077 }
1078 match row.entity_kind.as_deref() {
1079 Some("project") => Ok(RankedConnectionTarget::Local {
1080 path: ".".to_string(),
1081 symbol: None,
1082 }),
1083 Some("folder" | "file") => Ok(RankedConnectionTarget::Local {
1084 path: required_navigation_target(
1085 row.repository_path.as_deref(),
1086 "local navigation target is missing its repository path",
1087 )?,
1088 symbol: None,
1089 }),
1090 Some("symbol") => Ok(RankedConnectionTarget::Local {
1091 path: required_navigation_target(
1092 row.repository_path.as_deref(),
1093 "symbol navigation target is missing its repository path",
1094 )?,
1095 symbol: Some(required_navigation_target(
1096 row.symbol_name.as_deref(),
1097 "symbol navigation target is missing its declaration name",
1098 )?),
1099 }),
1100 Some("package") => Ok(RankedConnectionTarget::Package {
1101 manager: required_navigation_target(
1102 row.package_manager.as_deref(),
1103 "package navigation target is missing its manager",
1104 )?,
1105 name: required_navigation_target(
1106 row.package_name.as_deref(),
1107 "package navigation target is missing its name",
1108 )?,
1109 manifest: required_navigation_target(
1110 row.manifest_path.as_deref(),
1111 "package navigation target is missing its manifest",
1112 )?,
1113 }),
1114 Some("external") => Ok(RankedConnectionTarget::External {
1115 system: required_navigation_target(
1116 row.external_system.as_deref(),
1117 "external navigation target is missing its system",
1118 )?,
1119 identity: required_navigation_target(
1120 row.external_identity.as_deref(),
1121 "external navigation target is missing its identity",
1122 )?,
1123 }),
1124 Some(_) => Err(DbError::GraphRowShape {
1125 table: "graph_entities",
1126 reason: "navigation endpoint has an unsupported entity kind",
1127 }),
1128 None => Err(DbError::GraphRowShape {
1129 table: "graph_relations",
1130 reason: "resolved navigation relation target is missing",
1131 }),
1132 }
1133}
1134
1135fn required_navigation_target(value: Option<&str>, reason: &'static str) -> DbResult<String> {
1137 value
1138 .filter(|value| !value.is_empty())
1139 .map(str::to_string)
1140 .ok_or(DbError::GraphRowShape {
1141 table: "graph_entities",
1142 reason,
1143 })
1144}
1145
1146fn navigation_connection_kind(scope: &str, relation: &str) -> DbResult<RankedConnectionKind> {
1148 NAVIGATION_CONNECTION_FAMILIES
1149 .iter()
1150 .find_map(|&(kind, expected_scope, expected_relation)| {
1151 (scope == expected_scope && relation == expected_relation).then_some(kind)
1152 })
1153 .ok_or(DbError::GraphRowShape {
1154 table: "graph_relations",
1155 reason: "relation family is not available to navigation enrichment",
1156 })
1157}
1158
1159const fn navigation_connection_kind_name(kind: RankedConnectionKind) -> &'static str {
1161 match kind {
1162 RankedConnectionKind::Package => "package",
1163 RankedConnectionKind::Import => "import",
1164 RankedConnectionKind::Call => "call",
1165 RankedConnectionKind::Reference => "reference",
1166 RankedConnectionKind::Test => "test",
1167 RankedConnectionKind::Route => "route",
1168 RankedConnectionKind::Config => "config",
1169 }
1170}
1171
1172const fn navigation_direction_order(direction: RankedConnectionDirection) -> u8 {
1174 match direction {
1175 RankedConnectionDirection::Outbound => 0,
1176 RankedConnectionDirection::Inbound => 1,
1177 }
1178}
1179
1180impl AtlasStore {
1181 pub fn repository_navigation_connections(
1193 &self,
1194 owners: &[RepositoryNavigationNode],
1195 family_limit: u32,
1196 sample_limit: usize,
1197 ) -> DbResult<Vec<RepositoryNavigationConnections>> {
1198 let family_limit_plus_one = validated_limit_plus_one(
1199 family_limit,
1200 GraphLimits::MAX_ROWS,
1201 "navigation connection rows must be nonzero and within the product ceiling",
1202 )?;
1203 if sample_limit == 0 || sample_limit > GraphLimits::MAX_ROWS as usize {
1204 return Err(GraphContractError::InvalidLimits {
1205 reason: "navigation connection sample must be nonzero and within the product ceiling",
1206 }
1207 .into());
1208 }
1209 for owner in owners {
1210 match owner.kind {
1211 NodeKind::Folder => {
1212 RepositoryNodePath::new(Path::new(&owner.path))?;
1213 }
1214 NodeKind::File => {
1215 RepositoryFilePath::new(Path::new(&owner.path))?;
1216 }
1217 }
1218 }
1219 if owners.is_empty() {
1220 return Ok(Vec::new());
1221 }
1222 if self.repository_graph_generation()?.is_none() {
1223 return Ok(owners
1224 .iter()
1225 .map(|owner| empty_navigation_connections(&owner.path))
1226 .collect());
1227 }
1228 let project = load_project_identity(&self.connection)?
1229 .ok_or(DbError::ProjectInstanceIdentityMissing)?;
1230 require_bound_project_identity(&self.connection, project)?;
1231
1232 let mut result = Vec::with_capacity(owners.len());
1233 for chunk in owners.chunks(NAVIGATION_CONNECTION_OWNER_CHUNK) {
1234 let rows =
1235 collect_navigation_connection_rows(&self.connection, chunk, family_limit_plus_one)?;
1236 result.extend(navigation_connection_pages(
1237 chunk,
1238 rows,
1239 family_limit as usize,
1240 sample_limit,
1241 )?);
1242 }
1243 Ok(result)
1244 }
1245
1246 pub fn repository_graph_entity(&self, key: &GraphEntityKey) -> DbResult<Option<GraphEntity>> {
1253 let Some(generation) = self.repository_graph_generation()? else {
1254 return Ok(None);
1255 };
1256 if !verify_project_identity(&self.connection, key.project())? {
1257 return Ok(None);
1258 }
1259 Ok(self
1260 .repository_graph_entity_bounded(
1261 key,
1262 generation,
1263 maximum_repository_graph_read_budget()?,
1264 None,
1265 )?
1266 .rows
1267 .into_iter()
1268 .next())
1269 }
1270
1271 pub fn repository_graph_entity_bounded(
1280 &self,
1281 key: &GraphEntityKey,
1282 generation: IndexGeneration,
1283 budget: RepositoryGraphReadBudget,
1284 control: Option<&IndexWorkControl>,
1285 ) -> DbResult<RepositoryGraphReadBatch<GraphEntity>> {
1286 self.require_repository_graph_snapshot(key.project(), generation)?;
1287 let digest = key.digest_bytes()?;
1288 let mut meter = RepositoryGraphReadMeter::new(budget, 1)?;
1289 let mut entities = load_graph_entities_by_digest_metered(
1290 self,
1291 &[digest],
1292 key.project(),
1293 generation,
1294 control,
1295 Some(&mut meter),
1296 )?;
1297 let rows = entities.remove(&digest).into_iter().collect::<Vec<_>>();
1298 let work = meter.finish(rows.len())?;
1299 Ok(RepositoryGraphReadBatch { rows, work })
1300 }
1301
1302 pub fn repository_graph_entities_by_digest(
1310 &self,
1311 project: ProjectInstanceId,
1312 generation: IndexGeneration,
1313 digests: &[[u8; 32]],
1314 budget: RepositoryGraphReadBudget,
1315 control: Option<&IndexWorkControl>,
1316 ) -> DbResult<RepositoryGraphReadBatch<GraphEntity>> {
1317 validate_graph_hydration_request(digests)?;
1318 self.require_repository_graph_snapshot(project, generation)?;
1319 let mut meter = RepositoryGraphReadMeter::new(budget, digests.len())?;
1320 let mut entities = load_graph_entities_by_digest_metered(
1321 self,
1322 digests,
1323 project,
1324 generation,
1325 control,
1326 Some(&mut meter),
1327 )?;
1328 let mut ordered = Vec::with_capacity(digests.len());
1329 for digest in digests {
1330 ordered.push(entities.remove(digest).ok_or(DbError::GraphRowShape {
1331 table: "graph_entities",
1332 reason: "requested graph entity is missing",
1333 })?);
1334 }
1335 let work = meter.finish(ordered.len())?;
1336 Ok(RepositoryGraphReadBatch {
1337 rows: ordered,
1338 work,
1339 })
1340 }
1341
1342 pub fn repository_graph_entities_by_path(
1349 &self,
1350 project: ProjectInstanceId,
1351 path: &RepositoryNodePath,
1352 limit: u32,
1353 ) -> DbResult<RepositoryGraphPage<GraphEntity>> {
1354 validated_limit_plus_one(
1355 limit,
1356 GraphLimits::MAX_ROWS,
1357 "graph rows must be nonzero and within the product ceiling",
1358 )?;
1359 let Some(generation) = self.repository_graph_generation()? else {
1360 return Ok(empty_page());
1361 };
1362 if !verify_project_identity(&self.connection, project)? {
1363 return Ok(empty_page());
1364 }
1365 Ok(self
1366 .repository_graph_entities_by_path_bounded(
1367 project,
1368 generation,
1369 path,
1370 limit,
1371 maximum_repository_graph_read_budget()?,
1372 None,
1373 )?
1374 .page)
1375 }
1376
1377 pub fn repository_graph_entities_by_path_bounded(
1386 &self,
1387 project: ProjectInstanceId,
1388 generation: IndexGeneration,
1389 path: &RepositoryNodePath,
1390 limit: u32,
1391 budget: RepositoryGraphReadBudget,
1392 control: Option<&IndexWorkControl>,
1393 ) -> DbResult<RepositoryGraphReadPage<GraphEntity>> {
1394 let limit_plus_one = validated_limit_plus_one(
1395 limit,
1396 GraphLimits::MAX_ROWS,
1397 "graph rows must be nonzero and within the product ceiling",
1398 )?;
1399 self.require_repository_graph_snapshot(project, generation)?;
1400 let mut meter = RepositoryGraphReadMeter::new(budget, 1)?;
1401 let raw = with_sqlite_read_progress(
1402 &self.connection,
1403 control,
1404 IndexWorkStage::RepositoryTraversal,
1405 || {
1406 let mut statement = self.connection.prepare_cached(
1407 "SELECT entity_key, project_instance_id, canonical_identity, entity_kind,
1408 repository_path, package_manager, package_name, manifest_path,
1409 symbol_name, symbol_kind, symbol_parent, symbol_signature,
1410 external_system, external_identity
1411 FROM graph_entities
1412 WHERE project_instance_id = ?1 AND repository_path = ?2
1413 ORDER BY entity_kind, entity_key
1414 LIMIT ?3",
1415 )?;
1416 collect_entity_rows_metered(
1417 statement.query(params![
1418 &project.as_bytes()[..],
1419 path.as_str(),
1420 limit_plus_one
1421 ])?,
1422 &mut meter,
1423 )
1424 },
1425 )?;
1426 let page = page_from_raw(raw, limit, |row| {
1427 let entity = entity_from_row(row, project, generation)?;
1428 meter.record_entity(&entity)?;
1429 Ok(entity)
1430 })?;
1431 let work = meter.finish(page.rows.len())?;
1432 Ok(RepositoryGraphReadPage { page, work })
1433 }
1434
1435 pub fn repository_graph_entrypoint_candidates_page_bounded(
1447 &self,
1448 project: ProjectInstanceId,
1449 generation: IndexGeneration,
1450 limit: u32,
1451 selection: ContentSelection,
1452 budget: RepositoryGraphReadBudget,
1453 control: Option<&IndexWorkControl>,
1454 ) -> DbResult<RepositoryGraphReadPage<GraphEntity>> {
1455 let limit_plus_one = validated_limit_plus_one(
1456 limit,
1457 GraphLimits::MAX_ROWS,
1458 "graph entity rows must be nonzero and within the product ceiling",
1459 )?;
1460 self.require_repository_graph_snapshot(project, generation)?;
1461 let mut meter = RepositoryGraphReadMeter::new(budget, 1)?;
1462 let selection_filter = match selection {
1463 ContentSelection::UnspecifiedLegacy => "",
1464 ContentSelection::Source => {
1465 " AND EXISTS (SELECT 1 FROM file_content_classifications AS classification
1466 WHERE classification.path = graph_entities.repository_path
1467 AND classification.classification = 'source')"
1468 }
1469 ContentSelection::Documentation => {
1470 " AND EXISTS (SELECT 1 FROM file_content_classifications AS classification
1471 WHERE classification.path = graph_entities.repository_path
1472 AND classification.classification = 'documentation')"
1473 }
1474 ContentSelection::Both => {
1475 " AND EXISTS (SELECT 1 FROM file_content_classifications AS classification
1476 WHERE classification.path = graph_entities.repository_path
1477 AND classification.classification IN ('source', 'documentation'))"
1478 }
1479 };
1480 let sql = format!(
1481 "SELECT entity_key, project_instance_id, canonical_identity, entity_kind,
1482 repository_path, package_manager, package_name, manifest_path,
1483 symbol_name, symbol_kind, symbol_parent, symbol_signature,
1484 external_system, external_identity
1485 FROM graph_entities
1486 WHERE project_instance_id = ?1
1487 AND entity_kind IN ('file', 'symbol'){selection_filter}
1488 ORDER BY entity_key
1489 LIMIT ?2"
1490 );
1491 let raw = with_sqlite_read_progress(
1492 &self.connection,
1493 control,
1494 IndexWorkStage::RepositoryTraversal,
1495 || {
1496 let mut statement = self.connection.prepare_cached(&sql)?;
1497 collect_entity_rows_metered(
1498 statement.query(params![&project.as_bytes()[..], limit_plus_one])?,
1499 &mut meter,
1500 )
1501 },
1502 )?;
1503 let page = page_from_raw(raw, limit, |row| {
1504 let entity = entity_from_row(row, project, generation)?;
1505 meter.record_entity(&entity)?;
1506 Ok(entity)
1507 })?;
1508 let work = meter.finish(page.rows.len())?;
1509 Ok(RepositoryGraphReadPage { page, work })
1510 }
1511
1512 pub fn repository_resolution_candidates(
1519 &self,
1520 key: &CanonicalResolutionKey,
1521 limit: u32,
1522 ) -> DbResult<RepositoryGraphPage<GraphEntity>> {
1523 let limit_plus_one = validated_limit_plus_one(
1524 limit,
1525 GraphLimits::MAX_ROWS,
1526 "resolution candidates must be nonzero and within the product ceiling",
1527 )?;
1528 let Some(generation) = self.repository_graph_generation()? else {
1529 return Ok(empty_page());
1530 };
1531 if !verify_project_identity(&self.connection, key.project())? {
1532 return Ok(empty_page());
1533 }
1534 if !validate_persisted_resolution_key(&self.connection, key)? {
1535 return Ok(empty_page());
1536 }
1537 let raw = {
1538 let mut statement = self.connection.prepare_cached(
1539 "SELECT entity.entity_key, entity.project_instance_id,
1540 entity.canonical_identity, entity.entity_kind,
1541 entity.repository_path, entity.package_manager,
1542 entity.package_name, entity.manifest_path,
1543 entity.symbol_name, entity.symbol_kind,
1544 entity.symbol_parent, entity.symbol_signature,
1545 entity.external_system, entity.external_identity
1546 FROM graph_entity_exports AS export
1547 INDEXED BY idx_graph_entity_exports_key
1548 JOIN graph_entities AS entity
1549 ON entity.project_instance_id = export.project_instance_id
1550 AND entity.entity_key = export.entity_key
1551 WHERE export.project_instance_id = ?1
1552 AND export.resolution_domain = ?2
1553 AND export.key_digest = ?3
1554 ORDER BY export.entity_key
1555 LIMIT ?4",
1556 )?;
1557 collect_entity_rows(statement.query(params![
1558 &key.project().as_bytes()[..],
1559 key.domain().as_str(),
1560 &key.digest_bytes()[..],
1561 limit_plus_one,
1562 ])?)?
1563 };
1564 page_from_raw(raw, limit, |row| {
1565 entity_from_row(row, key.project(), generation)
1566 })
1567 }
1568
1569 pub fn repository_resolution_candidates_for_keys(
1582 &self,
1583 project: ProjectInstanceId,
1584 keys: &[CanonicalResolutionKey],
1585 limit: u32,
1586 ) -> DbResult<RepositoryGraphPage<RepositoryResolutionCandidate>> {
1587 validated_limit_plus_one(
1588 limit,
1589 GraphLimits::MAX_ROWS,
1590 "resolution candidates must be nonzero and within the product ceiling",
1591 )?;
1592 let Some(generation) = self.repository_graph_generation()? else {
1593 return Ok(empty_page());
1594 };
1595 if !verify_project_identity(&self.connection, project)? {
1596 return Ok(empty_page());
1597 }
1598 let keys = normalized_resolution_keys(project, keys)?;
1599 validate_persisted_resolution_keys(&self.connection, &keys)?;
1600 let mut candidates = Vec::new();
1601 for chunk in keys.chunks(RESOLUTION_KEYS_PER_QUERY) {
1602 if chunk.is_empty() {
1603 continue;
1604 }
1605 let values_clause = anonymous_values_clause(chunk.len(), 4);
1606 let sql = format!(
1607 "WITH requested(project_instance_id, resolution_domain, key_digest, canonical_identity)
1608 AS (VALUES {values_clause})
1609 SELECT entity.entity_key, entity.project_instance_id,
1610 entity.canonical_identity, entity.entity_kind,
1611 entity.repository_path, entity.package_manager,
1612 entity.package_name, entity.manifest_path,
1613 entity.symbol_name, entity.symbol_kind,
1614 entity.symbol_parent, entity.symbol_signature,
1615 entity.external_system, entity.external_identity,
1616 stored.project_instance_id, stored.resolution_domain,
1617 stored.key_digest, stored.canonical_identity
1618 FROM requested
1619 JOIN graph_resolution_keys AS stored
1620 ON stored.project_instance_id = requested.project_instance_id
1621 AND stored.resolution_domain = requested.resolution_domain
1622 AND stored.key_digest = requested.key_digest
1623 AND stored.canonical_identity = requested.canonical_identity
1624 JOIN graph_entity_exports AS export
1625 INDEXED BY idx_graph_entity_exports_key
1626 ON export.project_instance_id = stored.project_instance_id
1627 AND export.resolution_domain = stored.resolution_domain
1628 AND export.key_digest = stored.key_digest
1629 JOIN graph_entities AS entity
1630 ON entity.project_instance_id = export.project_instance_id
1631 AND entity.entity_key = export.entity_key
1632 ORDER BY stored.resolution_domain, stored.key_digest, export.entity_key
1633 LIMIT ?"
1634 );
1635 let mut values = resolution_key_values(chunk, true);
1636 values.push(Value::Integer(i64::from(limit) + 1));
1637 let mut statement = self.connection.prepare(&sql)?;
1638 let mut rows = statement.query(params_from_iter(values.iter()))?;
1639 while let Some(row) = rows.next()? {
1640 let entity = entity_from_row(entity_row(row)?, project, generation)?;
1641 let key_project = project_from_blob(
1642 "graph_resolution_keys.project_instance_id",
1643 row.get::<_, Vec<u8>>(14)?,
1644 )?;
1645 require_project(project, key_project)?;
1646 let domain_text = row.get::<_, String>(15)?;
1647 let domain = ResolutionKeyDomain::try_from(domain_text.as_str())?;
1648 let digest = fixed_bytes::<32>(
1649 "graph_resolution_keys.key_digest",
1650 row.get::<_, Vec<u8>>(16)?,
1651 )?;
1652 let key = CanonicalResolutionKey::from_persisted(
1653 key_project,
1654 domain,
1655 digest,
1656 row.get(17)?,
1657 )?;
1658 candidates.push(RepositoryResolutionCandidate { key, entity });
1659 }
1660 if candidates.len() > limit as usize {
1661 break;
1662 }
1663 }
1664 candidates.sort_by(|left, right| {
1665 left.key
1666 .cmp(&right.key)
1667 .then_with(|| left.entity.key().digest().cmp(right.entity.key().digest()))
1668 .then_with(|| {
1669 left.entity
1670 .key()
1671 .canonical_identity()
1672 .cmp(right.entity.key().canonical_identity())
1673 })
1674 });
1675 candidates.dedup_by(|left, right| {
1676 left.key == right.key && left.entity.key() == right.entity.key()
1677 });
1678 let truncated = candidates.len() > limit as usize;
1679 candidates.truncate(limit as usize);
1680 Ok(RepositoryGraphPage {
1681 rows: candidates,
1682 truncated,
1683 })
1684 }
1685
1686 pub fn repository_export_keys_for_paths(
1696 &self,
1697 project: ProjectInstanceId,
1698 paths: &[String],
1699 limit: u32,
1700 ) -> DbResult<RepositoryGraphPage<CanonicalResolutionKey>> {
1701 validated_limit_plus_one(
1702 limit,
1703 GraphLimits::MAX_ROWS,
1704 "resolution export keys must be nonzero and within the product ceiling",
1705 )?;
1706 if self.repository_graph_generation()?.is_none()
1707 || !verify_project_identity(&self.connection, project)?
1708 {
1709 return Ok(empty_page());
1710 }
1711 let paths = normalized_file_paths(paths)?;
1712 let keys = resolution_keys_for_owner_paths(
1713 &self.connection,
1714 project,
1715 &paths,
1716 ResolutionOwner::EntityExports,
1717 Some(limit),
1718 )?;
1719 Ok(page_from_ordered_set(keys, limit))
1720 }
1721
1722 pub fn repository_affected_source_paths(
1734 &self,
1735 project: ProjectInstanceId,
1736 keys: &[CanonicalResolutionKey],
1737 limit: u32,
1738 ) -> DbResult<RepositoryGraphPage<RepositoryFilePath>> {
1739 validated_limit_plus_one(
1740 limit,
1741 GraphLimits::MAX_ROWS,
1742 "affected source paths must be nonzero and within the product ceiling",
1743 )?;
1744 if self.repository_graph_generation()?.is_none()
1745 || !verify_project_identity(&self.connection, project)?
1746 {
1747 return Ok(empty_page());
1748 }
1749 let keys = normalized_resolution_keys(project, keys)?;
1750 validate_persisted_resolution_keys(&self.connection, &keys)?;
1751 let paths = affected_source_paths(&self.connection, &keys, limit)?;
1752 Ok(page_from_ordered_set(paths, limit))
1753 }
1754
1755 pub fn repository_affected_source_footprint(
1769 &self,
1770 project: ProjectInstanceId,
1771 paths: &[String],
1772 limit: u32,
1773 ) -> DbResult<RepositoryAffectedSourceFootprint> {
1774 let limit_plus_one = validated_limit_plus_one(
1775 limit,
1776 GraphLimits::MAX_ROWS,
1777 "affected source footprint rows must be nonzero and within the product ceiling",
1778 )?;
1779 if self.repository_graph_generation()?.is_none()
1780 || !verify_project_identity(&self.connection, project)?
1781 {
1782 return Ok(empty_affected_source_footprint());
1783 }
1784 let paths = normalized_file_paths(paths)?;
1785 affected_source_footprint(&self.connection, project, &paths, limit_plus_one)
1786 }
1787
1788 pub fn repository_graph_relations(
1795 &self,
1796 query: RepositoryGraphRelationQuery,
1797 limit: u32,
1798 ) -> DbResult<RepositoryGraphPage<LogicalRelation>> {
1799 let page = self.repository_graph_relation_rows(query, limit, None)?;
1800 Ok(RepositoryGraphPage {
1801 rows: page.rows.into_iter().map(|row| row.relation).collect(),
1802 truncated: page.truncated,
1803 })
1804 }
1805
1806 pub fn repository_graph_resolved_relation_hubs(
1817 &self,
1818 relation: GraphRelationKind,
1819 limit: u32,
1820 control: Option<&IndexWorkControl>,
1821 ) -> DbResult<RepositoryGraphPage<GraphEntity>> {
1822 if let Some(control) = control {
1823 control.check(IndexWorkStage::RepositoryTraversal)?;
1824 }
1825 let limit_plus_one = validated_limit_plus_one(
1826 limit,
1827 GraphLimits::MAX_ROWS,
1828 "resolved graph hub rows must be nonzero and within the product ceiling",
1829 )?;
1830 let Some(generation) = self.repository_graph_generation()? else {
1831 return Ok(empty_page());
1832 };
1833 let project =
1834 load_project_identity(&self.connection)?.ok_or(DbError::GraphPublicationUnavailable)?;
1835 let (scope, kind) = relation_parts(relation);
1836 let raw_keys = with_sqlite_read_progress(
1837 &self.connection,
1838 control,
1839 IndexWorkStage::RepositoryTraversal,
1840 || {
1841 let mut statement = self
1842 .connection
1843 .prepare_cached(resolved_relation_hub_keys_sql())?;
1844 let mut rows = statement.query(params![
1845 &project.as_bytes()[..],
1846 scope,
1847 kind,
1848 RESOLUTION_STATUS_RESOLVED,
1849 limit_plus_one,
1850 ])?;
1851 let mut keys = Vec::new();
1852 while let Some(row) = rows.next()? {
1853 keys.push(row.get::<_, Vec<u8>>(0)?);
1854 }
1855 Ok(keys)
1856 },
1857 )?;
1858 let keys = raw_keys
1859 .into_iter()
1860 .map(|key| fixed_bytes::<32>("graph_entities.entity_key", key))
1861 .collect::<DbResult<Vec<_>>>()?;
1862 let mut entities =
1863 load_graph_entities_by_digest_metered(self, &keys, project, generation, control, None)?;
1864 let ordered = keys
1865 .into_iter()
1866 .map(|key| {
1867 entities.remove(&key).ok_or(DbError::GraphRowShape {
1868 table: "graph_relations",
1869 reason: "ranked resolved endpoint entity is missing",
1870 })
1871 })
1872 .collect::<DbResult<Vec<_>>>()?;
1873 page_from_raw(ordered, limit, Ok)
1874 }
1875
1876 pub fn repository_graph_relation_rows(
1884 &self,
1885 query: RepositoryGraphRelationQuery,
1886 limit: u32,
1887 control: Option<&IndexWorkControl>,
1888 ) -> DbResult<RepositoryGraphPage<RepositoryGraphRelationRow>> {
1889 let limit_plus_one = validated_limit_plus_one(
1890 limit,
1891 GraphLimits::MAX_ROWS,
1892 "graph rows must be nonzero and within the product ceiling",
1893 )?;
1894 let Some(generation) = self.repository_graph_generation()? else {
1895 return Ok(empty_page());
1896 };
1897 let (project, raw) = match query {
1898 RepositoryGraphRelationQuery::Outbound { source } => {
1899 let project = source.project();
1900 if !verify_project_identity(&self.connection, project)? {
1901 return Ok(empty_page());
1902 }
1903 let raw = with_sqlite_read_progress(
1904 &self.connection,
1905 control,
1906 IndexWorkStage::RepositoryTraversal,
1907 || {
1908 self.collect_relation_rows_by_key(
1909 "source_entity_key",
1910 &source.digest_bytes()?,
1911 limit_plus_one,
1912 )
1913 },
1914 )?;
1915 (project, raw)
1916 }
1917 RepositoryGraphRelationQuery::Inbound { target } => {
1918 let project = target.project();
1919 if !verify_project_identity(&self.connection, project)? {
1920 return Ok(empty_page());
1921 }
1922 let raw = with_sqlite_read_progress(
1923 &self.connection,
1924 control,
1925 IndexWorkStage::RepositoryTraversal,
1926 || {
1927 self.collect_relation_rows_by_key(
1928 "target_entity_key",
1929 &target.digest_bytes()?,
1930 limit_plus_one,
1931 )
1932 },
1933 )?;
1934 (project, raw)
1935 }
1936 RepositoryGraphRelationQuery::Family { relation } => {
1937 let project = load_project_identity(&self.connection)?
1938 .ok_or(DbError::GraphPublicationUnavailable)?;
1939 let (scope, kind) = relation_parts(relation);
1940 let raw = with_sqlite_read_progress(
1941 &self.connection,
1942 control,
1943 IndexWorkStage::RepositoryTraversal,
1944 || {
1945 #[cfg(feature = "sqlite-progress-test-observer")]
1946 crate::sqlite_progress_test_observer::notify(
1947 crate::sqlite_progress_test_observer::SqliteReadProgressEvent::RepositoryRelationFamilyQueryEntered,
1948 );
1949 let result = (|| {
1950 let mut statement = self.connection.prepare_cached(
1951 "SELECT relation_key, project_instance_id, canonical_identity,
1952 source_entity_key, relation_scope, relation_kind,
1953 resolution_status, target_entity_key, reference_text,
1954 candidate_count, document_unresolved_reason,
1955 confidence, completeness
1956 FROM graph_relations
1957 WHERE project_instance_id = ?1
1958 AND relation_scope = ?2 AND relation_kind = ?3
1959 ORDER BY relation_key
1960 LIMIT ?4",
1961 )?;
1962 collect_relation_rows(statement.query(params![
1963 &project.as_bytes()[..],
1964 scope,
1965 kind,
1966 limit_plus_one
1967 ])?)
1968 })();
1969 #[cfg(feature = "sqlite-progress-test-observer")]
1970 crate::sqlite_progress_test_observer::notify(
1971 crate::sqlite_progress_test_observer::SqliteReadProgressEvent::RepositoryRelationFamilyQueryExited,
1972 );
1973 result
1974 },
1975 )?;
1976 (project, raw)
1977 }
1978 };
1979 let entities = load_relation_entities(self, &raw, project, generation, control)?;
1980 page_from_raw(raw, limit, |row| {
1981 relation_detail_from_row(&entities, row, project, generation)
1982 })
1983 }
1984
1985 pub fn repository_graph_classified_relation_family_rows(
1998 &self,
1999 relation: GraphRelationKind,
2000 selection: ContentSelection,
2001 limit: u32,
2002 control: Option<&IndexWorkControl>,
2003 ) -> DbResult<RepositoryGraphPage<RepositoryGraphClassifiedRelationRow>> {
2004 let limit_plus_one = validated_limit_plus_one(
2005 limit,
2006 GraphLimits::MAX_ROWS,
2007 "graph rows must be nonzero and within the product ceiling",
2008 )?;
2009 let Some(generation) = self.repository_graph_generation()? else {
2010 return Ok(empty_page());
2011 };
2012 let project =
2013 load_project_identity(&self.connection)?.ok_or(DbError::GraphPublicationUnavailable)?;
2014 let (scope, kind) = relation_parts(relation);
2015 let sql = classified_relation_family_sql(selection);
2016 let mut bindings = vec![
2017 Value::Blob(project.as_bytes().to_vec()),
2018 Value::Text(scope.to_string()),
2019 Value::Text(kind.to_string()),
2020 ];
2021 match selection {
2022 ContentSelection::UnspecifiedLegacy => {}
2023 ContentSelection::Source => bindings.push(Value::Text(
2024 ContentClassification::Source.as_str().to_string(),
2025 )),
2026 ContentSelection::Documentation => bindings.push(Value::Text(
2027 ContentClassification::Documentation.as_str().to_string(),
2028 )),
2029 ContentSelection::Both => bindings.extend([
2030 Value::Text(ContentClassification::Source.as_str().to_string()),
2031 Value::Text(ContentClassification::Documentation.as_str().to_string()),
2032 ]),
2033 }
2034 bindings.push(Value::Integer(limit_plus_one));
2035 let raw = with_sqlite_read_progress(
2036 &self.connection,
2037 control,
2038 IndexWorkStage::RepositoryTraversal,
2039 || {
2040 #[cfg(feature = "sqlite-progress-test-observer")]
2041 crate::sqlite_progress_test_observer::notify(
2042 crate::sqlite_progress_test_observer::SqliteReadProgressEvent::RepositoryRelationFamilyQueryEntered,
2043 );
2044 let result = (|| {
2045 let mut statement = self.connection.prepare_cached(&sql)?;
2046 collect_classified_relation_rows(
2047 statement.query(params_from_iter(bindings.iter()))?,
2048 )
2049 })();
2050 #[cfg(feature = "sqlite-progress-test-observer")]
2051 crate::sqlite_progress_test_observer::notify(
2052 crate::sqlite_progress_test_observer::SqliteReadProgressEvent::RepositoryRelationFamilyQueryExited,
2053 );
2054 result
2055 },
2056 )?;
2057 let relation_rows = raw.iter().map(|row| &row.relation).collect::<Vec<_>>();
2058 let entities =
2059 load_relation_entity_references(self, &relation_rows, project, generation, control)?;
2060 page_from_raw(raw, limit, |row| {
2061 classified_relation_detail_from_row(&entities, row, project, generation)
2062 })
2063 }
2064
2065 pub fn repository_graph_relation_rows_by_digest(
2074 &self,
2075 project: ProjectInstanceId,
2076 generation: IndexGeneration,
2077 digests: &[[u8; 32]],
2078 budget: RepositoryGraphReadBudget,
2079 control: Option<&IndexWorkControl>,
2080 ) -> DbResult<RepositoryGraphReadBatch<RepositoryGraphRelationRow>> {
2081 validate_graph_hydration_request(digests)?;
2082 self.require_repository_graph_snapshot(project, generation)?;
2083 let mut meter = RepositoryGraphReadMeter::new(budget, digests.len())?;
2084 if digests.is_empty() {
2085 return Ok(RepositoryGraphReadBatch {
2086 rows: Vec::new(),
2087 work: meter.finish(0)?,
2088 });
2089 }
2090 let sql = graph_relation_hydration_sql(digests.len());
2091 let mut bindings = digests
2092 .iter()
2093 .map(|digest| Value::Blob(digest.to_vec()))
2094 .collect::<Vec<_>>();
2095 bindings.push(Value::Blob(project.as_bytes().to_vec()));
2096 let raw = with_sqlite_read_progress(
2097 &self.connection,
2098 control,
2099 IndexWorkStage::RepositoryTraversal,
2100 || {
2101 let mut statement = self.connection.prepare(&sql)?;
2102 collect_relation_rows_metered(
2103 statement.query(params_from_iter(bindings.iter()))?,
2104 &mut meter,
2105 )
2106 },
2107 )?;
2108 let mut relations = HashMap::with_capacity(raw.len());
2109 for row in raw {
2110 let digest = fixed_bytes::<32>("graph_relations.relation_key", row.key.clone())?;
2111 if relations.insert(digest, row).is_some() {
2112 return Err(DbError::GraphRowShape {
2113 table: "graph_relations",
2114 reason: "batched relation hydration returned a duplicate key",
2115 });
2116 }
2117 }
2118 let mut ordered = Vec::with_capacity(digests.len());
2119 for digest in digests {
2120 ordered.push(relations.remove(digest).ok_or(DbError::GraphRowShape {
2121 table: "graph_relations",
2122 reason: "requested graph relation is missing",
2123 })?);
2124 }
2125 let references = ordered.iter().collect::<Vec<_>>();
2126 let entities = load_relation_entity_references_metered(
2127 self,
2128 &references,
2129 project,
2130 generation,
2131 control,
2132 Some(&mut meter),
2133 )?;
2134 let rows = ordered
2135 .into_iter()
2136 .map(|row| relation_detail_from_row(&entities, row, project, generation))
2137 .collect::<DbResult<Vec<_>>>()?;
2138 let work = meter.finish(rows.len())?;
2139 Ok(RepositoryGraphReadBatch { rows, work })
2140 }
2141
2142 pub fn repository_graph_adjacency_page(
2156 &self,
2157 frontier: &[GraphEntityKey],
2158 direction: RepositoryGraphDirection,
2159 continuation: Option<&RepositoryGraphAdjacencyContinuation>,
2160 limit: u32,
2161 control: Option<&IndexWorkControl>,
2162 ) -> DbResult<RepositoryGraphAdjacencyPage> {
2163 Ok(self
2164 .repository_graph_adjacency_page_bounded(
2165 frontier,
2166 direction,
2167 continuation,
2168 limit,
2169 maximum_repository_graph_read_budget()?,
2170 control,
2171 )?
2172 .page)
2173 }
2174
2175 pub fn repository_graph_adjacency_page_bounded(
2183 &self,
2184 frontier: &[GraphEntityKey],
2185 direction: RepositoryGraphDirection,
2186 continuation: Option<&RepositoryGraphAdjacencyContinuation>,
2187 limit: u32,
2188 budget: RepositoryGraphReadBudget,
2189 control: Option<&IndexWorkControl>,
2190 ) -> DbResult<RepositoryGraphAdjacencyReadPage> {
2191 self.repository_graph_adjacency_page_filtered_bounded(
2192 frontier,
2193 direction,
2194 None,
2195 continuation,
2196 limit,
2197 budget,
2198 control,
2199 )
2200 }
2201
2202 pub fn repository_graph_adjacency_page_filtered(
2213 &self,
2214 frontier: &[GraphEntityKey],
2215 direction: RepositoryGraphDirection,
2216 relation: Option<GraphRelationKind>,
2217 continuation: Option<&RepositoryGraphAdjacencyContinuation>,
2218 limit: u32,
2219 control: Option<&IndexWorkControl>,
2220 ) -> DbResult<RepositoryGraphAdjacencyPage> {
2221 Ok(self
2222 .repository_graph_adjacency_page_filtered_bounded(
2223 frontier,
2224 direction,
2225 relation,
2226 continuation,
2227 limit,
2228 maximum_repository_graph_read_budget()?,
2229 control,
2230 )?
2231 .page)
2232 }
2233
2234 pub fn repository_graph_resolved_adjacency_page(
2242 &self,
2243 frontier: &[GraphEntityKey],
2244 direction: RepositoryGraphDirection,
2245 relation: GraphRelationKind,
2246 continuation: Option<&RepositoryGraphAdjacencyContinuation>,
2247 limit: u32,
2248 control: Option<&IndexWorkControl>,
2249 ) -> DbResult<RepositoryGraphAdjacencyPage> {
2250 Ok(self
2251 .repository_graph_adjacency_page_filtered_by_resolution_bounded(
2252 frontier,
2253 direction,
2254 Some(relation),
2255 true,
2256 false,
2257 continuation,
2258 limit,
2259 maximum_repository_graph_read_budget()?,
2260 control,
2261 )?
2262 .page)
2263 }
2264
2265 pub fn repository_graph_adjacency_page_filtered_bounded(
2275 &self,
2276 frontier: &[GraphEntityKey],
2277 direction: RepositoryGraphDirection,
2278 relation: Option<GraphRelationKind>,
2279 continuation: Option<&RepositoryGraphAdjacencyContinuation>,
2280 limit: u32,
2281 budget: RepositoryGraphReadBudget,
2282 control: Option<&IndexWorkControl>,
2283 ) -> DbResult<RepositoryGraphAdjacencyReadPage> {
2284 self.repository_graph_adjacency_page_filtered_bounded_with_documents(
2285 frontier,
2286 direction,
2287 relation,
2288 false,
2289 continuation,
2290 limit,
2291 budget,
2292 control,
2293 )
2294 }
2295
2296 #[allow(clippy::too_many_arguments)]
2307 pub fn repository_graph_adjacency_page_filtered_bounded_with_documents(
2308 &self,
2309 frontier: &[GraphEntityKey],
2310 direction: RepositoryGraphDirection,
2311 relation: Option<GraphRelationKind>,
2312 include_documents: bool,
2313 continuation: Option<&RepositoryGraphAdjacencyContinuation>,
2314 limit: u32,
2315 budget: RepositoryGraphReadBudget,
2316 control: Option<&IndexWorkControl>,
2317 ) -> DbResult<RepositoryGraphAdjacencyReadPage> {
2318 self.repository_graph_adjacency_page_filtered_by_resolution_bounded(
2319 frontier,
2320 direction,
2321 relation,
2322 false,
2323 include_documents,
2324 continuation,
2325 limit,
2326 budget,
2327 control,
2328 )
2329 }
2330
2331 pub fn repository_graph_adjacency_is_empty(
2342 &self,
2343 key: &GraphEntityKey,
2344 direction: RepositoryGraphDirection,
2345 relation: GraphRelationKind,
2346 control: Option<&IndexWorkControl>,
2347 ) -> DbResult<bool> {
2348 let project = key.project();
2349 if !verify_project_identity(&self.connection, project)?
2350 || self.repository_graph_generation()?.is_none()
2351 {
2352 return Ok(true);
2353 }
2354 let digest = key.digest_bytes()?;
2355 let (key_column, index_name) = match direction {
2356 RepositoryGraphDirection::Outbound => {
2357 ("source_entity_key", "idx_graph_relations_source_kind")
2358 }
2359 RepositoryGraphDirection::Inbound => {
2360 ("target_entity_key", "idx_graph_relations_target_kind")
2361 }
2362 };
2363 let (scope, kind) = relation_parts(relation);
2364 let sql = format!(
2365 "SELECT EXISTS(
2366 SELECT 1
2367 FROM graph_relations AS relation INDEXED BY {index_name}
2368 WHERE relation.project_instance_id = ?1
2369 AND relation.{key_column} = ?2
2370 AND relation.relation_scope = ?3
2371 AND relation.relation_kind = ?4
2372 )"
2373 );
2374 let bindings = [
2375 Value::Blob(project.as_bytes().to_vec()),
2376 Value::Blob(digest.to_vec()),
2377 Value::Text(scope.to_string()),
2378 Value::Text(kind.to_string()),
2379 ];
2380 with_sqlite_read_progress(
2381 &self.connection,
2382 control,
2383 IndexWorkStage::RepositoryTraversal,
2384 || {
2385 self.connection
2386 .query_row(&sql, params_from_iter(bindings.iter()), |row| {
2387 row.get::<_, bool>(0)
2388 })
2389 .map(|has_row| !has_row)
2390 .map_err(Into::into)
2391 },
2392 )
2393 }
2394
2395 pub fn repository_graph_adjacency_is_empty_filtered(
2407 &self,
2408 key: &GraphEntityKey,
2409 direction: RepositoryGraphDirection,
2410 relation: GraphRelationKind,
2411 minimum_confidence: ConfidenceClass,
2412 selection: ContentSelection,
2413 control: Option<&IndexWorkControl>,
2414 ) -> DbResult<bool> {
2415 let project = key.project();
2416 if !verify_project_identity(&self.connection, project)?
2417 || self.repository_graph_generation()?.is_none()
2418 {
2419 return Ok(true);
2420 }
2421 let digest = key.digest_bytes()?;
2422 let (key_column, index_name, endpoint_column) = match direction {
2423 RepositoryGraphDirection::Outbound => (
2424 "source_entity_key",
2425 "idx_graph_relations_source_kind",
2426 "target_entity_key",
2427 ),
2428 RepositoryGraphDirection::Inbound => (
2429 "target_entity_key",
2430 "idx_graph_relations_target_kind",
2431 "source_entity_key",
2432 ),
2433 };
2434 let (scope, kind) = relation_parts(relation);
2435 let confidence_filter = match minimum_confidence {
2436 ConfidenceClass::Exact => "AND relation.confidence = 'exact'",
2437 ConfidenceClass::High => "AND relation.confidence IN ('exact', 'high')",
2438 ConfidenceClass::Medium => "AND relation.confidence IN ('exact', 'high', 'medium')",
2439 ConfidenceClass::Low => "",
2440 };
2441 let endpoint_joins = if selection == ContentSelection::UnspecifiedLegacy {
2442 String::new()
2443 } else {
2444 format!(
2445 "LEFT JOIN graph_entities AS endpoint
2446 ON endpoint.project_instance_id = relation.project_instance_id
2447 AND endpoint.entity_key = relation.{endpoint_column}
2448 LEFT JOIN file_content_classifications AS endpoint_classification
2449 ON endpoint_classification.path = CASE endpoint.entity_kind
2450 WHEN 'file' THEN endpoint.repository_path
2451 WHEN 'symbol' THEN endpoint.repository_path
2452 WHEN 'package' THEN endpoint.manifest_path
2453 END"
2454 )
2455 };
2456 let selection_filter = match selection {
2457 ContentSelection::UnspecifiedLegacy => "",
2458 ContentSelection::Source => {
2459 "AND (relation.resolution_status NOT IN ('resolved', 'external')
2460 OR relation.resolution_status = 'external'
2461 OR (relation.relation_scope = 'extended'
2462 AND relation.relation_kind = 'documents')
2463 OR (relation.resolution_status = 'resolved'
2464 AND endpoint_classification.classification = 'source'))"
2465 }
2466 ContentSelection::Documentation => {
2467 "AND (relation.resolution_status NOT IN ('resolved', 'external')
2468 OR relation.resolution_status = 'external'
2469 OR (relation.relation_scope = 'extended'
2470 AND relation.relation_kind = 'documents')
2471 OR (relation.resolution_status = 'resolved'
2472 AND endpoint_classification.classification = 'documentation'))"
2473 }
2474 ContentSelection::Both => {
2475 "AND (relation.resolution_status NOT IN ('resolved', 'external')
2476 OR relation.resolution_status = 'external'
2477 OR (relation.relation_scope = 'extended'
2478 AND relation.relation_kind = 'documents')
2479 OR (relation.resolution_status = 'resolved'
2480 AND endpoint_classification.classification IN ('source', 'documentation')))"
2481 }
2482 };
2483 let sql = format!(
2484 "SELECT EXISTS(
2485 SELECT 1
2486 FROM graph_relations AS relation INDEXED BY {index_name}
2487 {endpoint_joins}
2488 WHERE relation.project_instance_id = ?1
2489 AND relation.{key_column} = ?2
2490 AND relation.relation_scope = ?3
2491 AND relation.relation_kind = ?4
2492 {confidence_filter}
2493 {selection_filter}
2494 )"
2495 );
2496 let bindings = [
2497 Value::Blob(project.as_bytes().to_vec()),
2498 Value::Blob(digest.to_vec()),
2499 Value::Text(scope.to_string()),
2500 Value::Text(kind.to_string()),
2501 ];
2502 with_sqlite_read_progress(
2503 &self.connection,
2504 control,
2505 IndexWorkStage::RepositoryTraversal,
2506 || {
2507 self.connection
2508 .query_row(&sql, params_from_iter(bindings.iter()), |row| {
2509 row.get::<_, bool>(0)
2510 })
2511 .map(|has_row| !has_row)
2512 .map_err(Into::into)
2513 },
2514 )
2515 }
2516
2517 pub fn repository_graph_adjacency_continuation_has_filtered_rows(
2528 &self,
2529 continuation: &RepositoryGraphAdjacencyContinuation,
2530 minimum_confidence: ConfidenceClass,
2531 selection: ContentSelection,
2532 control: Option<&IndexWorkControl>,
2533 ) -> DbResult<bool> {
2534 let project = continuation.project;
2535 self.require_repository_graph_snapshot(project, continuation.generation)?;
2536 if continuation.frontier.is_empty()
2537 || continuation.frontier_index as usize >= continuation.frontier.len()
2538 {
2539 return Err(GraphContractError::InvalidLimits {
2540 reason: "graph adjacency continuation has an invalid frontier",
2541 }
2542 .into());
2543 }
2544 let (key_column, index_name, endpoint_column) = match continuation.direction {
2545 RepositoryGraphDirection::Outbound => (
2546 "source_entity_key",
2547 "idx_graph_relations_source_kind",
2548 "target_entity_key",
2549 ),
2550 RepositoryGraphDirection::Inbound => (
2551 "target_entity_key",
2552 "idx_graph_relations_target_kind",
2553 "source_entity_key",
2554 ),
2555 };
2556 let confidence_filter = match minimum_confidence {
2557 ConfidenceClass::Exact => "AND relation.confidence = 'exact'",
2558 ConfidenceClass::High => "AND relation.confidence IN ('exact', 'high')",
2559 ConfidenceClass::Medium => "AND relation.confidence IN ('exact', 'high', 'medium')",
2560 ConfidenceClass::Low => "",
2561 };
2562 let endpoint_joins = if selection == ContentSelection::UnspecifiedLegacy {
2563 String::new()
2564 } else {
2565 format!(
2566 "LEFT JOIN graph_entities AS endpoint
2567 ON endpoint.project_instance_id = relation.project_instance_id
2568 AND endpoint.entity_key = relation.{endpoint_column}
2569 LEFT JOIN file_content_classifications AS endpoint_classification
2570 ON endpoint_classification.path = CASE endpoint.entity_kind
2571 WHEN 'file' THEN endpoint.repository_path
2572 WHEN 'symbol' THEN endpoint.repository_path
2573 WHEN 'package' THEN endpoint.manifest_path
2574 END"
2575 )
2576 };
2577 let selection_filter = match selection {
2578 ContentSelection::UnspecifiedLegacy => "",
2579 ContentSelection::Source => {
2580 "AND (relation.resolution_status NOT IN ('resolved', 'external')
2581 OR relation.resolution_status = 'external'
2582 OR (relation.relation_scope = 'extended'
2583 AND relation.relation_kind = 'documents')
2584 OR (relation.resolution_status = 'resolved'
2585 AND endpoint_classification.classification = 'source'))"
2586 }
2587 ContentSelection::Documentation => {
2588 "AND (relation.resolution_status NOT IN ('resolved', 'external')
2589 OR relation.resolution_status = 'external'
2590 OR (relation.relation_scope = 'extended'
2591 AND relation.relation_kind = 'documents')
2592 OR (relation.resolution_status = 'resolved'
2593 AND endpoint_classification.classification = 'documentation'))"
2594 }
2595 ContentSelection::Both => {
2596 "AND (relation.resolution_status NOT IN ('resolved', 'external')
2597 OR relation.resolution_status = 'external'
2598 OR (relation.relation_scope = 'extended'
2599 AND relation.relation_kind = 'documents')
2600 OR (relation.resolution_status = 'resolved'
2601 AND endpoint_classification.classification IN ('source', 'documentation')))"
2602 }
2603 };
2604 let relation_filter = continuation.relation.map(|relation| {
2605 let (scope, kind) = relation_parts(relation);
2606 (scope, kind)
2607 });
2608 let relation_filter_sql = if relation_filter.is_some() {
2609 "AND relation.relation_scope = ? AND relation.relation_kind = ?"
2610 } else {
2611 ""
2612 };
2613 let resolution_filter = if continuation.resolved_only {
2614 "AND relation.resolution_status = 'resolved'
2615 AND relation.target_entity_key IS NOT NULL
2616 AND relation.source_entity_key <> relation.target_entity_key"
2617 } else {
2618 ""
2619 };
2620 let document_filter = if continuation.relation.is_some() || continuation.include_documents {
2621 ""
2622 } else {
2623 "AND NOT (
2624 relation.relation_scope = 'extended'
2625 AND relation.relation_kind = 'documents'
2626 )"
2627 };
2628 let mut bindings = Vec::new();
2629 let branches = (continuation.frontier_index as usize..continuation.frontier.len())
2630 .map(|frontier_index| {
2631 let is_continuation_frontier =
2632 frontier_index == continuation.frontier_index as usize;
2633 let keyset = if is_continuation_frontier {
2634 "AND (relation.relation_scope, relation.relation_kind,
2635 relation.relation_key) > (?, ?, ?)"
2636 } else {
2637 ""
2638 };
2639 bindings.push(Value::Blob(project.as_bytes().to_vec()));
2640 bindings.push(Value::Blob(continuation.frontier[frontier_index].to_vec()));
2641 if let Some((scope, kind)) = relation_filter {
2642 bindings.push(Value::Text(scope.to_string()));
2643 bindings.push(Value::Text(kind.to_string()));
2644 }
2645 if is_continuation_frontier {
2646 bindings.push(Value::Text(continuation.relation_scope.clone()));
2647 bindings.push(Value::Text(continuation.relation_kind.clone()));
2648 bindings.push(Value::Blob(continuation.relation_key.to_vec()));
2649 }
2650 format!(
2651 "SELECT 1
2652 FROM graph_relations AS relation INDEXED BY {index_name}
2653 {endpoint_joins}
2654 WHERE relation.project_instance_id = ?
2655 AND relation.{key_column} = ?
2656 {relation_filter_sql}
2657 {resolution_filter}
2658 {document_filter}
2659 {confidence_filter}
2660 {selection_filter}
2661 {keyset}"
2662 )
2663 })
2664 .collect::<Vec<_>>()
2665 .join(" UNION ALL ");
2666 let sql = format!("SELECT EXISTS(SELECT 1 FROM ({branches}) AS pending LIMIT 1)");
2667 with_sqlite_read_progress(
2668 &self.connection,
2669 control,
2670 IndexWorkStage::RepositoryTraversal,
2671 || {
2672 self.connection
2673 .query_row(&sql, params_from_iter(bindings.iter()), |row| {
2674 row.get::<_, bool>(0)
2675 })
2676 .map_err(Into::into)
2677 },
2678 )
2679 }
2680
2681 fn repository_graph_adjacency_page_filtered_by_resolution_bounded(
2683 &self,
2684 frontier: &[GraphEntityKey],
2685 direction: RepositoryGraphDirection,
2686 relation: Option<GraphRelationKind>,
2687 resolved_only: bool,
2688 include_documents: bool,
2689 continuation: Option<&RepositoryGraphAdjacencyContinuation>,
2690 limit: u32,
2691 budget: RepositoryGraphReadBudget,
2692 control: Option<&IndexWorkControl>,
2693 ) -> DbResult<RepositoryGraphAdjacencyReadPage> {
2694 let include_documents = include_documents
2695 || relation == Some(GraphRelationKind::Extended(ExtendedRelationKind::Documents));
2696 let limit_plus_one = validated_limit_plus_one(
2697 limit,
2698 GraphLimits::MAX_ROWS,
2699 "graph adjacency rows must be nonzero and within the product ceiling",
2700 )?;
2701 if limit > budget.returned_rows() {
2702 return Err(GraphContractError::InvalidLimits {
2703 reason: "graph adjacency page limit exceeds the return budget",
2704 }
2705 .into());
2706 }
2707 if frontier.len() > MAX_REPOSITORY_GRAPH_FRONTIER {
2708 return Err(GraphContractError::InvalidLimits {
2709 reason: "graph adjacency frontier exceeds the product ceiling",
2710 }
2711 .into());
2712 }
2713 if frontier.is_empty() {
2714 if continuation.is_some() {
2715 return Err(GraphContractError::InvalidLimits {
2716 reason: "graph adjacency continuation requires a nonempty frontier",
2717 }
2718 .into());
2719 }
2720 let meter = RepositoryGraphReadMeter::new(budget, 0)?;
2721 return Ok(RepositoryGraphAdjacencyReadPage {
2722 page: empty_adjacency_page(),
2723 work: meter.finish(0)?,
2724 });
2725 }
2726 let mut meter = RepositoryGraphReadMeter::new(budget, frontier.len())?;
2727 let limit_plus_one_usize = usize::try_from(limit_plus_one).map_err(|_source| {
2728 GraphContractError::InvalidLimits {
2729 reason: "graph adjacency page limit overflowed",
2730 }
2731 })?;
2732 let project = frontier[0].project();
2733 let mut unique = BTreeSet::new();
2734 let mut frontier_digests = Vec::with_capacity(frontier.len());
2735 for key in frontier {
2736 require_project(project, key.project())?;
2737 let digest = key.digest_bytes()?;
2738 if !unique.insert(digest) {
2739 return Err(GraphContractError::InvalidLimits {
2740 reason: "graph adjacency frontier must contain unique entities",
2741 }
2742 .into());
2743 }
2744 frontier_digests.push(digest);
2745 }
2746
2747 let Some(generation) = self.repository_graph_generation()? else {
2748 if continuation.is_some() {
2749 return Err(GraphContractError::InvalidLimits {
2750 reason: "graph adjacency continuation has no active generation",
2751 }
2752 .into());
2753 }
2754 return Ok(RepositoryGraphAdjacencyReadPage {
2755 page: empty_adjacency_page(),
2756 work: meter.finish(0)?,
2757 });
2758 };
2759 if !verify_project_identity(&self.connection, project)? {
2760 if continuation.is_some() {
2761 return Err(GraphContractError::InvalidLimits {
2762 reason: "graph adjacency continuation does not match the bound project",
2763 }
2764 .into());
2765 }
2766 return Ok(RepositoryGraphAdjacencyReadPage {
2767 page: empty_adjacency_page(),
2768 work: meter.finish(0)?,
2769 });
2770 }
2771
2772 if let Some(continuation) = continuation
2773 && (continuation.project != project
2774 || continuation.generation != generation
2775 || continuation.direction != direction
2776 || continuation.relation != relation
2777 || continuation.resolved_only != resolved_only
2778 || continuation.include_documents != include_documents
2779 || continuation.frontier != frontier_digests
2780 || continuation.frontier_index as usize >= frontier.len())
2781 {
2782 return Err(GraphContractError::InvalidLimits {
2783 reason: "graph adjacency continuation does not match the request",
2784 }
2785 .into());
2786 }
2787
2788 let continuation_index = continuation.map_or(0, |value| value.frontier_index as usize);
2789 let active_frontier = frontier.len() - continuation_index;
2790 let work_rows = active_frontier.checked_mul(limit_plus_one_usize).ok_or(
2791 GraphContractError::InvalidLimits {
2792 reason: "graph adjacency intermediate row ceiling overflowed",
2793 },
2794 )?;
2795 if work_rows > MAX_REPOSITORY_GRAPH_ADJACENCY_WORK_ROWS {
2796 return Err(GraphContractError::InvalidLimits {
2797 reason: "graph adjacency frontier and page exceed the intermediate row ceiling",
2798 }
2799 .into());
2800 }
2801 if resolved_only && relation.is_none() {
2802 return Err(GraphContractError::InvalidLimits {
2803 reason: "resolved graph adjacency requires an exact relation family",
2804 }
2805 .into());
2806 }
2807
2808 let bindings_per_frontier = if relation.is_some() { 4 } else { 2 };
2809 let mut bindings = Vec::with_capacity(
2810 active_frontier * bindings_per_frontier + continuation.map_or(2, |_| 5),
2811 );
2812 bindings.push(Value::Blob(project.as_bytes().to_vec()));
2813 if resolved_only {
2814 bindings.push(Value::Text(RESOLUTION_STATUS_RESOLVED.to_string()));
2815 }
2816 for (index, digest) in frontier_digests.iter().enumerate().skip(continuation_index) {
2817 bindings.push(Value::Blob(digest.to_vec()));
2818 if let Some(relation) = relation {
2819 let (scope, kind) = relation_parts(relation);
2820 bindings.push(Value::Text(scope.to_string()));
2821 bindings.push(Value::Text(kind.to_string()));
2822 }
2823 if index == continuation_index
2824 && let Some(continuation) = continuation
2825 {
2826 bindings.push(Value::Text(continuation.relation_scope.clone()));
2827 bindings.push(Value::Text(continuation.relation_kind.clone()));
2828 bindings.push(Value::Blob(continuation.relation_key.to_vec()));
2829 }
2830 bindings.push(Value::Integer(limit_plus_one));
2831 }
2832 bindings.push(Value::Integer(limit_plus_one));
2833
2834 let sql = adjacency_relation_sql(
2835 frontier.len(),
2836 direction,
2837 continuation.map(|value| value.frontier_index as usize),
2838 relation.is_some(),
2839 resolved_only,
2840 include_documents,
2841 );
2842 let raw = with_sqlite_read_progress(
2843 &self.connection,
2844 control,
2845 IndexWorkStage::RepositoryTraversal,
2846 || {
2847 let mut statement = self.connection.prepare(&sql)?;
2848 collect_adjacency_relation_rows_metered(
2849 statement.query(params_from_iter(bindings.iter()))?,
2850 &mut meter,
2851 )
2852 },
2853 )?;
2854 let truncated = raw.len() > limit as usize;
2855 let next = if truncated {
2856 let last = raw.get(limit as usize - 1).ok_or(DbError::GraphRowShape {
2857 table: "graph_relations",
2858 reason: "adjacency truncation row is missing",
2859 })?;
2860 Some(RepositoryGraphAdjacencyContinuation {
2861 project,
2862 generation,
2863 direction,
2864 relation,
2865 resolved_only,
2866 include_documents,
2867 frontier: frontier_digests,
2868 frontier_index: last.frontier_index,
2869 relation_scope: last.relation.relation_scope.clone(),
2870 relation_kind: last.relation.relation_kind.clone(),
2871 relation_key: fixed_bytes::<32>(
2872 "graph_relations.relation_key",
2873 last.relation.key.clone(),
2874 )?,
2875 })
2876 } else {
2877 None
2878 };
2879 let relation_rows = raw.iter().map(|row| &row.relation).collect::<Vec<_>>();
2880 let entities = load_relation_entity_references_metered(
2881 self,
2882 &relation_rows,
2883 project,
2884 generation,
2885 control,
2886 Some(&mut meter),
2887 )?;
2888 let mut rows = Vec::with_capacity(raw.len());
2889 for row in raw {
2890 if let Some(control) = control {
2891 control.check(IndexWorkStage::RepositoryTraversal)?;
2892 }
2893 let frontier_key = frontier
2894 .get(row.frontier_index as usize)
2895 .ok_or(DbError::GraphRowShape {
2896 table: "graph_relations",
2897 reason: "adjacency row has an invalid frontier position",
2898 })?
2899 .clone();
2900 rows.push(RepositoryGraphAdjacencyRow {
2901 frontier_index: row.frontier_index,
2902 frontier: frontier_key,
2903 direction,
2904 detail: relation_detail_from_row(&entities, row.relation, project, generation)?,
2905 });
2906 }
2907 if truncated {
2908 rows.pop();
2909 }
2910 let work = meter.finish(rows.len())?;
2911 Ok(RepositoryGraphAdjacencyReadPage {
2912 page: RepositoryGraphAdjacencyPage {
2913 rows,
2914 truncated,
2915 continuation: next,
2916 },
2917 work,
2918 })
2919 }
2920
2921 pub fn repository_graph_occurrences(
2928 &self,
2929 relation: &LogicalRelation,
2930 limit: u32,
2931 ) -> DbResult<RepositoryGraphPage<RelationOccurrence>> {
2932 let limit_plus_one = validated_limit_plus_one(
2933 limit,
2934 GraphLimits::MAX_OCCURRENCES,
2935 "graph occurrences must be nonzero and within the product ceiling",
2936 )?;
2937 let Some(generation) = self.repository_graph_generation()? else {
2938 return Ok(empty_page());
2939 };
2940 if relation.generation() != generation {
2941 return Err(
2942 projectatlas_core::graph::GraphContractError::GenerationMismatch {
2943 context: "relation occurrence query",
2944 }
2945 .into(),
2946 );
2947 }
2948 if !verify_project_identity(&self.connection, relation.key().project())? {
2949 return Ok(empty_page());
2950 }
2951 let raw = {
2952 let mut statement = self.connection.prepare_cached(
2953 "SELECT relation_key, file_path, start_line, start_column,
2954 end_line, end_column
2955 FROM graph_relation_occurrences
2956 WHERE relation_key = ?1
2957 ORDER BY file_path, start_line, start_column, end_line, end_column
2958 LIMIT ?2",
2959 )?;
2960 let mut rows =
2961 statement.query(params![&relation.key().digest_bytes()?[..], limit_plus_one])?;
2962 let mut collected = Vec::new();
2963 while let Some(row) = rows.next()? {
2964 collected.push(occurrence_row(row)?);
2965 }
2966 collected
2967 };
2968 page_from_raw(raw, limit, |row| {
2969 occurrence_from_row(row, relation, generation)
2970 })
2971 }
2972
2973 pub fn repository_graph_occurrence_pages(
2985 &self,
2986 relations: &[LogicalRelation],
2987 limit: u32,
2988 control: Option<&IndexWorkControl>,
2989 ) -> DbResult<Vec<RepositoryGraphPage<RelationOccurrence>>> {
2990 Ok(self
2991 .repository_graph_occurrence_pages_bounded(
2992 relations,
2993 limit,
2994 maximum_repository_graph_read_budget()?,
2995 control,
2996 )?
2997 .pages)
2998 }
2999
3000 pub fn repository_graph_occurrence_pages_bounded(
3009 &self,
3010 relations: &[LogicalRelation],
3011 limit: u32,
3012 budget: RepositoryGraphReadBudget,
3013 control: Option<&IndexWorkControl>,
3014 ) -> DbResult<RepositoryGraphReadPages<RelationOccurrence>> {
3015 let limit_plus_one = validated_limit_plus_one(
3016 limit,
3017 GraphLimits::MAX_OCCURRENCES,
3018 "graph occurrences must be nonzero and within the product ceiling",
3019 )?;
3020 if relations.len() > MAX_REPOSITORY_GRAPH_FRONTIER {
3021 return Err(GraphContractError::InvalidLimits {
3022 reason: "graph occurrence batch exceeds the product ceiling",
3023 }
3024 .into());
3025 }
3026 let mut meter = RepositoryGraphReadMeter::new(budget, relations.len())?;
3027 if relations.is_empty() {
3028 return Ok(RepositoryGraphReadPages {
3029 pages: Vec::new(),
3030 work: meter.finish(0)?,
3031 });
3032 }
3033 let work_rows = relations.len().checked_mul(limit_plus_one as usize).ok_or(
3034 GraphContractError::InvalidLimits {
3035 reason: "graph occurrence batch work overflowed",
3036 },
3037 )?;
3038 if work_rows > MAX_REPOSITORY_GRAPH_ADJACENCY_WORK_ROWS {
3039 return Err(GraphContractError::InvalidLimits {
3040 reason: "graph occurrence batch exceeds the intermediate row ceiling",
3041 }
3042 .into());
3043 }
3044 let Some(generation) = self.repository_graph_generation()? else {
3045 return Ok(RepositoryGraphReadPages {
3046 pages: (0..relations.len()).map(|_| empty_page()).collect(),
3047 work: meter.finish(0)?,
3048 });
3049 };
3050 let project = relations[0].key().project();
3051 if !verify_project_identity(&self.connection, project)? {
3052 return Ok(RepositoryGraphReadPages {
3053 pages: (0..relations.len()).map(|_| empty_page()).collect(),
3054 work: meter.finish(0)?,
3055 });
3056 }
3057 let mut bindings = Vec::with_capacity(relations.len() * 2);
3058 for relation in relations {
3059 require_project(project, relation.key().project())?;
3060 if relation.generation() != generation {
3061 return Err(GraphContractError::GenerationMismatch {
3062 context: "relation occurrence batch query",
3063 }
3064 .into());
3065 }
3066 bindings.push(Value::Blob(relation.key().digest_bytes()?.to_vec()));
3067 bindings.push(Value::Integer(limit_plus_one));
3068 }
3069 let sql = occurrence_pages_sql(relations.len());
3070 let raw = with_sqlite_read_progress(
3071 &self.connection,
3072 control,
3073 IndexWorkStage::RepositoryTraversal,
3074 || {
3075 let mut statement = self.connection.prepare(&sql)?;
3076 let mut queried = statement.query(params_from_iter(bindings.iter()))?;
3077 let mut grouped = (0..relations.len())
3078 .map(|_| Vec::new())
3079 .collect::<Vec<Vec<OccurrenceRow>>>();
3080 while let Some(row) = queried.next()? {
3081 let index_value = row.get::<_, i64>(0)?;
3082 let index =
3083 usize::try_from(index_value).map_err(|source| DbError::InvalidCount {
3084 field: "graph_relation_occurrences.relation_index",
3085 value: index_value,
3086 source,
3087 })?;
3088 let group = grouped.get_mut(index).ok_or(DbError::GraphRowShape {
3089 table: "graph_relation_occurrences",
3090 reason: "occurrence batch returned an invalid relation position",
3091 })?;
3092 let occurrence = occurrence_row_at(row, 1)?;
3093 meter.record_decoded_bytes(
3094 occurrence_row_decoded_bytes(&occurrence)?
3095 .checked_add(8)
3096 .ok_or(GraphContractError::InvalidLimits {
3097 reason: "graph occurrence batch decoded row size overflowed",
3098 })?,
3099 )?;
3100 group.push(occurrence);
3101 }
3102 Ok(grouped)
3103 },
3104 )?;
3105 let pages = raw
3106 .into_iter()
3107 .zip(relations)
3108 .map(|(rows, relation)| {
3109 page_from_raw(rows, limit, |row| {
3110 let occurrence = occurrence_from_row(row, relation, generation)?;
3111 meter.record_hydrated_path(occurrence.file().as_str())?;
3112 Ok(occurrence)
3113 })
3114 })
3115 .collect::<DbResult<Vec<_>>>()?;
3116 let returned_rows = pages.iter().try_fold(0_usize, |count, page| {
3117 count
3118 .checked_add(page.rows.len())
3119 .ok_or(GraphContractError::InvalidLimits {
3120 reason: "graph occurrence returned-row accounting overflowed",
3121 })
3122 })?;
3123 let work = meter.finish(returned_rows)?;
3124 Ok(RepositoryGraphReadPages { pages, work })
3125 }
3126
3127 pub fn repository_graph_coverage(
3134 &self,
3135 project: ProjectInstanceId,
3136 scope: &CoverageScope,
3137 limit: u32,
3138 ) -> DbResult<RepositoryGraphPage<CoverageRecord>> {
3139 let limit_plus_one = validated_limit_plus_one(
3140 limit,
3141 GraphLimits::MAX_ROWS,
3142 "graph rows must be nonzero and within the product ceiling",
3143 )?;
3144 let Some(generation) = self.repository_graph_generation()? else {
3145 return Ok(empty_page());
3146 };
3147 if !verify_project_identity(&self.connection, project)? {
3148 return Ok(empty_page());
3149 }
3150 let (scope_kind, scope_path) = coverage_scope_parts(scope);
3151 let raw = {
3152 let mut statement = self.connection.prepare_cached(
3153 "SELECT project_instance_id, scope_kind, scope_path, relation_scope,
3154 relation_kind, state, total, covered, omitted, reason, reached_limit,
3155 NULL, NULL
3156 FROM graph_coverage
3157 WHERE project_instance_id = ?1
3158 AND scope_kind = ?2 AND scope_path IS ?3
3159 ORDER BY relation_scope, relation_kind, state, id
3160 LIMIT ?4",
3161 )?;
3162 let mut rows = statement.query(params![
3163 &project.as_bytes()[..],
3164 scope_kind,
3165 scope_path,
3166 limit_plus_one
3167 ])?;
3168 let mut collected = Vec::new();
3169 while let Some(row) = rows.next()? {
3170 collected.push(coverage_row(row)?);
3171 }
3172 collected
3173 };
3174 page_from_raw(raw, limit, |row| {
3175 coverage_from_row(row, project, generation)
3176 })
3177 }
3178
3179 pub fn repository_graph_path_coverage(
3190 &self,
3191 project: ProjectInstanceId,
3192 paths: &[RepositoryNodePath],
3193 control: Option<&IndexWorkControl>,
3194 ) -> DbResult<RepositoryGraphPage<CoverageRecord>> {
3195 validate_path_coverage_request(paths)?;
3196 if paths.is_empty() {
3197 return Ok(empty_page());
3198 }
3199 let Some(generation) = self.repository_graph_generation()? else {
3200 return Ok(empty_page());
3201 };
3202 if !verify_project_identity(&self.connection, project)? {
3203 return Ok(empty_page());
3204 }
3205 Ok(self
3206 .repository_graph_path_coverage_bounded(
3207 project,
3208 generation,
3209 paths,
3210 maximum_repository_graph_read_budget()?,
3211 control,
3212 )?
3213 .page)
3214 }
3215
3216 pub fn repository_graph_path_coverage_bounded(
3226 &self,
3227 project: ProjectInstanceId,
3228 generation: IndexGeneration,
3229 paths: &[RepositoryNodePath],
3230 budget: RepositoryGraphReadBudget,
3231 control: Option<&IndexWorkControl>,
3232 ) -> DbResult<RepositoryGraphReadPage<CoverageRecord>> {
3233 validate_path_coverage_request(paths)?;
3234 self.require_repository_graph_snapshot(project, generation)?;
3235 let mut meter = RepositoryGraphReadMeter::new(budget, paths.len())?;
3236 if paths.is_empty() {
3237 return Ok(RepositoryGraphReadPage {
3238 page: empty_page(),
3239 work: meter.finish(0)?,
3240 });
3241 }
3242
3243 let sql = path_coverage_sql(paths.len());
3244 let mut bindings = Vec::with_capacity(paths.len() + 3);
3245 bindings.push(Value::Blob(project.as_bytes().to_vec()));
3246 bindings.push(Value::Text("path".to_string()));
3247 bindings.extend(
3248 paths
3249 .iter()
3250 .map(|path| Value::Text(path.as_str().to_string())),
3251 );
3252 bindings.push(Value::Integer(i64::from(GraphLimits::MAX_ROWS) + 1));
3253 let raw = with_sqlite_read_progress(
3254 &self.connection,
3255 control,
3256 IndexWorkStage::RepositoryTraversal,
3257 || {
3258 let mut statement = self.connection.prepare(&sql)?;
3259 let mut rows = statement.query(params_from_iter(bindings.iter()))?;
3260 let mut collected = Vec::new();
3261 while let Some(row) = rows.next()? {
3262 let coverage = coverage_row(row)?;
3263 meter.record_decoded_bytes(coverage_row_decoded_bytes(&coverage)?)?;
3264 collected.push(coverage);
3265 }
3266 Ok(collected)
3267 },
3268 )?;
3269 let page = page_from_raw(raw, GraphLimits::MAX_ROWS, |row| {
3270 let coverage = coverage_from_row(row, project, generation)?;
3271 if let CoverageScope::Path { path } = coverage.scope() {
3272 meter.record_hydrated_path(path.as_str())?;
3273 }
3274 Ok(coverage)
3275 })?;
3276 let work = meter.finish(page.rows.len())?;
3277 Ok(RepositoryGraphReadPage { page, work })
3278 }
3279
3280 pub fn repository_coverage_page(
3288 &self,
3289 project: ProjectInstanceId,
3290 query: &RepositoryCoverageQuery,
3291 ) -> DbResult<RepositoryGraphPage<RepositoryCoverageRow>> {
3292 self.repository_coverage_page_controlled(project, query, None)
3293 }
3294
3295 pub fn repository_coverage_page_controlled(
3302 &self,
3303 project: ProjectInstanceId,
3304 query: &RepositoryCoverageQuery,
3305 control: Option<&IndexWorkControl>,
3306 ) -> DbResult<RepositoryGraphPage<RepositoryCoverageRow>> {
3307 let limit_plus_one = validated_limit_plus_one(
3308 query.limit,
3309 GraphLimits::MAX_ROWS,
3310 "coverage rows must be nonzero and within the product ceiling",
3311 )?;
3312 if query.start_index >= GraphLimits::MAX_ROWS {
3313 return Err(GraphContractError::InvalidLimits {
3314 reason: "coverage start index is at or above the product ceiling",
3315 }
3316 .into());
3317 }
3318 let Some(generation) = self.repository_graph_generation()? else {
3319 return Ok(empty_page());
3320 };
3321 if !verify_project_identity(&self.connection, project)? {
3322 return Ok(empty_page());
3323 }
3324
3325 let provenance_driven = query.parser.is_some() || query.provider.is_some();
3326 let path_prefix = query.path_prefix.as_deref().filter(|prefix| *prefix != ".");
3327 let mut sql = String::from(
3328 "SELECT coverage.project_instance_id, coverage.scope_kind,
3329 coverage.scope_path, coverage.relation_scope,
3330 coverage.relation_kind, coverage.state, coverage.total,
3331 coverage.covered, coverage.omitted, coverage.reason,
3332 coverage.reached_limit, metadata.source_parser,
3333 metadata.fact_parser
3334 FROM ",
3335 );
3336 if provenance_driven {
3337 sql.push_str(
3338 "source_parse_metadata AS metadata
3339 CROSS JOIN graph_coverage AS coverage
3340 ON coverage.scope_kind = 'path'
3341 AND coverage.scope_path = metadata.path",
3342 );
3343 } else {
3344 sql.push_str(
3345 "graph_coverage AS coverage
3346 LEFT JOIN source_parse_metadata AS metadata
3347 ON metadata.path = coverage.scope_path",
3348 );
3349 }
3350 sql.push_str(" WHERE coverage.project_instance_id = ?");
3351 let mut values = vec![Value::Blob(project.as_bytes().to_vec())];
3352
3353 if let Some(prefix) = path_prefix {
3354 sql.push_str(
3355 " AND coverage.scope_kind = 'path'
3356 AND coverage.scope_path >= ? AND coverage.scope_path < ?
3357 AND (coverage.scope_path = ? OR coverage.scope_path >= ?)",
3358 );
3359 values.push(Value::Text(prefix.to_string()));
3360 values.push(Value::Text(format!("{prefix}0")));
3361 values.push(Value::Text(prefix.to_string()));
3362 values.push(Value::Text(format!("{prefix}/")));
3363 }
3364 if let Some(parser) = query.parser {
3365 sql.push_str(" AND metadata.source_parser = ?");
3366 values.push(Value::Text(parser.to_string()));
3367 }
3368 if let Some(provider) = query.provider {
3369 sql.push_str(" AND metadata.fact_parser = ?");
3370 values.push(Value::Text(provider.to_string()));
3371 }
3372 if let Some(relation) = query.relation {
3373 let (scope, kind) = relation_parts(relation);
3374 sql.push_str(" AND coverage.relation_scope = ? AND coverage.relation_kind = ?");
3375 values.push(Value::Text(scope.to_string()));
3376 values.push(Value::Text(kind.to_string()));
3377 }
3378 if let Some(state) = query.state {
3379 match state {
3380 CoverageState::NoCandidates => {
3381 sql.push_str(
3382 " AND coverage.state = 'complete' AND coverage.total = 0 AND coverage.relation_scope = 'extended' AND coverage.relation_kind = 'documents'",
3383 );
3384 }
3385 CoverageState::Complete => {
3386 sql.push_str(
3387 " AND coverage.state = 'complete' AND NOT (coverage.total = 0 AND coverage.relation_scope IS 'extended' AND coverage.relation_kind IS 'documents')",
3388 );
3389 }
3390 _ => {
3391 sql.push_str(" AND coverage.state = ?");
3392 values.push(Value::Text(coverage_state_name(state).to_string()));
3393 }
3394 }
3395 }
3396 if let Some(reason) = query.reason.as_deref() {
3397 sql.push_str(" AND coverage.reason = ?");
3398 values.push(Value::Text(reason.to_string()));
3399 }
3400
3401 if matches!(query.state, Some(CoverageState::NoCandidates)) {
3402 sql.push_str(
3403 " ORDER BY coverage.relation_scope, coverage.relation_kind,
3404 coverage.state, coverage.id",
3405 );
3406 } else if provenance_driven {
3407 sql.push_str(" ORDER BY metadata.path, coverage.id");
3408 } else if path_prefix.is_some() {
3409 sql.push_str(
3410 " ORDER BY coverage.scope_path, coverage.relation_scope,
3411 coverage.relation_kind, coverage.state, coverage.id",
3412 );
3413 } else if query.relation.is_some() {
3414 sql.push_str(
3415 " ORDER BY coverage.relation_scope, coverage.relation_kind,
3416 coverage.state, coverage.id",
3417 );
3418 } else if query.state.is_some() {
3419 sql.push_str(" ORDER BY coverage.state, coverage.scope_path, coverage.id");
3420 } else if query.reason.is_some() {
3421 sql.push_str(" ORDER BY coverage.reason, coverage.scope_path, coverage.id");
3422 } else {
3423 sql.push_str(
3424 " ORDER BY coverage.scope_kind, coverage.scope_path,
3425 coverage.relation_scope, coverage.relation_kind,
3426 coverage.state, coverage.id",
3427 );
3428 }
3429 sql.push_str(" LIMIT ? OFFSET ?");
3430 values.push(Value::Integer(limit_plus_one));
3431 values.push(Value::Integer(i64::from(query.start_index)));
3432
3433 let raw = with_sqlite_read_progress(
3434 &self.connection,
3435 control,
3436 IndexWorkStage::RepositoryTraversal,
3437 || {
3438 let mut statement = self.connection.prepare_cached(&sql)?;
3439 let mut rows = statement.query(params_from_iter(values.iter()))?;
3440 let mut collected = Vec::new();
3441 while let Some(row) = rows.next()? {
3442 collected.push(coverage_row(row)?);
3443 }
3444 Ok(collected)
3445 },
3446 )?;
3447 page_from_raw(raw, query.limit, |row| {
3448 coverage_discovery_from_row(row, project, generation)
3449 })
3450 }
3451
3452 pub fn repository_graph_identity_rejections(
3460 &self,
3461 project: ProjectInstanceId,
3462 paths: &[RepositoryNodePath],
3463 limit: u32,
3464 control: Option<&IndexWorkControl>,
3465 ) -> DbResult<Vec<GraphIdentityRejection>> {
3466 if limit == 0 || limit > GraphLimits::MAX_ROWS {
3467 return Err(GraphContractError::InvalidLimits {
3468 reason: "identity rejection limit must be nonzero and within the product ceiling",
3469 }
3470 .into());
3471 }
3472 let Some(generation) = self.repository_graph_generation()? else {
3473 return Ok(Vec::new());
3474 };
3475 if !verify_project_identity(&self.connection, project)? {
3476 return Ok(Vec::new());
3477 }
3478 if paths.is_empty() {
3479 return Ok(Vec::new());
3480 }
3481 if paths.len() > GraphLimits::MAX_ROWS as usize {
3482 return Err(GraphContractError::InvalidLimits {
3483 reason: "identity rejection path set exceeds the product ceiling",
3484 }
3485 .into());
3486 }
3487 let placeholders = numbered_placeholders(4, paths.len());
3488 let sql = format!(
3489 "SELECT file_path, start_line, start_column, end_line, end_column,
3490 parser, field, reason, fact_index
3491 FROM graph_identity_rejections
3492 WHERE project_instance_id = ?1 AND generation = ?2
3493 AND file_path IN ({placeholders})
3494 ORDER BY file_path, start_line, start_column, end_line, end_column,
3495 parser, field, reason, fact_index, id
3496 LIMIT ?3"
3497 );
3498 let mut values = Vec::with_capacity(paths.len() + 3);
3499 values.push(Value::Blob(project.as_bytes().to_vec()));
3500 values.push(Value::Integer(i64::try_from(generation.get()).map_err(
3501 |_error| GraphContractError::InvalidLimits {
3502 reason: "identity rejection generation exceeded SQLite integer range",
3503 },
3504 )?));
3505 values.push(Value::Integer(i64::from(limit)));
3506 values.extend(
3507 paths
3508 .iter()
3509 .map(|path| Value::Text(path.as_str().to_string())),
3510 );
3511 with_sqlite_read_progress(
3512 &self.connection,
3513 control,
3514 IndexWorkStage::RepositoryTraversal,
3515 || {
3516 let mut statement = self.connection.prepare_cached(&sql)?;
3517 let mut rows = statement.query(params_from_iter(values.iter()))?;
3518 let mut rejections = Vec::new();
3519 while let Some(row) = rows.next()? {
3520 rejections.push(graph_identity_rejection_from_row(row)?);
3521 }
3522 Ok(rejections)
3523 },
3524 )
3525 }
3526
3527 pub fn repository_graph_generation(&self) -> DbResult<Option<IndexGeneration>> {
3534 let Some(publication) = self.index_publication()? else {
3535 return Ok(None);
3536 };
3537 if publication.state != IndexPublicationState::Complete
3538 || publication.generation == IndexGeneration::ZERO
3539 {
3540 return Err(DbError::GraphPublicationUnavailable);
3541 }
3542 let Some(graph_generation) = load_graph_generation(&self.connection)? else {
3543 return Ok(None);
3544 };
3545 if graph_generation == IndexGeneration::ZERO || graph_generation != publication.generation {
3546 return Err(DbError::GraphRowShape {
3547 table: "project_identity",
3548 reason: "typed graph generation does not match complete publication",
3549 });
3550 }
3551 Ok(Some(graph_generation))
3552 }
3553
3554 pub(crate) fn require_repository_graph_snapshot(
3556 &self,
3557 project: ProjectInstanceId,
3558 generation: IndexGeneration,
3559 ) -> DbResult<()> {
3560 require_bound_project_identity(&self.connection, project)?;
3561 let current = self
3562 .repository_graph_generation()?
3563 .ok_or(DbError::GraphPublicationUnavailable)?;
3564 if current != generation {
3565 return Err(GraphContractError::InvalidLimits {
3566 reason: "graph hydration generation does not match the current publication",
3567 }
3568 .into());
3569 }
3570 Ok(())
3571 }
3572
3573 fn collect_relation_rows_by_key(
3575 &self,
3576 key_column: &'static str,
3577 key: &[u8; 32],
3578 limit_plus_one: i64,
3579 ) -> DbResult<Vec<RelationRow>> {
3580 let sql = match key_column {
3581 "source_entity_key" => {
3582 "SELECT relation_key, project_instance_id, canonical_identity,
3583 source_entity_key, relation_scope, relation_kind,
3584 resolution_status, target_entity_key, reference_text,
3585 candidate_count, document_unresolved_reason, confidence, completeness
3586 FROM graph_relations
3587 WHERE source_entity_key = ?1
3588 ORDER BY relation_scope, relation_kind, relation_key
3589 LIMIT ?2"
3590 }
3591 "target_entity_key" => {
3592 "SELECT relation_key, project_instance_id, canonical_identity,
3593 source_entity_key, relation_scope, relation_kind,
3594 resolution_status, target_entity_key, reference_text,
3595 candidate_count, document_unresolved_reason, confidence, completeness
3596 FROM graph_relations
3597 WHERE target_entity_key = ?1
3598 ORDER BY relation_scope, relation_kind, relation_key
3599 LIMIT ?2"
3600 }
3601 _ => {
3602 return Err(DbError::GraphRowShape {
3603 table: "graph_relations",
3604 reason: "unsupported internal relation lookup",
3605 });
3606 }
3607 };
3608 let mut statement = self.connection.prepare_cached(sql)?;
3609 collect_relation_rows(statement.query(params![&key[..], limit_plus_one])?)
3610 }
3611}
3612
3613pub(crate) fn capture_derived_graph(
3615 connection: &Connection,
3616 project: ProjectInstanceId,
3617 generation: IndexGeneration,
3618 budget: &mut SnapshotBudget,
3619) -> DbResult<CapturedGraph> {
3620 let mut entities = Vec::new();
3621 let mut entities_by_key = HashMap::new();
3622 {
3623 let mut statement = connection.prepare(
3624 "SELECT entity_key, project_instance_id, canonical_identity, entity_kind,
3625 repository_path, package_manager, package_name, manifest_path,
3626 symbol_name, symbol_kind, symbol_parent, symbol_signature,
3627 external_system, external_identity
3628 FROM graph_entities
3629 WHERE project_instance_id = ?1
3630 ORDER BY entity_key",
3631 )?;
3632 let mut rows = statement.query(params![&project.as_bytes()[..]])?;
3633 while let Some(row) = rows.next()? {
3634 let raw = entity_row(row)?;
3635 budget.admit(entity_row_decoded_bytes(&raw)?)?;
3636 let entity = entity_from_row(raw, project, generation)?;
3637 let key = entity.key().digest_bytes()?;
3638 if entities_by_key.insert(key, entity.clone()).is_some() {
3639 return Err(DbError::DerivedSnapshotInvalid {
3640 reason: "private capture contains a duplicate entity",
3641 });
3642 }
3643 entities.push(entity);
3644 }
3645 }
3646
3647 let mut relations = Vec::new();
3648 let mut relations_by_key = HashMap::new();
3649 let mut document_unresolved_reasons = Vec::new();
3650 {
3651 let mut statement = connection.prepare(
3652 "SELECT relation_key, project_instance_id, canonical_identity,
3653 source_entity_key, relation_scope, relation_kind,
3654 resolution_status, target_entity_key, reference_text,
3655 candidate_count, document_unresolved_reason, confidence, completeness
3656 FROM graph_relations
3657 WHERE project_instance_id = ?1
3658 ORDER BY relation_key",
3659 )?;
3660 let mut rows = statement.query(params![&project.as_bytes()[..]])?;
3661 while let Some(row) = rows.next()? {
3662 let raw = relation_row(row)?;
3663 budget.admit(relation_row_decoded_bytes(&raw)?)?;
3664 let document_unresolved_reason = document_unresolved_reason_from_row(&raw)?;
3665 let relation = relation_from_row(&entities_by_key, raw, project, generation)?;
3666 let key = relation.key().digest_bytes()?;
3667 if relations_by_key.insert(key, relation.clone()).is_some() {
3668 return Err(DbError::DerivedSnapshotInvalid {
3669 reason: "private capture contains a duplicate relation",
3670 });
3671 }
3672 if let Some(reason) = document_unresolved_reason {
3673 document_unresolved_reasons.push((key, reason));
3674 }
3675 relations.push(relation);
3676 }
3677 }
3678
3679 let mut occurrences = Vec::new();
3680 {
3681 let mut statement = connection.prepare(
3682 "SELECT relation_key, file_path, start_line, start_column, end_line, end_column
3683 FROM graph_relation_occurrences
3684 ORDER BY relation_key, file_path, start_line, start_column, end_line, end_column",
3685 )?;
3686 let mut rows = statement.query([])?;
3687 while let Some(row) = rows.next()? {
3688 let raw = occurrence_row(row)?;
3689 budget.admit(occurrence_row_decoded_bytes(&raw)?)?;
3690 let key = fixed_bytes::<32>(
3691 "graph_relation_occurrences.relation_key",
3692 raw.relation.clone(),
3693 )?;
3694 let relation = relations_by_key
3695 .get(&key)
3696 .ok_or(DbError::DerivedSnapshotInvalid {
3697 reason: "private capture occurrence owner is absent",
3698 })?;
3699 occurrences.push(occurrence_from_row(raw, relation, generation)?);
3700 }
3701 }
3702
3703 let mut coverage = Vec::new();
3704 {
3705 let mut statement = connection.prepare(
3706 "SELECT project_instance_id, scope_kind, scope_path, relation_scope,
3707 relation_kind, state, total, covered, omitted, reason,
3708 reached_limit, NULL, NULL
3709 FROM graph_coverage
3710 WHERE project_instance_id = ?1
3711 ORDER BY scope_kind, scope_path, relation_scope, relation_kind, state, id",
3712 )?;
3713 let mut rows = statement.query(params![&project.as_bytes()[..]])?;
3714 while let Some(row) = rows.next()? {
3715 let raw = coverage_row(row)?;
3716 budget.admit(coverage_row_decoded_bytes(&raw)?)?;
3717 coverage.push(coverage_from_row(raw, project, generation)?);
3718 }
3719 }
3720
3721 let mut entity_exports = Vec::new();
3722 {
3723 let mut statement = connection.prepare(
3724 "SELECT key.project_instance_id, key.resolution_domain, key.key_digest,
3725 key.canonical_identity, export.entity_key
3726 FROM graph_entity_exports AS export
3727 JOIN graph_resolution_keys AS key
3728 ON key.project_instance_id = export.project_instance_id
3729 AND key.resolution_domain = export.resolution_domain
3730 AND key.key_digest = export.key_digest
3731 WHERE export.project_instance_id = ?1
3732 ORDER BY export.entity_key, key.resolution_domain, key.canonical_identity",
3733 )?;
3734 let mut rows = statement.query(params![&project.as_bytes()[..]])?;
3735 while let Some(row) = rows.next()? {
3736 let key = resolution_key_from_row(row, project)?;
3737 let owner =
3738 fixed_bytes::<32>("graph_entity_exports.entity_key", row.get::<_, Vec<u8>>(4)?)?;
3739 budget.admit(decoded_payload_bytes(
3740 [
3741 key.canonical_identity().len(),
3742 key.digest_bytes().len(),
3743 owner.len(),
3744 ],
3745 16,
3746 )?)?;
3747 entity_exports.push((owner, key));
3748 }
3749 }
3750
3751 let mut relation_dependencies = Vec::new();
3752 {
3753 let mut statement = connection.prepare(
3754 "SELECT key.project_instance_id, key.resolution_domain, key.key_digest,
3755 key.canonical_identity, dependency.relation_key
3756 FROM graph_relation_dependencies AS dependency
3757 JOIN graph_resolution_keys AS key
3758 ON key.project_instance_id = dependency.project_instance_id
3759 AND key.resolution_domain = dependency.resolution_domain
3760 AND key.key_digest = dependency.key_digest
3761 WHERE dependency.project_instance_id = ?1
3762 ORDER BY dependency.relation_key, key.resolution_domain, key.canonical_identity",
3763 )?;
3764 let mut rows = statement.query(params![&project.as_bytes()[..]])?;
3765 while let Some(row) = rows.next()? {
3766 let key = resolution_key_from_row(row, project)?;
3767 let owner = fixed_bytes::<32>(
3768 "graph_relation_dependencies.relation_key",
3769 row.get::<_, Vec<u8>>(4)?,
3770 )?;
3771 budget.admit(decoded_payload_bytes(
3772 [
3773 key.canonical_identity().len(),
3774 key.digest_bytes().len(),
3775 owner.len(),
3776 ],
3777 16,
3778 )?)?;
3779 relation_dependencies.push((owner, key));
3780 }
3781 }
3782
3783 Ok(CapturedGraph {
3784 file_classifications: Vec::new(),
3785 entities,
3786 relations,
3787 document_unresolved_reasons,
3788 occurrences,
3789 coverage,
3790 entity_exports,
3791 relation_dependencies,
3792 })
3793}
3794
3795impl AtlasStore {
3796 pub fn repository_graph_staging_belongs_to(
3806 path: &Path,
3807 root: &Path,
3808 project: ProjectInstanceId,
3809 ) -> DbResult<bool> {
3810 let connection = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
3811 let marker = connection
3812 .query_row(
3813 "SELECT value FROM metadata WHERE key = ?1",
3814 [GRAPH_STAGING_MARKER_KEY],
3815 |row| row.get::<_, String>(0),
3816 )
3817 .optional()?;
3818 let schema_version = connection
3819 .query_row(
3820 "SELECT value FROM metadata WHERE key = ?1",
3821 [schema::SCHEMA_VERSION_KEY],
3822 |row| row.get::<_, String>(0),
3823 )
3824 .optional()?
3825 .and_then(|value| value.parse::<i64>().ok());
3826 let Some(schema_version) = schema_version else {
3827 return Ok(false);
3828 };
3829 let expected_root = projectatlas_core::CanonicalProjectRoot::from_path(root)?;
3830 let roots_match = match schema_version {
3831 schema::SCHEMA_VERSION => {
3832 load_project_root_identity(&connection)?.is_some_and(|stored_root| {
3833 prove_existing_root_equivalence(expected_root.as_path(), stored_root.as_path())
3834 .is_ok()
3835 })
3836 }
3837 schema::CANONICAL_ROOT_PREDECESSOR_SCHEMA_VERSION => true,
3842 _ => false,
3843 };
3844 Ok(roots_match
3845 && load_project_identity(&connection)? == Some(project)
3846 && marker.as_deref() == Some(GRAPH_STAGING_MARKER_VALUE))
3847 }
3848
3849 pub fn create_repository_graph_staging(
3856 path: &Path,
3857 root: &Path,
3858 project: ProjectInstanceId,
3859 ) -> DbResult<Self> {
3860 if path.exists() {
3861 return Err(DbError::GraphRowShape {
3862 table: "repository graph staging database",
3863 reason: "staging path already exists",
3864 });
3865 }
3866 let mut store = Self::open_for_project(path, root)?;
3867 set_project_identity(&store.connection, project)?;
3868 store.connection.execute(
3869 "INSERT INTO metadata(key, value) VALUES (?1, ?2)
3870 ON CONFLICT(key) DO UPDATE SET value = excluded.value",
3871 params![GRAPH_STAGING_MARKER_KEY, GRAPH_STAGING_MARKER_VALUE],
3872 )?;
3873 drop_graph_rebuildable_indexes(&store.connection)?;
3874 store.checkpoint_repository_graph_staging()?;
3875 store.validated_project_instance_id = Some(project);
3876 Ok(store)
3877 }
3878
3879 pub fn checkpoint_repository_graph_staging(&self) -> DbResult<()> {
3886 let (busy, _log_frames, _checkpointed_frames) =
3887 self.connection
3888 .query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |row| {
3889 Ok((
3890 row.get::<_, i64>(0)?,
3891 row.get::<_, i64>(1)?,
3892 row.get::<_, i64>(2)?,
3893 ))
3894 })?;
3895 if busy != 0 {
3896 return Err(DbError::GraphRowShape {
3897 table: "repository graph staging database",
3898 reason: "WAL checkpoint remained busy",
3899 });
3900 }
3901 Ok(())
3902 }
3903
3904 pub fn begin_repository_graph_staging(
3911 &mut self,
3912 project: ProjectInstanceId,
3913 generation: IndexGeneration,
3914 ) -> DbResult<RepositoryGraphStagingGuard<'_>> {
3915 if generation == IndexGeneration::ZERO {
3916 return Err(GraphContractError::InvalidGeneration.into());
3917 }
3918 let transaction = self.connection.transaction()?;
3919 require_bound_project_identity(&transaction, project)?;
3920 transaction.execute("DELETE FROM graph_resolution_keys", [])?;
3921 transaction.execute("DELETE FROM graph_coverage", [])?;
3922 transaction.execute("DELETE FROM graph_identity_rejections", [])?;
3923 transaction.execute("DELETE FROM graph_relations", [])?;
3924 transaction.execute("DELETE FROM graph_entities", [])?;
3925 Ok(RepositoryGraphStagingGuard {
3926 transaction,
3927 project,
3928 generation,
3929 })
3930 }
3931}
3932
3933impl RepositoryGraphStagingGuard<'_> {
3934 pub fn append_entity_refs(&mut self, entities: &[&GraphEntity]) -> DbResult<()> {
3941 if entities.iter().any(|entity| {
3942 entity.key().project() != self.project || entity.generation() != self.generation
3943 }) {
3944 return Err(GraphContractError::GenerationMismatch {
3945 context: "repository graph staging entities",
3946 }
3947 .into());
3948 }
3949 insert_entities(&self.transaction, self.project, entities.iter().copied())
3950 }
3951
3952 #[allow(clippy::too_many_arguments)]
3959 pub fn append_batch(
3960 &mut self,
3961 entities: &[GraphEntity],
3962 relations: &[LogicalRelation],
3963 occurrences: &[RelationOccurrence],
3964 coverage: &[CoverageRecord],
3965 entity_exports: &[EntityResolutionKey],
3966 relation_dependencies: &[RelationDependencyKey],
3967 ) -> DbResult<()> {
3968 validate_graph_batch(
3969 self.project,
3970 self.generation,
3971 entities,
3972 relations,
3973 occurrences,
3974 coverage,
3975 )?;
3976 validate_resolution_key_batch(self.project, entity_exports, relation_dependencies)?;
3977 insert_graph_batch(
3978 &self.transaction,
3979 self.project,
3980 entities,
3981 relations,
3982 occurrences,
3983 coverage,
3984 )?;
3985 insert_resolution_key_batch(
3986 &self.transaction,
3987 self.project,
3988 entity_exports,
3989 relation_dependencies,
3990 )
3991 }
3992
3993 pub fn set_document_unresolved_reasons(
4000 &mut self,
4001 reasons: &[(LogicalRelationKey, DocumentTargetUnresolvedReason)],
4002 ) -> DbResult<()> {
4003 self.set_document_unresolved_reasons_with_control(reasons, None)
4004 }
4005
4006 pub fn set_document_unresolved_reasons_controlled(
4013 &mut self,
4014 reasons: &[(LogicalRelationKey, DocumentTargetUnresolvedReason)],
4015 control: &IndexWorkControl,
4016 ) -> DbResult<()> {
4017 self.set_document_unresolved_reasons_with_control(reasons, Some(control))
4018 }
4019
4020 fn set_document_unresolved_reasons_with_control(
4022 &mut self,
4023 reasons: &[(LogicalRelationKey, DocumentTargetUnresolvedReason)],
4024 control: Option<&IndexWorkControl>,
4025 ) -> DbResult<()> {
4026 let savepoint = self.transaction.savepoint()?;
4027 write_document_unresolved_reasons(&savepoint, reasons, control)?;
4028 savepoint.commit()?;
4029 Ok(())
4030 }
4031
4032 pub fn complete(self) -> DbResult<()> {
4038 validate_complete_document_unresolved_reasons(&self.transaction)?;
4039 set_graph_generation(&self.transaction, self.generation)?;
4040 self.transaction.commit()?;
4041 Ok(())
4042 }
4043}
4044
4045const GRAPH_STAGE_COPY_TABLES: &[(&str, &str)] = &[
4047 (
4048 "graph_entities",
4049 "entity_key, project_instance_id, canonical_identity, entity_kind, repository_path, \
4050 package_manager, package_name, manifest_path, symbol_name, symbol_kind, symbol_parent, \
4051 symbol_signature, external_system, external_identity",
4052 ),
4053 (
4054 "graph_relations",
4055 "relation_key, project_instance_id, canonical_identity, source_entity_key, \
4056 relation_scope, relation_kind, resolution_status, target_entity_key, reference_text, \
4057 candidate_count, document_unresolved_reason, confidence, completeness",
4058 ),
4059 (
4060 "graph_relation_occurrences",
4061 "relation_key, file_path, start_line, start_column, end_line, end_column",
4062 ),
4063 (
4064 "graph_coverage",
4065 "project_instance_id, scope_kind, scope_path, relation_scope, relation_kind, state, \
4066 total, covered, omitted, reason, reached_limit",
4067 ),
4068 (
4069 "graph_resolution_keys",
4070 "project_instance_id, resolution_domain, key_digest, canonical_identity",
4071 ),
4072 (
4073 "graph_entity_exports",
4074 "project_instance_id, entity_key, owner_path, resolution_domain, key_digest",
4075 ),
4076 (
4077 "graph_relation_dependencies",
4078 "project_instance_id, relation_key, owner_path, resolution_domain, key_digest",
4079 ),
4080];
4081
4082const GRAPH_REBUILDABLE_INDEX_NAMES: &[&str] = &[
4084 "idx_graph_entities_path",
4085 "idx_graph_entities_package",
4086 "idx_graph_entities_manifest_path",
4087 "idx_graph_entities_symbol",
4088 "idx_graph_entities_external",
4089 "idx_graph_relations_source_kind",
4090 "idx_graph_relations_target_kind",
4091 "idx_graph_relations_kind_order",
4092 "idx_graph_relations_kind_resolution",
4093 "idx_graph_occurrences_file_span",
4094 "idx_graph_coverage_scope_state",
4095 "idx_graph_coverage_scope_order",
4096 "idx_graph_coverage_path",
4097 "idx_graph_coverage_relation_state",
4098 "idx_graph_coverage_discovery_state",
4099 "idx_graph_coverage_discovery_reason",
4100 "idx_graph_entity_exports_key",
4101 "idx_graph_entity_exports_owner",
4102 "idx_graph_relation_dependencies_key",
4103 "idx_graph_relation_dependencies_owner",
4104];
4105
4106fn drop_graph_rebuildable_indexes(connection: &Connection) -> DbResult<()> {
4108 for name in GRAPH_REBUILDABLE_INDEX_NAMES {
4109 connection.execute(&format!("DROP INDEX IF EXISTS {name}"), [])?;
4110 }
4111 Ok(())
4112}
4113
4114fn graph_rebuildable_index_sql(connection: &Connection) -> DbResult<Vec<String>> {
4116 GRAPH_REBUILDABLE_INDEX_NAMES
4117 .iter()
4118 .map(|name| {
4119 connection
4120 .query_row(
4121 "SELECT sql FROM sqlite_schema WHERE type = 'index' AND name = ?1",
4122 [name],
4123 |row| row.get::<_, String>(0),
4124 )
4125 .optional()?
4126 .ok_or(DbError::GraphRowShape {
4127 table: "repository graph indexes",
4128 reason: "required rebuildable index is missing",
4129 })
4130 })
4131 .collect()
4132}
4133const GRAPH_STAGE_COPY_ROWS: usize = 512;
4135
4136fn insert_graph_stage_values(
4138 target: &Connection,
4139 table: &'static str,
4140 columns: &'static str,
4141 column_count: usize,
4142 values: &[Value],
4143) -> DbResult<()> {
4144 let rows = values.len() / column_count;
4145 let row = format!(
4146 "({})",
4147 std::iter::repeat_n("?", column_count)
4148 .collect::<Vec<_>>()
4149 .join(",")
4150 );
4151 let placeholders = std::iter::repeat_n(row, rows).collect::<Vec<_>>().join(",");
4152 let insert_sql = format!("INSERT INTO {table}({columns}) VALUES{placeholders}");
4153 target.execute(&insert_sql, params_from_iter(values.iter()))?;
4154 Ok(())
4155}
4156
4157fn copy_graph_stage_table(
4159 source: &Connection,
4160 target: &Connection,
4161 table: &'static str,
4162 columns: &'static str,
4163 control: Option<&IndexWorkControl>,
4164) -> DbResult<()> {
4165 let column_count = columns.split(',').count();
4166 let select_sql = format!("SELECT {columns} FROM {table}");
4167 let mut select = source.prepare(&select_sql)?;
4168 let mut rows = select.query([])?;
4169 let mut copied = 0_usize;
4170 let mut values = Vec::with_capacity(GRAPH_STAGE_COPY_ROWS.saturating_mul(column_count));
4171 while let Some(row) = rows.next()? {
4172 if copied.is_multiple_of(256)
4173 && let Some(control) = control
4174 {
4175 control.check(IndexWorkStage::Publication)?;
4176 }
4177 for column in 0..column_count {
4178 values.push(row.get::<_, Value>(column)?);
4179 }
4180 copied = copied.saturating_add(1);
4181 if copied.is_multiple_of(GRAPH_STAGE_COPY_ROWS) {
4182 insert_graph_stage_values(target, table, columns, column_count, &values)?;
4183 values.clear();
4184 }
4185 }
4186 if !values.is_empty() {
4187 insert_graph_stage_values(target, table, columns, column_count, &values)?;
4188 }
4189 Ok(())
4190}
4191
4192impl IndexPublicationGuard<'_> {
4193 pub fn replace_repository_graph_from_staging(
4200 &mut self,
4201 project: ProjectInstanceId,
4202 staging: &AtlasStore,
4203 control: Option<&IndexWorkControl>,
4204 ) -> DbResult<()> {
4205 let generation = self.pending_graph_generation()?;
4206 let staged_project = load_project_identity(&staging.connection)?;
4207 if staged_project != Some(project) {
4208 return Err(DbError::GraphProjectIdentityMismatch {
4209 expected: project.to_string(),
4210 found: staged_project
4211 .map_or_else(|| "missing".to_string(), |value| value.to_string()),
4212 });
4213 }
4214 if load_graph_generation(&staging.connection)? != Some(generation) {
4215 return Err(GraphContractError::GenerationMismatch {
4216 context: "staged repository graph",
4217 }
4218 .into());
4219 }
4220 let quick_check = staging
4221 .connection
4222 .query_row("PRAGMA quick_check(1)", [], |row| row.get::<_, String>(0))?;
4223 if quick_check != "ok" {
4224 return Err(DbError::GraphRowShape {
4225 table: "repository graph staging database",
4226 reason: "quick check failed",
4227 });
4228 }
4229 let mut foreign_keys = staging.connection.prepare("PRAGMA foreign_key_check")?;
4230 if foreign_keys.query([])?.next()?.is_some() {
4231 return Err(DbError::GraphRowShape {
4232 table: "repository graph staging database",
4233 reason: "foreign key check failed",
4234 });
4235 }
4236 validate_complete_document_unresolved_reasons(&staging.connection)?;
4237
4238 let savepoint = self.store.connection.savepoint()?;
4239 require_bound_project_identity(&savepoint, project)?;
4240 prepare_graph_identity_rejections(&savepoint, project, generation, None)?;
4241 let rebuildable_indexes = graph_rebuildable_index_sql(&savepoint)?;
4242 drop_graph_rebuildable_indexes(&savepoint)?;
4243 savepoint.execute("DELETE FROM graph_resolution_keys", [])?;
4244 savepoint.execute("DELETE FROM graph_coverage", [])?;
4245 savepoint.execute("DELETE FROM graph_relations", [])?;
4246 savepoint.execute("DELETE FROM graph_entities", [])?;
4247 for (table, columns) in GRAPH_STAGE_COPY_TABLES {
4248 copy_graph_stage_table(&staging.connection, &savepoint, table, columns, control)?;
4249 }
4250 set_graph_generation(&savepoint, generation)?;
4251 for index_sql in rebuildable_indexes {
4252 savepoint.execute_batch(&index_sql)?;
4253 }
4254 savepoint.commit()?;
4255 Ok(())
4256 }
4257
4258 pub fn replace_repository_graph(
4265 &mut self,
4266 project: ProjectInstanceId,
4267 entities: &[GraphEntity],
4268 relations: &[LogicalRelation],
4269 occurrences: &[RelationOccurrence],
4270 coverage: &[CoverageRecord],
4271 ) -> DbResult<()> {
4272 self.replace_repository_graph_with_resolution_keys(
4273 project,
4274 entities,
4275 relations,
4276 occurrences,
4277 coverage,
4278 &[],
4279 &[],
4280 )
4281 }
4282
4283 #[allow(clippy::too_many_arguments)]
4291 pub fn replace_repository_graph_with_resolution_keys(
4292 &mut self,
4293 project: ProjectInstanceId,
4294 entities: &[GraphEntity],
4295 relations: &[LogicalRelation],
4296 occurrences: &[RelationOccurrence],
4297 coverage: &[CoverageRecord],
4298 entity_exports: &[EntityResolutionKey],
4299 relation_dependencies: &[RelationDependencyKey],
4300 ) -> DbResult<()> {
4301 let generation = self.pending_graph_generation()?;
4302 validate_graph_batch(
4303 project,
4304 generation,
4305 entities,
4306 relations,
4307 occurrences,
4308 coverage,
4309 )?;
4310 validate_resolution_key_batch(project, entity_exports, relation_dependencies)?;
4311 let savepoint = self.store.connection.savepoint()?;
4312 require_bound_project_identity(&savepoint, project)?;
4313 prepare_graph_identity_rejections(&savepoint, project, generation, None)?;
4314 savepoint.execute("DELETE FROM graph_resolution_keys", [])?;
4315 savepoint.execute("DELETE FROM graph_coverage", [])?;
4316 savepoint.execute("DELETE FROM graph_relations", [])?;
4317 savepoint.execute("DELETE FROM graph_entities", [])?;
4318 insert_graph_batch(
4319 &savepoint,
4320 project,
4321 entities,
4322 relations,
4323 occurrences,
4324 coverage,
4325 )?;
4326 insert_resolution_key_batch(&savepoint, project, entity_exports, relation_dependencies)?;
4327 set_graph_generation(&savepoint, generation)?;
4328 savepoint.commit()?;
4329 Ok(())
4330 }
4331
4332 pub fn replace_repository_graph_for_paths(
4343 &mut self,
4344 project: ProjectInstanceId,
4345 affected_paths: &[String],
4346 entities: &[GraphEntity],
4347 relations: &[LogicalRelation],
4348 occurrences: &[RelationOccurrence],
4349 coverage: &[CoverageRecord],
4350 ) -> DbResult<()> {
4351 self.replace_repository_graph_for_paths_with_resolution_keys(
4352 project,
4353 affected_paths,
4354 entities,
4355 relations,
4356 occurrences,
4357 coverage,
4358 &[],
4359 &[],
4360 )
4361 }
4362
4363 #[allow(clippy::too_many_arguments)]
4371 pub fn replace_repository_graph_for_paths_with_resolution_keys(
4372 &mut self,
4373 project: ProjectInstanceId,
4374 affected_paths: &[String],
4375 entities: &[GraphEntity],
4376 relations: &[LogicalRelation],
4377 occurrences: &[RelationOccurrence],
4378 coverage: &[CoverageRecord],
4379 entity_exports: &[EntityResolutionKey],
4380 relation_dependencies: &[RelationDependencyKey],
4381 ) -> DbResult<()> {
4382 let generation = self.pending_graph_generation()?;
4383 validate_graph_batch(
4384 project,
4385 generation,
4386 entities,
4387 relations,
4388 occurrences,
4389 coverage,
4390 )?;
4391 validate_resolution_key_batch(project, entity_exports, relation_dependencies)?;
4392 let affected_paths = affected_paths
4393 .iter()
4394 .map(|path| RepositoryNodePath::new(Path::new(path)))
4395 .collect::<Result<Vec<_>, _>>()?;
4396 let savepoint = self.store.connection.savepoint()?;
4397 require_bound_project_identity(&savepoint, project)?;
4398 if affected_paths.iter().any(|path| path.as_str() == ".") {
4399 prepare_graph_identity_rejections(&savepoint, project, generation, None)?;
4400 savepoint.execute("DELETE FROM graph_resolution_keys", [])?;
4401 savepoint.execute("DELETE FROM graph_coverage", [])?;
4402 savepoint.execute("DELETE FROM graph_relations", [])?;
4403 savepoint.execute("DELETE FROM graph_entities", [])?;
4404 insert_graph_batch(
4405 &savepoint,
4406 project,
4407 entities,
4408 relations,
4409 occurrences,
4410 coverage,
4411 )?;
4412 insert_resolution_key_batch(
4413 &savepoint,
4414 project,
4415 entity_exports,
4416 relation_dependencies,
4417 )?;
4418 set_graph_generation(&savepoint, generation)?;
4419 savepoint.commit()?;
4420 return Ok(());
4421 }
4422 prepare_graph_identity_rejections(&savepoint, project, generation, Some(&affected_paths))?;
4423 let touched_keys = resolution_keys_for_owner_paths(
4424 &savepoint,
4425 project,
4426 &affected_paths,
4427 ResolutionOwner::Both,
4428 None,
4429 )?;
4430 let mut orphan_candidates = affected_external_candidates(&savepoint, &affected_paths)?;
4431 invalidate_repository_graph_paths(&savepoint, &affected_paths, &mut orphan_candidates)?;
4432 insert_graph_batch(
4433 &savepoint,
4434 project,
4435 entities,
4436 relations,
4437 occurrences,
4438 coverage,
4439 )?;
4440 insert_resolution_key_batch(&savepoint, project, entity_exports, relation_dependencies)?;
4441 for entity in entities {
4442 if matches!(entity.selector(), EntitySelector::External { .. }) {
4443 orphan_candidates.insert(entity.key().digest_bytes()?);
4444 }
4445 }
4446 remove_orphan_external_candidates(&savepoint, &orphan_candidates)?;
4447 remove_touched_orphan_resolution_keys(&savepoint, &touched_keys)?;
4448 set_graph_generation(&savepoint, generation)?;
4449 savepoint.commit()?;
4450 Ok(())
4451 }
4452
4453 pub fn set_document_unresolved_reasons(
4465 &mut self,
4466 reasons: &[(LogicalRelationKey, DocumentTargetUnresolvedReason)],
4467 ) -> DbResult<()> {
4468 self.set_document_unresolved_reasons_with_control(reasons, None)
4469 }
4470
4471 pub fn set_document_unresolved_reasons_controlled(
4478 &mut self,
4479 reasons: &[(LogicalRelationKey, DocumentTargetUnresolvedReason)],
4480 control: &IndexWorkControl,
4481 ) -> DbResult<()> {
4482 self.set_document_unresolved_reasons_with_control(reasons, Some(control))
4483 }
4484
4485 fn set_document_unresolved_reasons_with_control(
4487 &mut self,
4488 reasons: &[(LogicalRelationKey, DocumentTargetUnresolvedReason)],
4489 control: Option<&IndexWorkControl>,
4490 ) -> DbResult<()> {
4491 let savepoint = self.store.validated_savepoint()?;
4492 write_document_unresolved_reasons(&savepoint, reasons, control)?;
4493 savepoint.commit()?;
4494 Ok(())
4495 }
4496
4497 pub fn replace_graph_identity_rejections(
4509 &mut self,
4510 project: ProjectInstanceId,
4511 rejections: &[GraphIdentityRejection],
4512 ) -> DbResult<()> {
4513 if rejections.len() > GraphLimits::MAX_ROWS as usize {
4514 return Err(GraphContractError::InvalidLimits {
4515 reason: "graph identity rejection batch exceeds the graph row ceiling",
4516 }
4517 .into());
4518 }
4519 let generation = self.pending_graph_generation()?;
4520 let savepoint = self.store.validated_savepoint()?;
4521 require_bound_project_identity(&savepoint, project)?;
4522 insert_graph_identity_rejections(&savepoint, project, generation, rejections)?;
4523 let generation_value =
4524 sqlite_count("graph_identity_rejections.generation", generation.get())?;
4525 let persisted_count = savepoint.query_row(
4526 "SELECT COUNT(*)
4527 FROM graph_identity_rejections
4528 WHERE project_instance_id = ?1 AND generation = ?2",
4529 params![&project.as_bytes()[..], generation_value],
4530 |row| row.get::<_, i64>(0),
4531 )?;
4532 if persisted_count > i64::from(GraphLimits::MAX_ROWS) {
4533 return Err(GraphContractError::InvalidLimits {
4534 reason: "complete graph identity rejection generation exceeds the graph row ceiling",
4535 }
4536 .into());
4537 }
4538 savepoint.commit()?;
4539 Ok(())
4540 }
4541
4542 fn pending_graph_generation(&self) -> DbResult<IndexGeneration> {
4544 self.previous_generation
4545 .checked_next()
4546 .ok_or(DbError::PublicationGenerationOverflow)
4547 }
4548}
4549
4550fn write_document_unresolved_reasons(
4552 connection: &Connection,
4553 reasons: &[(LogicalRelationKey, DocumentTargetUnresolvedReason)],
4554 control: Option<&IndexWorkControl>,
4555) -> DbResult<()> {
4556 if let Some(control) = control {
4557 control.check(IndexWorkStage::Publication)?;
4558 }
4559 validate_document_unresolved_reasons(connection, reasons, control)?;
4560 for chunk in reasons.chunks(GraphLimits::MAX_ROWS as usize) {
4561 if let Some(control) = control {
4562 control.check(IndexWorkStage::Publication)?;
4563 }
4564 write_document_unresolved_reason_chunk(connection, chunk, control)?;
4565 }
4566 Ok(())
4567}
4568
4569fn validate_document_unresolved_reasons(
4571 connection: &Connection,
4572 reasons: &[(LogicalRelationKey, DocumentTargetUnresolvedReason)],
4573 control: Option<&IndexWorkControl>,
4574) -> DbResult<()> {
4575 let mut keys = HashSet::with_capacity(reasons.len());
4576 for (index, (key, _)) in reasons.iter().enumerate() {
4577 if index.is_multiple_of(256)
4578 && let Some(control) = control
4579 {
4580 control.check(IndexWorkStage::Publication)?;
4581 }
4582 if !keys.insert(key.digest_bytes()?) {
4583 return Err(GraphContractError::InvalidLimits {
4584 reason: "document unresolved reason batch repeats a relation key",
4585 }
4586 .into());
4587 }
4588 }
4589 let mut validate = connection.prepare_cached(
4590 "SELECT EXISTS(
4591 SELECT 1
4592 FROM graph_relations
4593 INDEXED BY idx_graph_relations_project_key
4594 WHERE relation_key = ?1
4595 AND project_instance_id = ?2
4596 AND relation_scope = 'extended'
4597 AND relation_kind = 'documents'
4598 AND resolution_status = 'unresolved'
4599 )",
4600 )?;
4601 for (index, (key, _)) in reasons.iter().enumerate() {
4602 if index.is_multiple_of(256)
4603 && let Some(control) = control
4604 {
4605 control.check(IndexWorkStage::Publication)?;
4606 }
4607 let digest = key.digest_bytes()?;
4608 let compatible = validate
4609 .query_row(params![&digest[..], &key.project().as_bytes()[..]], |row| {
4610 row.get::<_, bool>(0)
4611 })?;
4612 if !compatible {
4613 return Err(DbError::GraphRowShape {
4614 table: "graph_relations",
4615 reason: "document unresolved reason target is missing or incompatible",
4616 });
4617 }
4618 }
4619 drop(validate);
4620 Ok(())
4621}
4622
4623fn write_document_unresolved_reason_chunk(
4625 connection: &Connection,
4626 reasons: &[(LogicalRelationKey, DocumentTargetUnresolvedReason)],
4627 control: Option<&IndexWorkControl>,
4628) -> DbResult<()> {
4629 let mut statement = connection.prepare_cached(
4630 "UPDATE graph_relations
4631 SET document_unresolved_reason = ?2
4632 WHERE relation_key = ?1
4633 AND project_instance_id = ?3
4634 AND relation_scope = 'extended'
4635 AND relation_kind = 'documents'
4636 AND resolution_status = 'unresolved'",
4637 )?;
4638 for (index, (key, reason)) in reasons.iter().enumerate() {
4639 if index.is_multiple_of(256)
4640 && let Some(control) = control
4641 {
4642 control.check(IndexWorkStage::Publication)?;
4643 }
4644 let digest = key.digest_bytes()?;
4645 let changed = statement.execute(params![
4646 &digest[..],
4647 reason.as_str(),
4648 &key.project().as_bytes()[..],
4649 ])?;
4650 debug_assert_eq!(changed, 1);
4651 }
4652 if let Some(control) = control {
4653 control.check(IndexWorkStage::Publication)?;
4654 }
4655 Ok(())
4656}
4657
4658pub(crate) fn validate_complete_document_unresolved_reasons(
4660 connection: &Connection,
4661) -> DbResult<()> {
4662 let invalid = connection
4663 .query_row(
4664 "SELECT relation_key
4665 FROM graph_relations
4666 WHERE (
4667 relation_scope = 'extended'
4668 AND relation_kind = 'documents'
4669 AND resolution_status = 'unresolved'
4670 AND document_unresolved_reason IS NULL
4671 ) OR (
4672 document_unresolved_reason IS NOT NULL
4673 AND NOT (
4674 relation_scope = 'extended'
4675 AND relation_kind = 'documents'
4676 AND resolution_status = 'unresolved'
4677 )
4678 )
4679 LIMIT 1",
4680 [],
4681 |row| row.get::<_, Vec<u8>>(0),
4682 )
4683 .optional()?;
4684 if invalid.is_some() {
4685 return Err(DbError::GraphRowShape {
4686 table: "graph_relations",
4687 reason: "published document unresolved reason invariant is incomplete",
4688 });
4689 }
4690 Ok(())
4691}
4692
4693const RESOLUTION_KEYS_PER_QUERY: usize = 200;
4695const RESOLUTION_PATHS_PER_QUERY: usize = 400;
4697const EXTERNAL_CANDIDATE_KEYS_PER_QUERY: usize = 400;
4699
4700#[derive(Clone, Copy)]
4702enum ResolutionOwner {
4703 EntityExports,
4705 Both,
4707}
4708
4709impl ResolutionOwner {
4710 fn tables(self) -> &'static [(&'static str, &'static str)] {
4712 match self {
4713 Self::EntityExports => &[("graph_entity_exports", "idx_graph_entity_exports_owner")],
4714 Self::Both => &[
4715 ("graph_entity_exports", "idx_graph_entity_exports_owner"),
4716 (
4717 "graph_relation_dependencies",
4718 "idx_graph_relation_dependencies_owner",
4719 ),
4720 ],
4721 }
4722 }
4723}
4724
4725fn normalized_file_paths(paths: &[String]) -> DbResult<Vec<RepositoryNodePath>> {
4727 let mut normalized = BTreeSet::new();
4728 for path in paths {
4729 let file = RepositoryFilePath::new(Path::new(path))?;
4730 normalized.insert(RepositoryNodePath::new(Path::new(file.as_str()))?);
4731 }
4732 Ok(normalized.into_iter().collect())
4733}
4734
4735fn normalized_resolution_keys(
4737 project: ProjectInstanceId,
4738 keys: &[CanonicalResolutionKey],
4739) -> DbResult<Vec<CanonicalResolutionKey>> {
4740 let mut normalized = BTreeSet::new();
4741 for key in keys {
4742 if key.project() != project {
4743 return Err(DbError::GraphProjectIdentityMismatch {
4744 expected: project.to_string(),
4745 found: key.project().to_string(),
4746 });
4747 }
4748 normalized.insert(key.clone());
4749 }
4750 Ok(normalized.into_iter().collect())
4751}
4752
4753fn page_from_ordered_set<T: Ord>(rows: BTreeSet<T>, limit: u32) -> RepositoryGraphPage<T> {
4755 let truncated = rows.len() > limit as usize;
4756 RepositoryGraphPage {
4757 rows: rows.into_iter().take(limit as usize).collect(),
4758 truncated,
4759 }
4760}
4761
4762fn resolution_keys_for_owner_paths(
4764 connection: &Connection,
4765 project: ProjectInstanceId,
4766 paths: &[RepositoryNodePath],
4767 owners: ResolutionOwner,
4768 limit: Option<u32>,
4769) -> DbResult<BTreeSet<CanonicalResolutionKey>> {
4770 let mut keys = BTreeSet::new();
4771 for (table, index) in owners.tables() {
4772 for chunk in paths.chunks(RESOLUTION_PATHS_PER_QUERY) {
4773 if chunk.is_empty() {
4774 continue;
4775 }
4776 let placeholders = vec!["?"; chunk.len()].join(",");
4777 let limit_clause = limit.map_or("", |_| " LIMIT ?");
4778 let sql = format!(
4779 "SELECT DISTINCT resolution.project_instance_id,
4780 resolution.resolution_domain, resolution.key_digest,
4781 resolution.canonical_identity
4782 FROM {table} AS owner INDEXED BY {index}
4783 JOIN graph_resolution_keys AS resolution
4784 ON resolution.project_instance_id = owner.project_instance_id
4785 AND resolution.resolution_domain = owner.resolution_domain
4786 AND resolution.key_digest = owner.key_digest
4787 WHERE owner.project_instance_id = ?
4788 AND owner.owner_path IN ({placeholders})
4789 ORDER BY resolution.resolution_domain, resolution.key_digest{limit_clause}"
4790 );
4791 let mut values = Vec::with_capacity(chunk.len() + 2);
4792 values.push(Value::Blob(project.as_bytes().to_vec()));
4793 values.extend(
4794 chunk
4795 .iter()
4796 .map(|path| Value::Text(path.as_str().to_string())),
4797 );
4798 if let Some(limit) = limit {
4799 values.push(Value::Integer(i64::from(limit) + 1));
4800 }
4801 let mut statement = connection.prepare(&sql)?;
4802 let mut rows = statement.query(params_from_iter(values.iter()))?;
4803 while let Some(row) = rows.next()? {
4804 keys.insert(resolution_key_from_row(row, project)?);
4805 }
4806 if limit.is_some_and(|limit| keys.len() > limit as usize) {
4807 return Ok(keys);
4808 }
4809 }
4810 }
4811 Ok(keys)
4812}
4813
4814fn resolution_key_from_row(
4816 row: &Row<'_>,
4817 expected_project: ProjectInstanceId,
4818) -> DbResult<CanonicalResolutionKey> {
4819 let project = project_from_blob(
4820 "graph_resolution_keys.project_instance_id",
4821 row.get::<_, Vec<u8>>(0)?,
4822 )?;
4823 require_project(expected_project, project)?;
4824 let domain_text = row.get::<_, String>(1)?;
4825 let domain = ResolutionKeyDomain::try_from(domain_text.as_str())?;
4826 let digest = fixed_bytes::<32>(
4827 "graph_resolution_keys.key_digest",
4828 row.get::<_, Vec<u8>>(2)?,
4829 )?;
4830 Ok(CanonicalResolutionKey::from_persisted(
4831 project,
4832 domain,
4833 digest,
4834 row.get(3)?,
4835 )?)
4836}
4837
4838fn validate_persisted_resolution_key(
4840 connection: &Connection,
4841 key: &CanonicalResolutionKey,
4842) -> DbResult<bool> {
4843 let stored = connection
4844 .query_row(
4845 "SELECT canonical_identity
4846 FROM graph_resolution_keys
4847 WHERE project_instance_id = ?1
4848 AND resolution_domain = ?2
4849 AND key_digest = ?3",
4850 params![
4851 &key.project().as_bytes()[..],
4852 key.domain().as_str(),
4853 &key.digest_bytes()[..],
4854 ],
4855 |row| row.get::<_, String>(0),
4856 )
4857 .optional()?;
4858 let Some(stored) = stored else {
4859 return Ok(false);
4860 };
4861 if stored != key.canonical_identity() {
4862 return Err(DbError::ResolutionKeyCollision {
4863 domain: key.domain().as_str(),
4864 digest: key.digest_bytes(),
4865 });
4866 }
4867 CanonicalResolutionKey::from_persisted(
4868 key.project(),
4869 key.domain(),
4870 key.digest_bytes(),
4871 stored,
4872 )?;
4873 Ok(true)
4874}
4875
4876fn validate_persisted_resolution_keys(
4878 connection: &Connection,
4879 keys: &[CanonicalResolutionKey],
4880) -> DbResult<()> {
4881 for chunk in keys.chunks(RESOLUTION_KEYS_PER_QUERY) {
4882 if chunk.is_empty() {
4883 continue;
4884 }
4885 let values_clause = anonymous_values_clause(chunk.len(), 4);
4886 let sql = format!(
4887 "WITH requested(project_instance_id, resolution_domain, key_digest, canonical_identity)
4888 AS (VALUES {values_clause})
4889 SELECT requested.resolution_domain, requested.key_digest,
4890 requested.canonical_identity, stored.canonical_identity
4891 FROM requested
4892 JOIN graph_resolution_keys AS stored
4893 ON stored.project_instance_id = requested.project_instance_id
4894 AND stored.resolution_domain = requested.resolution_domain
4895 AND stored.key_digest = requested.key_digest
4896 ORDER BY requested.resolution_domain, requested.key_digest"
4897 );
4898 let values = resolution_key_values(chunk, true);
4899 let mut statement = connection.prepare(&sql)?;
4900 let mut rows = statement.query(params_from_iter(values.iter()))?;
4901 while let Some(row) = rows.next()? {
4902 let domain_text = row.get::<_, String>(0)?;
4903 let domain = ResolutionKeyDomain::try_from(domain_text.as_str())?;
4904 let digest = fixed_bytes::<32>(
4905 "graph_resolution_keys.key_digest",
4906 row.get::<_, Vec<u8>>(1)?,
4907 )?;
4908 let requested = row.get::<_, String>(2)?;
4909 let stored = row.get::<_, String>(3)?;
4910 if requested != stored {
4911 return Err(DbError::ResolutionKeyCollision {
4912 domain: domain.as_str(),
4913 digest,
4914 });
4915 }
4916 }
4917 }
4918 Ok(())
4919}
4920
4921fn affected_source_paths(
4923 connection: &Connection,
4924 keys: &[CanonicalResolutionKey],
4925 limit: u32,
4926) -> DbResult<BTreeSet<RepositoryFilePath>> {
4927 let mut paths = BTreeSet::new();
4928 for chunk in keys.chunks(RESOLUTION_KEYS_PER_QUERY) {
4929 if chunk.is_empty() {
4930 continue;
4931 }
4932 let values_clause = anonymous_values_clause(chunk.len(), 4);
4933 let sql = format!(
4934 "WITH requested(project_instance_id, resolution_domain, key_digest, canonical_identity)
4935 AS (VALUES {values_clause})
4936 SELECT DISTINCT dependency.owner_path
4937 FROM requested
4938 JOIN graph_resolution_keys AS stored
4939 ON stored.project_instance_id = requested.project_instance_id
4940 AND stored.resolution_domain = requested.resolution_domain
4941 AND stored.key_digest = requested.key_digest
4942 AND stored.canonical_identity = requested.canonical_identity
4943 JOIN graph_relation_dependencies AS dependency
4944 INDEXED BY idx_graph_relation_dependencies_key
4945 ON dependency.project_instance_id = stored.project_instance_id
4946 AND dependency.resolution_domain = stored.resolution_domain
4947 AND dependency.key_digest = stored.key_digest
4948 ORDER BY dependency.owner_path
4949 LIMIT ?"
4950 );
4951 let mut values = resolution_key_values(chunk, true);
4952 values.push(Value::Integer(i64::from(limit) + 1));
4953 let mut statement = connection.prepare(&sql)?;
4954 let mut rows = statement.query(params_from_iter(values.iter()))?;
4955 while let Some(row) = rows.next()? {
4956 paths.insert(RepositoryFilePath::new(Path::new(
4957 &row.get::<_, String>(0)?,
4958 ))?);
4959 }
4960 if paths.len() > limit as usize {
4961 return Ok(paths);
4962 }
4963 }
4964 Ok(paths)
4965}
4966
4967fn affected_source_footprint(
4969 connection: &Connection,
4970 project: ProjectInstanceId,
4971 paths: &[RepositoryNodePath],
4972 limit_plus_one: i64,
4973) -> DbResult<RepositoryAffectedSourceFootprint> {
4974 let maximum_rows = u64::try_from(limit_plus_one).map_err(|source| DbError::InvalidCount {
4975 field: "affected_source_footprint.limit_plus_one",
4976 value: limit_plus_one,
4977 source,
4978 })?;
4979 let mut footprint = empty_affected_source_footprint();
4980 for chunk in paths.chunks(RESOLUTION_PATHS_PER_QUERY) {
4981 if chunk.is_empty() {
4982 continue;
4983 }
4984 let remaining = maximum_rows.saturating_sub(footprint.rows);
4985 if remaining == 0 {
4986 footprint.truncated = true;
4987 break;
4988 }
4989 let sql = affected_source_footprint_sql(chunk.len());
4990 let mut values = Vec::with_capacity(chunk.len() + 2);
4991 values.push(Value::Blob(project.as_bytes().to_vec()));
4992 values.extend(
4993 chunk
4994 .iter()
4995 .map(|path| Value::Text(path.as_str().to_string())),
4996 );
4997 values.push(Value::Integer(i64::try_from(remaining).map_err(
4998 |source| DbError::InvalidCount {
4999 field: "affected_source_footprint.remaining_rows",
5000 value: i64::MAX,
5001 source,
5002 },
5003 )?));
5004 let mut statement = connection.prepare(&sql)?;
5005 let mut rows = statement.query(params_from_iter(values.iter()))?;
5006 while let Some(row) = rows.next()? {
5007 let bytes = nonnegative_u64(
5008 "affected_source_footprint.retained_bytes",
5009 row.get::<_, i64>(0)?,
5010 )?;
5011 footprint.rows = footprint.rows.saturating_add(1);
5012 footprint.retained_bytes = footprint.retained_bytes.saturating_add(bytes);
5013 }
5014 if footprint.rows >= maximum_rows {
5015 footprint.truncated = true;
5016 break;
5017 }
5018 }
5019 Ok(footprint)
5020}
5021
5022fn affected_source_footprint_sql(path_count: usize) -> String {
5024 let requested = vec!["(?)"; path_count].join(",");
5025 format!(
5026 "WITH selected_project(project_instance_id) AS (VALUES (?)),
5027 requested(path) AS (VALUES {requested})
5028 SELECT retained_bytes
5029 FROM (
5030 SELECT length(CAST(metadata.path AS BLOB))
5031 + coalesce(length(CAST(metadata.language AS BLOB)), 0)
5032 + length(CAST(metadata.source_parser AS BLOB))
5033 + length(CAST(metadata.fact_parser AS BLOB)) + 16
5034 + length(CAST(metadata.updated_at AS BLOB)) AS retained_bytes
5035 FROM requested
5036 JOIN source_parse_metadata AS metadata
5037 INDEXED BY sqlite_autoindex_source_parse_metadata_1
5038 ON metadata.path = requested.path
5039 UNION ALL
5040 SELECT 32 + length(CAST(symbol.path AS BLOB))
5041 + coalesce(length(CAST(symbol.language AS BLOB)), 0)
5042 + length(CAST(symbol.name AS BLOB))
5043 + length(CAST(symbol.kind AS BLOB))
5044 + length(CAST(symbol.signature AS BLOB))
5045 + coalesce(length(CAST(symbol.documentation AS BLOB)), 0)
5046 + coalesce(length(CAST(symbol.parent AS BLOB)), 0)
5047 + length(CAST(symbol.parser AS BLOB))
5048 + coalesce(length(CAST(symbol.detail AS BLOB)), 0)
5049 + length(CAST(symbol.created_at AS BLOB))
5050 + length(CAST(symbol.updated_at AS BLOB))
5051 FROM requested
5052 JOIN symbols AS symbol INDEXED BY idx_symbols_path
5053 ON symbol.path = requested.path
5054 UNION ALL
5055 SELECT 16 + length(CAST(symbol_relation.path AS BLOB))
5056 + length(CAST(symbol_relation.source_name AS BLOB))
5057 + length(CAST(symbol_relation.target_name AS BLOB))
5058 + length(CAST(symbol_relation.kind AS BLOB))
5059 + length(CAST(symbol_relation.context AS BLOB))
5060 + length(CAST(symbol_relation.parser AS BLOB))
5061 + length(CAST(symbol_relation.created_at AS BLOB))
5062 FROM requested
5063 JOIN symbol_relations AS symbol_relation INDEXED BY idx_symbol_relations_path
5064 ON symbol_relation.path = requested.path
5065 UNION ALL
5066 SELECT 48 + length(CAST(entity.canonical_identity AS BLOB))
5067 + length(CAST(entity.entity_kind AS BLOB))
5068 + coalesce(length(CAST(entity.repository_path AS BLOB)), 0)
5069 + coalesce(length(CAST(entity.package_manager AS BLOB)), 0)
5070 + coalesce(length(CAST(entity.package_name AS BLOB)), 0)
5071 + coalesce(length(CAST(entity.manifest_path AS BLOB)), 0)
5072 + coalesce(length(CAST(entity.symbol_name AS BLOB)), 0)
5073 + coalesce(length(CAST(entity.symbol_kind AS BLOB)), 0)
5074 + coalesce(length(CAST(entity.symbol_parent AS BLOB)), 0)
5075 + coalesce(length(CAST(entity.symbol_signature AS BLOB)), 0)
5076 + coalesce(length(CAST(entity.external_system AS BLOB)), 0)
5077 + coalesce(length(CAST(entity.external_identity AS BLOB)), 0)
5078 FROM requested
5079 JOIN graph_entities AS entity INDEXED BY idx_graph_entities_path
5080 ON entity.repository_path = requested.path
5081 JOIN selected_project
5082 ON selected_project.project_instance_id = entity.project_instance_id
5083 UNION ALL
5084 SELECT 48 + length(CAST(entity.canonical_identity AS BLOB))
5085 + length(CAST(entity.entity_kind AS BLOB))
5086 + coalesce(length(CAST(entity.repository_path AS BLOB)), 0)
5087 + coalesce(length(CAST(entity.package_manager AS BLOB)), 0)
5088 + coalesce(length(CAST(entity.package_name AS BLOB)), 0)
5089 + coalesce(length(CAST(entity.manifest_path AS BLOB)), 0)
5090 + coalesce(length(CAST(entity.symbol_name AS BLOB)), 0)
5091 + coalesce(length(CAST(entity.symbol_kind AS BLOB)), 0)
5092 + coalesce(length(CAST(entity.symbol_parent AS BLOB)), 0)
5093 + coalesce(length(CAST(entity.symbol_signature AS BLOB)), 0)
5094 + coalesce(length(CAST(entity.external_system AS BLOB)), 0)
5095 + coalesce(length(CAST(entity.external_identity AS BLOB)), 0)
5096 FROM requested
5097 JOIN graph_entities AS entity INDEXED BY idx_graph_entities_manifest_path
5098 ON entity.manifest_path = requested.path
5099 JOIN selected_project
5100 ON selected_project.project_instance_id = entity.project_instance_id
5101 UNION ALL
5102 SELECT 80 + length(CAST(relation.canonical_identity AS BLOB))
5103 + length(CAST(relation.relation_scope AS BLOB))
5104 + length(CAST(relation.relation_kind AS BLOB))
5105 + length(CAST(relation.resolution_status AS BLOB))
5106 + coalesce(length(relation.target_entity_key), 0)
5107 + coalesce(length(CAST(relation.reference_text AS BLOB)), 0)
5108 + CASE WHEN relation.candidate_count IS NULL THEN 0 ELSE 8 END
5109 + length(CAST(relation.confidence AS BLOB))
5110 + length(CAST(relation.completeness AS BLOB))
5111 FROM requested
5112 JOIN graph_entities AS entity INDEXED BY idx_graph_entities_path
5113 ON entity.repository_path = requested.path
5114 JOIN selected_project
5115 ON selected_project.project_instance_id = entity.project_instance_id
5116 JOIN graph_relations AS relation INDEXED BY idx_graph_relations_source_kind
5117 ON relation.source_entity_key = entity.entity_key
5118 AND relation.project_instance_id = selected_project.project_instance_id
5119 UNION ALL
5120 SELECT 80 + length(CAST(relation.canonical_identity AS BLOB))
5121 + length(CAST(relation.relation_scope AS BLOB))
5122 + length(CAST(relation.relation_kind AS BLOB))
5123 + length(CAST(relation.resolution_status AS BLOB))
5124 + coalesce(length(relation.target_entity_key), 0)
5125 + coalesce(length(CAST(relation.reference_text AS BLOB)), 0)
5126 + CASE WHEN relation.candidate_count IS NULL THEN 0 ELSE 8 END
5127 + length(CAST(relation.confidence AS BLOB))
5128 + length(CAST(relation.completeness AS BLOB))
5129 FROM requested
5130 JOIN graph_entities AS entity INDEXED BY idx_graph_entities_manifest_path
5131 ON entity.manifest_path = requested.path
5132 JOIN selected_project
5133 ON selected_project.project_instance_id = entity.project_instance_id
5134 JOIN graph_relations AS relation INDEXED BY idx_graph_relations_source_kind
5135 ON relation.source_entity_key = entity.entity_key
5136 AND relation.project_instance_id = selected_project.project_instance_id
5137 UNION ALL
5138 SELECT 72 + length(CAST(occurrence.file_path AS BLOB))
5139 FROM requested
5140 JOIN graph_relation_occurrences AS occurrence
5141 INDEXED BY idx_graph_occurrences_file_span
5142 ON occurrence.file_path = requested.path
5143 UNION ALL
5144 SELECT 48 + length(CAST(coverage.scope_kind AS BLOB))
5145 + coalesce(length(CAST(coverage.scope_path AS BLOB)), 0)
5146 + coalesce(length(CAST(coverage.relation_scope AS BLOB)), 0)
5147 + coalesce(length(CAST(coverage.relation_kind AS BLOB)), 0)
5148 + length(CAST(coverage.state AS BLOB))
5149 + coalesce(length(CAST(coverage.reason AS BLOB)), 0)
5150 + coalesce(length(CAST(coverage.reached_limit AS BLOB)), 0)
5151 FROM requested
5152 JOIN graph_coverage AS coverage INDEXED BY idx_graph_coverage_path
5153 ON coverage.scope_path = requested.path
5154 JOIN selected_project
5155 ON selected_project.project_instance_id = coverage.project_instance_id
5156 UNION ALL
5157 SELECT 80 + length(CAST(export.owner_path AS BLOB))
5158 + length(CAST(export.resolution_domain AS BLOB))
5159 FROM requested
5160 JOIN graph_entity_exports AS export INDEXED BY idx_graph_entity_exports_owner
5161 ON export.owner_path = requested.path
5162 JOIN selected_project
5163 ON selected_project.project_instance_id = export.project_instance_id
5164 UNION ALL
5165 SELECT length(witness.project_instance_id)
5166 + length(CAST(witness.resolution_domain AS BLOB))
5167 + length(witness.key_digest)
5168 + length(CAST(witness.canonical_identity AS BLOB))
5169 FROM requested
5170 JOIN graph_entity_exports AS export INDEXED BY idx_graph_entity_exports_owner
5171 ON export.owner_path = requested.path
5172 JOIN selected_project
5173 ON selected_project.project_instance_id = export.project_instance_id
5174 LEFT JOIN graph_resolution_keys AS witness
5175 ON witness.project_instance_id = export.project_instance_id
5176 AND witness.resolution_domain = export.resolution_domain
5177 AND witness.key_digest = export.key_digest
5178 UNION ALL
5179 SELECT 80 + length(CAST(dependency.owner_path AS BLOB))
5180 + length(CAST(dependency.resolution_domain AS BLOB))
5181 FROM requested
5182 JOIN graph_relation_dependencies AS dependency
5183 INDEXED BY idx_graph_relation_dependencies_owner
5184 ON dependency.owner_path = requested.path
5185 JOIN selected_project
5186 ON selected_project.project_instance_id = dependency.project_instance_id
5187 UNION ALL
5188 SELECT length(witness.project_instance_id)
5189 + length(CAST(witness.resolution_domain AS BLOB))
5190 + length(witness.key_digest)
5191 + length(CAST(witness.canonical_identity AS BLOB))
5192 FROM requested
5193 JOIN graph_relation_dependencies AS dependency
5194 INDEXED BY idx_graph_relation_dependencies_owner
5195 ON dependency.owner_path = requested.path
5196 JOIN selected_project
5197 ON selected_project.project_instance_id = dependency.project_instance_id
5198 LEFT JOIN graph_resolution_keys AS witness
5199 ON witness.project_instance_id = dependency.project_instance_id
5200 AND witness.resolution_domain = dependency.resolution_domain
5201 AND witness.key_digest = dependency.key_digest
5202 )
5203 LIMIT ?"
5204 )
5205}
5206
5207fn anonymous_values_clause(rows: usize, columns: usize) -> String {
5209 let row = format!("({})", vec!["?"; columns].join(","));
5210 vec![row; rows].join(",")
5211}
5212
5213fn resolution_key_values(keys: &[CanonicalResolutionKey], witness: bool) -> Vec<Value> {
5215 let columns = if witness { 4 } else { 3 };
5216 let mut values = Vec::with_capacity(keys.len() * columns);
5217 for key in keys {
5218 values.push(Value::Blob(key.project().as_bytes().to_vec()));
5219 values.push(Value::Text(key.domain().as_str().to_string()));
5220 values.push(Value::Blob(key.digest_bytes().to_vec()));
5221 if witness {
5222 values.push(Value::Text(key.canonical_identity().to_string()));
5223 }
5224 }
5225 values
5226}
5227
5228fn validate_resolution_key_batch(
5230 project: ProjectInstanceId,
5231 entity_exports: &[EntityResolutionKey],
5232 relation_dependencies: &[RelationDependencyKey],
5233) -> DbResult<()> {
5234 let foreign = entity_exports.iter().find_map(|binding| {
5235 (binding.entity().project() != project || binding.key().project() != project)
5236 .then(|| binding.key().project())
5237 });
5238 let foreign = foreign.or_else(|| {
5239 relation_dependencies.iter().find_map(|binding| {
5240 (binding.relation().project() != project || binding.key().project() != project)
5241 .then(|| binding.key().project())
5242 })
5243 });
5244 if let Some(found) = foreign {
5245 return Err(DbError::GraphProjectIdentityMismatch {
5246 expected: project.to_string(),
5247 found: found.to_string(),
5248 });
5249 }
5250 Ok(())
5251}
5252
5253fn insert_resolution_key_batch(
5255 connection: &Connection,
5256 project: ProjectInstanceId,
5257 entity_exports: &[EntityResolutionKey],
5258 relation_dependencies: &[RelationDependencyKey],
5259) -> DbResult<()> {
5260 let mut insert_key = connection.prepare_cached(
5261 "INSERT INTO graph_resolution_keys(
5262 project_instance_id, resolution_domain, key_digest, canonical_identity
5263 ) VALUES(?1, ?2, ?3, ?4)
5264 ON CONFLICT(project_instance_id, resolution_domain, key_digest)
5265 DO UPDATE SET canonical_identity = excluded.canonical_identity
5266 WHERE graph_resolution_keys.canonical_identity = excluded.canonical_identity",
5267 )?;
5268 let mut insert_export = connection.prepare_cached(
5269 "INSERT INTO graph_entity_exports(
5270 project_instance_id, entity_key, owner_path, resolution_domain, key_digest
5271 )
5272 SELECT entity.project_instance_id, entity.entity_key,
5273 CASE entity.entity_kind
5274 WHEN 'file' THEN entity.repository_path
5275 WHEN 'symbol' THEN entity.repository_path
5276 WHEN 'package' THEN entity.manifest_path
5277 END,
5278 ?3, ?4
5279 FROM graph_entities AS entity
5280 WHERE entity.project_instance_id = ?1 AND entity.entity_key = ?2
5281 AND entity.entity_kind IN ('file', 'symbol', 'package')
5282 ON CONFLICT(project_instance_id, entity_key, resolution_domain, key_digest)
5283 DO UPDATE SET owner_path = excluded.owner_path",
5284 )?;
5285 let mut insert_dependency = connection.prepare_cached(
5286 "INSERT INTO graph_relation_dependencies(
5287 project_instance_id, relation_key, owner_path, resolution_domain, key_digest
5288 )
5289 SELECT relation.project_instance_id, relation.relation_key,
5290 CASE source.entity_kind
5291 WHEN 'file' THEN source.repository_path
5292 WHEN 'symbol' THEN source.repository_path
5293 WHEN 'package' THEN source.manifest_path
5294 END,
5295 ?3, ?4
5296 FROM graph_relations AS relation
5297 JOIN graph_entities AS source
5298 ON source.project_instance_id = relation.project_instance_id
5299 AND source.entity_key = relation.source_entity_key
5300 WHERE relation.project_instance_id = ?1 AND relation.relation_key = ?2
5301 AND source.entity_kind IN ('file', 'symbol', 'package')
5302 ON CONFLICT(project_instance_id, relation_key, resolution_domain, key_digest)
5303 DO UPDATE SET owner_path = excluded.owner_path",
5304 )?;
5305
5306 for binding in entity_exports {
5307 insert_resolution_key(&mut insert_key, binding.key())?;
5308 let key = binding.key();
5309 let inserted = insert_export.execute(params![
5310 &project.as_bytes()[..],
5311 &binding.entity().digest_bytes()?[..],
5312 key.domain().as_str(),
5313 &key.digest_bytes()[..],
5314 ])?;
5315 if inserted != 1 {
5316 return Err(DbError::GraphRowShape {
5317 table: "graph_entity_exports",
5318 reason: "export keys require a local file, symbol, or package owner",
5319 });
5320 }
5321 }
5322 for binding in relation_dependencies {
5323 insert_resolution_key(&mut insert_key, binding.key())?;
5324 let key = binding.key();
5325 let inserted = insert_dependency.execute(params![
5326 &project.as_bytes()[..],
5327 &binding.relation().digest_bytes()?[..],
5328 key.domain().as_str(),
5329 &key.digest_bytes()[..],
5330 ])?;
5331 if inserted != 1 {
5332 return Err(DbError::GraphRowShape {
5333 table: "graph_relation_dependencies",
5334 reason: "dependency keys require a local file, symbol, or package source",
5335 });
5336 }
5337 }
5338 Ok(())
5339}
5340
5341fn insert_resolution_key(
5343 statement: &mut rusqlite::CachedStatement<'_>,
5344 key: &CanonicalResolutionKey,
5345) -> DbResult<()> {
5346 let inserted = statement.execute(params![
5347 &key.project().as_bytes()[..],
5348 key.domain().as_str(),
5349 &key.digest_bytes()[..],
5350 key.canonical_identity(),
5351 ])?;
5352 if inserted == 0 {
5353 return Err(DbError::ResolutionKeyCollision {
5354 domain: key.domain().as_str(),
5355 digest: key.digest_bytes(),
5356 });
5357 }
5358 Ok(())
5359}
5360
5361fn remove_touched_orphan_resolution_keys(
5363 connection: &Connection,
5364 keys: &BTreeSet<CanonicalResolutionKey>,
5365) -> DbResult<()> {
5366 let keys = keys.iter().cloned().collect::<Vec<_>>();
5367 for chunk in keys.chunks(RESOLUTION_KEYS_PER_QUERY) {
5368 if chunk.is_empty() {
5369 continue;
5370 }
5371 let values_clause = anonymous_values_clause(chunk.len(), 3);
5372 let sql = format!(
5373 "WITH touched(project_instance_id, resolution_domain, key_digest)
5374 AS (VALUES {values_clause})
5375 DELETE FROM graph_resolution_keys
5376 WHERE rowid IN (
5377 SELECT stored.rowid
5378 FROM graph_resolution_keys AS stored
5379 JOIN touched
5380 ON touched.project_instance_id = stored.project_instance_id
5381 AND touched.resolution_domain = stored.resolution_domain
5382 AND touched.key_digest = stored.key_digest
5383 WHERE NOT EXISTS (
5384 SELECT 1 FROM graph_entity_exports AS export
5385 WHERE export.project_instance_id = stored.project_instance_id
5386 AND export.resolution_domain = stored.resolution_domain
5387 AND export.key_digest = stored.key_digest
5388 )
5389 AND NOT EXISTS (
5390 SELECT 1 FROM graph_relation_dependencies AS dependency
5391 WHERE dependency.project_instance_id = stored.project_instance_id
5392 AND dependency.resolution_domain = stored.resolution_domain
5393 AND dependency.key_digest = stored.key_digest
5394 )
5395 )"
5396 );
5397 let values = resolution_key_values(chunk, false);
5398 connection.execute(&sql, params_from_iter(values.iter()))?;
5399 }
5400 Ok(())
5401}
5402
5403fn affected_external_candidates(
5405 connection: &Connection,
5406 affected_paths: &[RepositoryNodePath],
5407) -> DbResult<HashSet<[u8; 32]>> {
5408 let mut by_repository_path = connection.prepare_cached(
5409 "SELECT entity_key FROM graph_entities
5410 WHERE repository_path = ?1
5411 OR (repository_path >= ?2 AND repository_path < ?3)",
5412 )?;
5413 let mut by_manifest_path = connection.prepare_cached(
5414 "SELECT entity_key FROM graph_entities
5415 WHERE manifest_path = ?1 OR (manifest_path >= ?2 AND manifest_path < ?3)",
5416 )?;
5417 let mut local_keys = HashSet::new();
5418 for path in affected_paths {
5419 let path = path.as_str();
5420 let (descendant_start, descendant_end) = repository_descendant_bounds(path);
5421 for statement in [&mut by_repository_path, &mut by_manifest_path] {
5422 let mut rows = statement.query(params![path, descendant_start, descendant_end])?;
5423 while let Some(row) = rows.next()? {
5424 local_keys.insert(fixed_bytes::<32>(
5425 "graph_entities.entity_key",
5426 row.get::<_, Vec<u8>>(0)?,
5427 )?);
5428 }
5429 }
5430 }
5431
5432 let mut candidates = HashSet::new();
5433 let local_keys = local_keys.into_iter().collect::<Vec<_>>();
5434 for chunk in local_keys.chunks(EXTERNAL_CANDIDATE_KEYS_PER_QUERY) {
5435 let sql = external_candidate_batch_sql(chunk.len());
5436 let mut statement = connection.prepare_cached(&sql)?;
5437 let mut rows = statement.query(params_from_iter(chunk.iter().map(|key| &key[..])))?;
5438 while let Some(row) = rows.next()? {
5439 candidates.insert(fixed_bytes::<32>(
5440 "graph_entities.entity_key",
5441 row.get::<_, Vec<u8>>(0)?,
5442 )?);
5443 }
5444 }
5445 Ok(candidates)
5446}
5447
5448fn external_candidate_batch_sql(key_count: usize) -> String {
5450 let values = anonymous_values_clause(key_count, 1);
5451 format!(
5452 "WITH affected(entity_key) AS (VALUES {values})
5453 SELECT relation.target_entity_key
5454 FROM affected
5455 CROSS JOIN graph_relations AS relation
5456 INDEXED BY idx_graph_relations_source_kind
5457 JOIN graph_entities AS external
5458 ON external.entity_key = relation.target_entity_key
5459 WHERE relation.source_entity_key = affected.entity_key
5460 AND external.entity_kind = 'external'
5461 UNION ALL
5462 SELECT relation.source_entity_key
5463 FROM affected
5464 CROSS JOIN graph_relations AS relation
5465 INDEXED BY idx_graph_relations_target_kind
5466 JOIN graph_entities AS external
5467 ON external.entity_key = relation.source_entity_key
5468 WHERE relation.target_entity_key = affected.entity_key
5469 AND external.entity_kind = 'external'"
5470 )
5471}
5472
5473fn invalidate_repository_graph_paths(
5475 connection: &Connection,
5476 affected_paths: &[RepositoryNodePath],
5477 orphan_candidates: &mut HashSet<[u8; 32]>,
5478) -> DbResult<()> {
5479 let mut affected_relations = HashSet::new();
5480 let mut relation_occurrences = connection.prepare_cached(
5481 "SELECT relation_key FROM graph_relation_occurrences
5482 WHERE file_path = ?1 OR (file_path >= ?2 AND file_path < ?3)",
5483 )?;
5484 let mut occurrences = connection.prepare_cached(
5485 "DELETE FROM graph_relation_occurrences
5486 WHERE file_path = ?1 OR (file_path >= ?2 AND file_path < ?3)",
5487 )?;
5488 let mut coverage = connection.prepare_cached(
5489 "DELETE FROM graph_coverage
5490 INDEXED BY idx_graph_coverage_path
5491 WHERE scope_kind = 'path'
5492 AND (scope_path = ?1 OR (scope_path >= ?2 AND scope_path < ?3))",
5493 )?;
5494 let mut entities_by_path = connection.prepare_cached(
5495 "DELETE FROM graph_entities
5496 WHERE repository_path = ?1
5497 OR (repository_path >= ?2 AND repository_path < ?3)",
5498 )?;
5499 let mut entities_by_manifest = connection.prepare_cached(
5500 "DELETE FROM graph_entities
5501 WHERE manifest_path = ?1 OR (manifest_path >= ?2 AND manifest_path < ?3)",
5502 )?;
5503 for path in affected_paths {
5504 let path = path.as_str();
5505 let (descendant_start, descendant_end) = repository_descendant_bounds(path);
5506 let mut rows =
5507 relation_occurrences.query(params![path, descendant_start, descendant_end])?;
5508 while let Some(row) = rows.next()? {
5509 affected_relations.insert(fixed_bytes::<32>(
5510 "graph_relation_occurrences.relation_key",
5511 row.get::<_, Vec<u8>>(0)?,
5512 )?);
5513 }
5514 occurrences.execute(params![path, descendant_start, descendant_end])?;
5515 coverage.execute(params![path, descendant_start, descendant_end])?;
5516 entities_by_path.execute(params![path, descendant_start, descendant_end])?;
5517 entities_by_manifest.execute(params![path, descendant_start, descendant_end])?;
5518 }
5519 collect_external_relation_endpoints(connection, &affected_relations, orphan_candidates)?;
5520 let mut relation = connection.prepare_cached(
5521 "DELETE FROM graph_relations
5522 WHERE relation_key = ?1
5523 AND NOT EXISTS (
5524 SELECT 1 FROM graph_relation_occurrences
5525 WHERE relation_key = ?1
5526 )",
5527 )?;
5528 for relation_key in affected_relations {
5529 relation.execute([&relation_key[..]])?;
5530 }
5531 Ok(())
5532}
5533
5534fn collect_external_relation_endpoints(
5536 connection: &Connection,
5537 relation_keys: &HashSet<[u8; 32]>,
5538 candidates: &mut HashSet<[u8; 32]>,
5539) -> DbResult<()> {
5540 let mut endpoints = connection.prepare_cached(
5541 "SELECT source_entity_key, target_entity_key
5542 FROM graph_relations
5543 WHERE relation_key = ?1",
5544 )?;
5545 let mut is_external = connection.prepare_cached(
5546 "SELECT EXISTS(
5547 SELECT 1 FROM graph_entities
5548 WHERE entity_key = ?1 AND entity_kind = 'external'
5549 )",
5550 )?;
5551 for relation_key in relation_keys {
5552 let endpoints = endpoints
5553 .query_row([&relation_key[..]], |row| {
5554 Ok((row.get::<_, Vec<u8>>(0)?, row.get::<_, Option<Vec<u8>>>(1)?))
5555 })
5556 .optional()?;
5557 let Some((source, target)) = endpoints else {
5558 continue;
5559 };
5560 for endpoint in [Some(source), target].into_iter().flatten() {
5561 let endpoint = fixed_bytes::<32>("graph_relations endpoint", endpoint)?;
5562 if is_external.query_row([&endpoint[..]], |row| row.get::<_, bool>(0))? {
5563 candidates.insert(endpoint);
5564 }
5565 }
5566 }
5567 Ok(())
5568}
5569
5570fn remove_orphan_external_candidates(
5572 connection: &Connection,
5573 candidates: &HashSet<[u8; 32]>,
5574) -> DbResult<()> {
5575 let mut statement = connection.prepare_cached(
5576 "DELETE FROM graph_entities
5577 WHERE entity_key = ?1 AND entity_kind = 'external'
5578 AND NOT EXISTS (
5579 SELECT 1 FROM graph_relations INDEXED BY idx_graph_relations_source_kind
5580 WHERE source_entity_key = ?1
5581 )
5582 AND NOT EXISTS (
5583 SELECT 1 FROM graph_relations INDEXED BY idx_graph_relations_target_kind
5584 WHERE target_entity_key = ?1
5585 )",
5586 )?;
5587 for candidate in candidates {
5588 statement.execute([&candidate[..]])?;
5589 }
5590 Ok(())
5591}
5592
5593fn repository_descendant_bounds(path: &str) -> (String, String) {
5595 (format!("{path}/"), format!("{path}0"))
5596}
5597
5598fn validate_graph_batch(
5600 project: ProjectInstanceId,
5601 generation: IndexGeneration,
5602 entities: &[GraphEntity],
5603 relations: &[LogicalRelation],
5604 occurrences: &[RelationOccurrence],
5605 coverage: &[CoverageRecord],
5606) -> DbResult<()> {
5607 if entities
5608 .iter()
5609 .any(|entity| entity.key().project() != project)
5610 || relations
5611 .iter()
5612 .any(|relation| relation.key().project() != project)
5613 || occurrences
5614 .iter()
5615 .any(|occurrence| occurrence.relation().project() != project)
5616 {
5617 return Err(DbError::GraphProjectIdentityMismatch {
5618 expected: project.to_string(),
5619 found: "record from another project".to_string(),
5620 });
5621 }
5622 if entities
5623 .iter()
5624 .any(|entity| entity.generation() != generation)
5625 || relations
5626 .iter()
5627 .any(|relation| relation.generation() != generation)
5628 || occurrences
5629 .iter()
5630 .any(|occurrence| occurrence.generation() != generation)
5631 || coverage
5632 .iter()
5633 .any(|record| record.generation() != generation)
5634 {
5635 return Err(
5636 projectatlas_core::graph::GraphContractError::GenerationMismatch {
5637 context: "repository graph publication batch",
5638 }
5639 .into(),
5640 );
5641 }
5642 Ok(())
5643}
5644
5645fn insert_graph_batch(
5647 connection: &Connection,
5648 project: ProjectInstanceId,
5649 entities: &[GraphEntity],
5650 relations: &[LogicalRelation],
5651 occurrences: &[RelationOccurrence],
5652 coverage: &[CoverageRecord],
5653) -> DbResult<()> {
5654 insert_entities(connection, project, entities)?;
5655 insert_relations(connection, project, relations)?;
5656 insert_occurrences(connection, occurrences)?;
5657 insert_coverage(connection, project, coverage)
5658}
5659
5660fn insert_entities<'entity>(
5662 connection: &Connection,
5663 project: ProjectInstanceId,
5664 entities: impl IntoIterator<Item = &'entity GraphEntity>,
5665) -> DbResult<()> {
5666 let mut insert = connection.prepare_cached(
5667 "INSERT INTO graph_entities(
5668 entity_key, project_instance_id, canonical_identity, entity_kind,
5669 repository_path, package_manager, package_name, manifest_path,
5670 symbol_name, symbol_kind, symbol_parent, symbol_signature,
5671 external_system, external_identity
5672 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)
5673 ON CONFLICT(entity_key) DO NOTHING",
5674 )?;
5675 let mut existing = connection.prepare_cached(
5676 "SELECT project_instance_id, canonical_identity
5677 FROM graph_entities WHERE entity_key = ?1",
5678 )?;
5679 for entity in entities {
5680 let columns = entity_columns(entity.selector());
5681 let key = entity.key().digest_bytes()?;
5682 insert.execute(params![
5683 &key[..],
5684 &project.as_bytes()[..],
5685 entity.key().canonical_identity(),
5686 columns.kind,
5687 columns.repository_path,
5688 columns.package_manager,
5689 columns.package_name,
5690 columns.manifest_path,
5691 columns.symbol_name,
5692 columns.symbol_kind,
5693 columns.symbol_parent,
5694 columns.symbol_signature,
5695 columns.external_system,
5696 columns.external_identity,
5697 ])?;
5698 let (stored_project, stored_canonical): (Vec<u8>, String) =
5699 existing.query_row([&key[..]], |row| Ok((row.get(0)?, row.get(1)?)))?;
5700 if fixed_bytes::<16>("graph_entities.project_instance_id", stored_project)?
5701 != project.as_bytes()
5702 || stored_canonical != entity.key().canonical_identity()
5703 {
5704 return Err(
5705 projectatlas_core::graph::GraphContractError::StableKeyCollision {
5706 digest: entity.key().digest().to_string(),
5707 }
5708 .into(),
5709 );
5710 }
5711 }
5712 Ok(())
5713}
5714
5715fn insert_relations(
5717 connection: &Connection,
5718 project: ProjectInstanceId,
5719 relations: &[LogicalRelation],
5720) -> DbResult<()> {
5721 let mut statement = connection.prepare_cached(
5722 "INSERT INTO graph_relations(
5723 relation_key, project_instance_id, canonical_identity, source_entity_key,
5724 relation_scope, relation_kind, resolution_status, target_entity_key,
5725 reference_text, candidate_count, confidence, completeness
5726 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
5727 ON CONFLICT(relation_key) DO UPDATE SET
5728 confidence = excluded.confidence,
5729 completeness = excluded.completeness
5730 WHERE graph_relations.project_instance_id = excluded.project_instance_id
5731 AND graph_relations.canonical_identity = excluded.canonical_identity
5732 AND graph_relations.source_entity_key = excluded.source_entity_key
5733 AND graph_relations.relation_scope = excluded.relation_scope
5734 AND graph_relations.relation_kind = excluded.relation_kind
5735 AND graph_relations.resolution_status = excluded.resolution_status
5736 AND graph_relations.target_entity_key IS excluded.target_entity_key
5737 AND graph_relations.reference_text IS excluded.reference_text
5738 AND graph_relations.candidate_count IS excluded.candidate_count",
5739 )?;
5740 for relation in relations {
5741 let (scope, kind) = relation_parts(relation.kind());
5742 let resolution = resolution_columns(relation.resolution())?;
5743 let key = relation.key().digest_bytes()?;
5744 let source = relation.source().digest_bytes()?;
5745 let changed = statement.execute(params![
5746 &key[..],
5747 &project.as_bytes()[..],
5748 relation.key().canonical_identity(),
5749 &source[..],
5750 scope,
5751 kind,
5752 resolution.status,
5753 resolution.target.as_ref().map(|target| &target[..]),
5754 resolution.reference,
5755 resolution.candidate_count,
5756 confidence_name(relation.confidence()),
5757 completeness_name(relation.completeness()),
5758 ])?;
5759 if changed == 0 {
5760 return Err(
5761 projectatlas_core::graph::GraphContractError::StableKeyCollision {
5762 digest: relation.key().digest().to_string(),
5763 }
5764 .into(),
5765 );
5766 }
5767 }
5768 Ok(())
5769}
5770
5771fn insert_occurrences(connection: &Connection, occurrences: &[RelationOccurrence]) -> DbResult<()> {
5773 let mut statement = connection.prepare_cached(
5774 "INSERT INTO graph_relation_occurrences(
5775 relation_key, file_path, start_line, start_column, end_line, end_column
5776 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)
5777 ON CONFLICT(relation_key, file_path, start_line, start_column, end_line, end_column)
5778 DO NOTHING",
5779 )?;
5780 for occurrence in occurrences {
5781 let key = occurrence.relation().digest_bytes()?;
5782 let span = occurrence.span();
5783 statement.execute(params![
5784 &key[..],
5785 occurrence.file().as_str(),
5786 i64::from(span.start_line()),
5787 i64::from(span.start_column()),
5788 i64::from(span.end_line()),
5789 i64::from(span.end_column()),
5790 ])?;
5791 }
5792 Ok(())
5793}
5794
5795fn insert_coverage(
5797 connection: &Connection,
5798 project: ProjectInstanceId,
5799 coverage: &[CoverageRecord],
5800) -> DbResult<()> {
5801 let mut remove = connection.prepare_cached(
5802 "DELETE FROM graph_coverage
5803 WHERE project_instance_id = ?1 AND scope_kind = ?2 AND scope_path IS ?3
5804 AND relation_scope IS ?4 AND relation_kind IS ?5",
5805 )?;
5806 let mut insert = connection.prepare_cached(
5807 "INSERT INTO graph_coverage(
5808 project_instance_id, scope_kind, scope_path, relation_scope, relation_kind,
5809 state, total, covered, omitted, reason, reached_limit
5810 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
5811 )?;
5812 for record in coverage {
5813 let (scope_kind, scope_path) = coverage_scope_parts(record.scope());
5814 let (relation_scope, relation_kind) = record
5815 .relation()
5816 .map(relation_parts)
5817 .map_or((None, None), |(scope, kind)| (Some(scope), Some(kind)));
5818 let values = params![
5819 &project.as_bytes()[..],
5820 scope_kind,
5821 scope_path,
5822 relation_scope,
5823 relation_kind,
5824 ];
5825 remove.execute(values)?;
5826 insert.execute(params![
5827 &project.as_bytes()[..],
5828 scope_kind,
5829 scope_path,
5830 relation_scope,
5831 relation_kind,
5832 coverage_state_name(record.state()),
5833 sqlite_count("graph_coverage.total", record.total())?,
5834 sqlite_count("graph_coverage.covered", record.covered())?,
5835 sqlite_count("graph_coverage.omitted", record.omitted())?,
5836 record.reason().map(GraphIdentityText::as_str),
5837 record.reached_limit().map(GraphLimitKind::as_str),
5838 ])?;
5839 }
5840 Ok(())
5841}
5842
5843fn prepare_graph_identity_rejections(
5850 connection: &Connection,
5851 project: ProjectInstanceId,
5852 generation: IndexGeneration,
5853 affected_paths: Option<&[RepositoryNodePath]>,
5854) -> DbResult<()> {
5855 let generation = sqlite_count("graph_identity_rejections.generation", generation.get())?;
5856 let project_bytes = project.as_bytes();
5857 match affected_paths {
5858 None => {
5859 connection.execute(
5860 "DELETE FROM graph_identity_rejections WHERE project_instance_id = ?1",
5861 [project_bytes.as_slice()],
5862 )?;
5863 }
5864 Some(paths) => {
5865 connection.execute(
5866 "UPDATE graph_identity_rejections
5867 SET generation = ?2
5868 WHERE project_instance_id = ?1",
5869 params![project_bytes.as_slice(), generation],
5870 )?;
5871 let mut remove = connection.prepare_cached(
5872 "DELETE FROM graph_identity_rejections
5873 WHERE project_instance_id = ?1
5874 AND generation = ?2
5875 AND (file_path = ?3 OR (file_path >= ?4 AND file_path < ?5))",
5876 )?;
5877 for path in paths {
5878 let (descendant_start, descendant_end) =
5879 repository_descendant_bounds(path.as_str());
5880 remove.execute(params![
5881 project_bytes.as_slice(),
5882 generation,
5883 path.as_str(),
5884 descendant_start,
5885 descendant_end,
5886 ])?;
5887 }
5888 }
5889 }
5890 Ok(())
5891}
5892
5893fn insert_graph_identity_rejections(
5895 connection: &Connection,
5896 project: ProjectInstanceId,
5897 generation: IndexGeneration,
5898 rejections: &[GraphIdentityRejection],
5899) -> DbResult<()> {
5900 let mut insert = connection.prepare_cached(
5901 "INSERT INTO graph_identity_rejections(
5902 project_instance_id, generation, file_path,
5903 start_line, start_column, end_line, end_column,
5904 parser, field, reason, fact_index
5905 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
5906 ON CONFLICT(
5907 project_instance_id, generation, file_path, start_line, start_column,
5908 end_line, end_column, parser, field, reason, fact_index
5909 ) DO NOTHING",
5910 )?;
5911 let generation = sqlite_count("graph_identity_rejections.generation", generation.get())?;
5912 for rejection in rejections {
5913 let span = rejection.span;
5914 insert.execute(params![
5915 &project.as_bytes()[..],
5916 generation,
5917 rejection.path.as_str(),
5918 i64::from(span.start_line()),
5919 i64::from(span.start_column()),
5920 i64::from(span.end_line()),
5921 i64::from(span.end_column()),
5922 rejection.parser.to_string(),
5923 rejection.field.to_string(),
5924 rejection.reason.to_string(),
5925 i64::try_from(rejection.fact_index).map_err(|_error| {
5926 GraphContractError::InvalidLimits {
5927 reason: "graph identity rejection fact index exceeded SQLite integer range",
5928 }
5929 })?,
5930 ])?;
5931 }
5932 Ok(())
5933}
5934
5935struct EntityColumns<'selector> {
5937 kind: &'static str,
5939 repository_path: Option<&'selector str>,
5941 package_manager: Option<&'selector str>,
5943 package_name: Option<&'selector str>,
5945 manifest_path: Option<&'selector str>,
5947 symbol_name: Option<&'selector str>,
5949 symbol_kind: Option<&'static str>,
5951 symbol_parent: Option<&'selector str>,
5953 symbol_signature: Option<&'selector str>,
5955 external_system: Option<&'selector str>,
5957 external_identity: Option<&'selector str>,
5959}
5960
5961fn entity_columns(selector: &EntitySelector) -> EntityColumns<'_> {
5963 match selector {
5964 EntitySelector::Project => EntityColumns {
5965 kind: "project",
5966 repository_path: None,
5967 package_manager: None,
5968 package_name: None,
5969 manifest_path: None,
5970 symbol_name: None,
5971 symbol_kind: None,
5972 symbol_parent: None,
5973 symbol_signature: None,
5974 external_system: None,
5975 external_identity: None,
5976 },
5977 EntitySelector::Folder { path } => EntityColumns {
5978 kind: "folder",
5979 repository_path: Some(path.as_str()),
5980 package_manager: None,
5981 package_name: None,
5982 manifest_path: None,
5983 symbol_name: None,
5984 symbol_kind: None,
5985 symbol_parent: None,
5986 symbol_signature: None,
5987 external_system: None,
5988 external_identity: None,
5989 },
5990 EntitySelector::File { path } => EntityColumns {
5991 kind: "file",
5992 repository_path: Some(path.as_str()),
5993 package_manager: None,
5994 package_name: None,
5995 manifest_path: None,
5996 symbol_name: None,
5997 symbol_kind: None,
5998 symbol_parent: None,
5999 symbol_signature: None,
6000 external_system: None,
6001 external_identity: None,
6002 },
6003 EntitySelector::Package { package } => EntityColumns {
6004 kind: "package",
6005 repository_path: None,
6006 package_manager: Some(package.manager.as_str()),
6007 package_name: Some(package.name.as_str()),
6008 manifest_path: Some(package.manifest.as_str()),
6009 symbol_name: None,
6010 symbol_kind: None,
6011 symbol_parent: None,
6012 symbol_signature: None,
6013 external_system: None,
6014 external_identity: None,
6015 },
6016 EntitySelector::Symbol { symbol } => EntityColumns {
6017 kind: "symbol",
6018 repository_path: Some(symbol.file.as_str()),
6019 package_manager: None,
6020 package_name: None,
6021 manifest_path: None,
6022 symbol_name: Some(symbol.name.as_str()),
6023 symbol_kind: Some(symbol_kind_name(symbol.kind)),
6024 symbol_parent: symbol.parent.as_ref().map(GraphIdentityText::as_str),
6025 symbol_signature: Some(symbol.signature.as_str()),
6026 external_system: None,
6027 external_identity: None,
6028 },
6029 EntitySelector::External { external } => EntityColumns {
6030 kind: "external",
6031 repository_path: None,
6032 package_manager: None,
6033 package_name: None,
6034 manifest_path: None,
6035 symbol_name: None,
6036 symbol_kind: None,
6037 symbol_parent: None,
6038 symbol_signature: None,
6039 external_system: Some(external.system.as_str()),
6040 external_identity: Some(external.identity.as_str()),
6041 },
6042 }
6043}
6044
6045struct ResolutionColumns<'resolution> {
6047 status: &'static str,
6049 target: Option<[u8; 32]>,
6051 reference: Option<&'resolution str>,
6053 candidate_count: Option<i64>,
6055}
6056
6057fn resolution_columns(resolution: &RelationResolution) -> DbResult<ResolutionColumns<'_>> {
6059 match resolution {
6060 RelationResolution::Resolved { target, .. } => Ok(ResolutionColumns {
6061 status: "resolved",
6062 target: Some(target.digest_bytes()?),
6063 reference: None,
6064 candidate_count: None,
6065 }),
6066 RelationResolution::Ambiguous {
6067 reference,
6068 candidates,
6069 } => Ok(ResolutionColumns {
6070 status: "ambiguous",
6071 target: None,
6072 reference: Some(reference.as_str()),
6073 candidate_count: Some(i64::from(candidates.get())),
6074 }),
6075 RelationResolution::Unresolved { reference } => Ok(ResolutionColumns {
6076 status: "unresolved",
6077 target: None,
6078 reference: Some(reference.as_str()),
6079 candidate_count: None,
6080 }),
6081 RelationResolution::External { target, .. } => Ok(ResolutionColumns {
6082 status: "external",
6083 target: Some(target.digest_bytes()?),
6084 reference: None,
6085 candidate_count: None,
6086 }),
6087 }
6088}
6089
6090fn entity_from_row(
6092 row: EntityRow,
6093 expected_project: ProjectInstanceId,
6094 generation: IndexGeneration,
6095) -> DbResult<GraphEntity> {
6096 let project = project_from_blob("graph_entities.project_instance_id", row.project.clone())?;
6097 require_project(expected_project, project)?;
6098 validate_entity_row_shape(&row)?;
6099 let selector = match row.kind.as_str() {
6100 "project" => EntitySelector::Project,
6101 "folder" => EntitySelector::Folder {
6102 path: RepositoryNodePath::new(Path::new(required_text(
6103 "graph_entities",
6104 "folder path is missing",
6105 row.repository_path.as_deref(),
6106 )?))?,
6107 },
6108 "file" => EntitySelector::File {
6109 path: RepositoryFilePath::new(Path::new(required_text(
6110 "graph_entities",
6111 "file path is missing",
6112 row.repository_path.as_deref(),
6113 )?))?,
6114 },
6115 "package" => EntitySelector::Package {
6116 package: PackageSelector {
6117 manager: GraphIdentityText::new(required_text(
6118 "graph_entities",
6119 "package manager is missing",
6120 row.package_manager.as_deref(),
6121 )?)?,
6122 name: GraphIdentityText::new(required_text(
6123 "graph_entities",
6124 "package name is missing",
6125 row.package_name.as_deref(),
6126 )?)?,
6127 manifest: RepositoryFilePath::new(Path::new(required_text(
6128 "graph_entities",
6129 "package manifest is missing",
6130 row.manifest_path.as_deref(),
6131 )?))?,
6132 },
6133 },
6134 "symbol" => EntitySelector::Symbol {
6135 symbol: SymbolSelector {
6136 file: RepositoryFilePath::new(Path::new(required_text(
6137 "graph_entities",
6138 "symbol file is missing",
6139 row.repository_path.as_deref(),
6140 )?))?,
6141 name: GraphIdentityText::new(required_text(
6142 "graph_entities",
6143 "symbol name is missing",
6144 row.symbol_name.as_deref(),
6145 )?)?,
6146 kind: parse_symbol_kind(required_text(
6147 "graph_entities",
6148 "symbol kind is missing",
6149 row.symbol_kind.as_deref(),
6150 )?)?,
6151 parent: row.symbol_parent.map(GraphIdentityText::new).transpose()?,
6152 signature: GraphIdentityText::new(required_text(
6153 "graph_entities",
6154 "symbol signature is missing",
6155 row.symbol_signature.as_deref(),
6156 )?)?,
6157 },
6158 },
6159 "external" => EntitySelector::External {
6160 external: ExternalSelector {
6161 system: GraphIdentityText::new(required_text(
6162 "graph_entities",
6163 "external system is missing",
6164 row.external_system.as_deref(),
6165 )?)?,
6166 identity: GraphIdentityText::new(required_text(
6167 "graph_entities",
6168 "external identity is missing",
6169 row.external_identity.as_deref(),
6170 )?)?,
6171 },
6172 },
6173 value => {
6174 return Err(DbError::InvalidEnum {
6175 field: "graph_entities.entity_kind",
6176 value: value.to_string(),
6177 });
6178 }
6179 };
6180 let entity = GraphEntity::new(project, selector, generation)?;
6181 validate_entity_key(&entity, row.key, &row.canonical)?;
6182 Ok(entity)
6183}
6184
6185fn validate_entity_row_shape(row: &EntityRow) -> DbResult<()> {
6187 let repository = row.repository_path.is_some();
6188 let package = (
6189 row.package_manager.is_some(),
6190 row.package_name.is_some(),
6191 row.manifest_path.is_some(),
6192 );
6193 let symbol = (
6194 row.symbol_name.is_some(),
6195 row.symbol_kind.is_some(),
6196 row.symbol_parent.is_some(),
6197 row.symbol_signature.is_some(),
6198 );
6199 let external = (
6200 row.external_system.is_some(),
6201 row.external_identity.is_some(),
6202 );
6203 let valid = match row.kind.as_str() {
6204 "project" => {
6205 !repository
6206 && package == (false, false, false)
6207 && symbol == (false, false, false, false)
6208 && external == (false, false)
6209 }
6210 "folder" | "file" => {
6211 repository
6212 && package == (false, false, false)
6213 && symbol == (false, false, false, false)
6214 && external == (false, false)
6215 }
6216 "package" => {
6217 !repository
6218 && package == (true, true, true)
6219 && symbol == (false, false, false, false)
6220 && external == (false, false)
6221 }
6222 "symbol" => {
6223 repository
6224 && package == (false, false, false)
6225 && symbol.0
6226 && symbol.1
6227 && symbol.3
6228 && external == (false, false)
6229 }
6230 "external" => {
6231 !repository
6232 && package == (false, false, false)
6233 && symbol == (false, false, false, false)
6234 && external == (true, true)
6235 }
6236 _ => true,
6237 };
6238 if !valid {
6239 return Err(DbError::GraphRowShape {
6240 table: "graph_entities",
6241 reason: "selector columns contradict entity kind",
6242 });
6243 }
6244 Ok(())
6245}
6246
6247fn resolved_relation_hub_keys_sql() -> &'static str {
6249 "WITH endpoints(entity_key) AS (
6250 SELECT relation.source_entity_key
6251 FROM graph_relations AS relation INDEXED BY idx_graph_relations_kind_resolution
6252 WHERE relation.project_instance_id = ?1
6253 AND relation.relation_scope = ?2
6254 AND relation.relation_kind = ?3
6255 AND relation.resolution_status = ?4
6256 AND relation.target_entity_key IS NOT NULL
6257 AND relation.source_entity_key <> relation.target_entity_key
6258 UNION ALL
6259 SELECT relation.target_entity_key
6260 FROM graph_relations AS relation INDEXED BY idx_graph_relations_kind_resolution
6261 WHERE relation.project_instance_id = ?1
6262 AND relation.relation_scope = ?2
6263 AND relation.relation_kind = ?3
6264 AND relation.resolution_status = ?4
6265 AND relation.target_entity_key IS NOT NULL
6266 AND relation.source_entity_key <> relation.target_entity_key
6267 ), endpoint_degrees(entity_key, degree) AS (
6268 SELECT entity_key, COUNT(*)
6269 FROM endpoints
6270 GROUP BY entity_key
6271 )
6272 SELECT entity_key
6273 FROM endpoint_degrees
6274 ORDER BY endpoint_degrees.degree DESC, endpoint_degrees.entity_key
6275 LIMIT ?5"
6276}
6277
6278fn is_default<T: Default + PartialEq>(value: &T) -> bool {
6280 value == &T::default()
6281}
6282
6283fn adjacency_relation_sql(
6285 frontier_count: usize,
6286 direction: RepositoryGraphDirection,
6287 continuation_index: Option<usize>,
6288 relation_filter: bool,
6289 resolved_only: bool,
6290 include_documents: bool,
6291) -> String {
6292 let (key_column, index_name) = match direction {
6293 RepositoryGraphDirection::Outbound => {
6294 ("source_entity_key", "idx_graph_relations_source_kind")
6295 }
6296 RepositoryGraphDirection::Inbound => {
6297 ("target_entity_key", "idx_graph_relations_target_kind")
6298 }
6299 };
6300 let request = if resolved_only {
6301 "request(project_instance_id, resolution_status) AS (VALUES (?, ?))"
6302 } else {
6303 "request(project_instance_id) AS (VALUES (?))"
6304 };
6305 let document_filter = if relation_filter || include_documents {
6306 ""
6307 } else {
6308 "AND NOT (
6309 relation.relation_scope = 'extended'
6310 AND relation.relation_kind = 'documents'
6311 )"
6312 };
6313 let relation_filter = if relation_filter {
6314 "AND relation.relation_scope = ? AND relation.relation_kind = ?"
6315 } else {
6316 ""
6317 };
6318 let resolution_filter = if resolved_only {
6319 "AND relation.resolution_status = request.resolution_status
6320 AND relation.target_entity_key IS NOT NULL
6321 AND relation.source_entity_key <> relation.target_entity_key"
6322 } else {
6323 ""
6324 };
6325 let branches = (continuation_index.unwrap_or(0)..frontier_count)
6326 .map(|frontier_index| {
6327 let continuation = if continuation_index == Some(frontier_index) {
6328 "AND (relation.relation_scope, relation.relation_kind,
6329 relation.relation_key) > (?, ?, ?)"
6330 .to_string()
6331 } else {
6332 String::new()
6333 };
6334 format!(
6335 "SELECT * FROM (
6336 SELECT {frontier_index} AS frontier_index,
6337 relation.relation_key, relation.project_instance_id,
6338 relation.canonical_identity, relation.source_entity_key,
6339 relation.relation_scope, relation.relation_kind,
6340 relation.resolution_status, relation.target_entity_key,
6341 relation.reference_text, relation.candidate_count,
6342 relation.document_unresolved_reason,
6343 relation.confidence, relation.completeness
6344 FROM graph_relations AS relation INDEXED BY {index_name}
6345 CROSS JOIN request
6346 WHERE relation.{key_column} = ?
6347 AND relation.project_instance_id = request.project_instance_id
6348 {relation_filter}
6349 {resolution_filter}
6350 {document_filter}
6351 {continuation}
6352 ORDER BY relation.relation_scope, relation.relation_kind,
6353 relation.relation_key
6354 LIMIT ?
6355 )"
6356 )
6357 })
6358 .collect::<Vec<_>>()
6359 .join(" UNION ALL ");
6360 format!(
6361 "WITH {request}
6362 {branches}
6363 ORDER BY frontier_index, relation_scope, relation_kind,
6364 relation_key
6365 LIMIT ?"
6366 )
6367}
6368
6369fn graph_values_clause(rows: usize, columns: usize) -> String {
6371 let row = format!("({})", vec!["?"; columns].join(", "));
6372 vec![row; rows].join(", ")
6373}
6374
6375fn graph_entity_hydration_sql(entity_count: usize) -> String {
6377 format!(
6378 "WITH requested(entity_key) AS (VALUES {})
6379 SELECT entity.entity_key, entity.project_instance_id,
6380 entity.canonical_identity, entity.entity_kind,
6381 entity.repository_path, entity.package_manager,
6382 entity.package_name, entity.manifest_path,
6383 entity.symbol_name, entity.symbol_kind,
6384 entity.symbol_parent, entity.symbol_signature,
6385 entity.external_system, entity.external_identity
6386 FROM requested
6387 JOIN graph_entities AS entity
6388 ON entity.entity_key = requested.entity_key
6389 WHERE entity.project_instance_id = ?",
6390 graph_values_clause(entity_count, 1),
6391 )
6392}
6393
6394fn graph_relation_hydration_sql(relation_count: usize) -> String {
6396 format!(
6397 "WITH requested(relation_key) AS (VALUES {})
6398 SELECT relation.relation_key, relation.project_instance_id,
6399 relation.canonical_identity, relation.source_entity_key,
6400 relation.relation_scope, relation.relation_kind,
6401 relation.resolution_status, relation.target_entity_key,
6402 relation.reference_text, relation.candidate_count,
6403 relation.document_unresolved_reason,
6404 relation.confidence, relation.completeness
6405 FROM requested
6406 JOIN graph_relations AS relation INDEXED BY idx_graph_relations_project_key
6407 ON relation.relation_key = requested.relation_key
6408 AND relation.project_instance_id = ?",
6409 graph_values_clause(relation_count, 1),
6410 )
6411}
6412
6413fn classified_relation_family_sql(selection: ContentSelection) -> String {
6415 let selection_predicate = match selection {
6416 ContentSelection::UnspecifiedLegacy => "",
6417 ContentSelection::Source | ContentSelection::Documentation => {
6418 "AND source_classification.classification = ?"
6419 }
6420 ContentSelection::Both => "AND source_classification.classification IN (?, ?)",
6421 };
6422 format!(
6423 "SELECT relation.relation_key, relation.project_instance_id,
6424 relation.canonical_identity, relation.source_entity_key,
6425 relation.relation_scope, relation.relation_kind,
6426 relation.resolution_status, relation.target_entity_key,
6427 relation.reference_text, relation.candidate_count,
6428 relation.document_unresolved_reason,
6429 relation.confidence, relation.completeness,
6430 source_classification.classification
6431 FROM graph_relations AS relation INDEXED BY idx_graph_relations_kind_order
6432 JOIN graph_entities AS source
6433 ON source.project_instance_id = relation.project_instance_id
6434 AND source.entity_key = relation.source_entity_key
6435 LEFT JOIN file_content_classifications AS source_classification
6436 ON source_classification.path = CASE source.entity_kind
6437 WHEN 'file' THEN source.repository_path
6438 WHEN 'symbol' THEN source.repository_path
6439 WHEN 'package' THEN source.manifest_path
6440 END
6441 WHERE relation.project_instance_id = ?
6442 AND relation.relation_scope = ?
6443 AND relation.relation_kind = ?
6444 {selection_predicate}
6445 ORDER BY relation.relation_key
6446 LIMIT ?"
6447 )
6448}
6449
6450fn occurrence_pages_sql(relation_count: usize) -> String {
6452 let branches = (0..relation_count)
6453 .map(|index| {
6454 format!(
6455 "SELECT {index} AS relation_index, relation_key, file_path,
6456 start_line, start_column, end_line, end_column
6457 FROM (
6458 SELECT relation_key, file_path, start_line, start_column,
6459 end_line, end_column
6460 FROM graph_relation_occurrences
6461 WHERE relation_key = ?
6462 ORDER BY file_path, start_line, start_column,
6463 end_line, end_column
6464 LIMIT ?
6465 ) AS occurrence_page_{index}"
6466 )
6467 })
6468 .collect::<Vec<_>>()
6469 .join(" UNION ALL ");
6470 format!(
6471 "{branches}
6472 ORDER BY relation_index, file_path, start_line, start_column,
6473 end_line, end_column"
6474 )
6475}
6476
6477fn path_coverage_sql(path_count: usize) -> String {
6479 let path_bindings = vec!["?"; path_count].join(", ");
6480 format!(
6481 "SELECT project_instance_id, scope_kind, scope_path, relation_scope,
6482 relation_kind, state, total, covered, omitted, reason, reached_limit,
6483 NULL, NULL
6484 FROM graph_coverage
6485 WHERE project_instance_id = ?
6486 AND scope_kind = ?
6487 AND scope_path IN ({path_bindings})
6488 ORDER BY scope_path, relation_scope, relation_kind, state, id
6489 LIMIT ?"
6490 )
6491}
6492
6493fn load_relation_entities(
6495 store: &AtlasStore,
6496 rows: &[RelationRow],
6497 project: ProjectInstanceId,
6498 generation: IndexGeneration,
6499 control: Option<&IndexWorkControl>,
6500) -> DbResult<HashMap<[u8; 32], GraphEntity>> {
6501 let references = rows.iter().collect::<Vec<_>>();
6502 load_relation_entity_references(store, &references, project, generation, control)
6503}
6504
6505fn load_relation_entity_references(
6507 store: &AtlasStore,
6508 rows: &[&RelationRow],
6509 project: ProjectInstanceId,
6510 generation: IndexGeneration,
6511 control: Option<&IndexWorkControl>,
6512) -> DbResult<HashMap<[u8; 32], GraphEntity>> {
6513 load_relation_entity_references_metered(store, rows, project, generation, control, None)
6514}
6515
6516fn load_relation_entity_references_metered(
6518 store: &AtlasStore,
6519 rows: &[&RelationRow],
6520 project: ProjectInstanceId,
6521 generation: IndexGeneration,
6522 control: Option<&IndexWorkControl>,
6523 meter: Option<&mut RepositoryGraphReadMeter>,
6524) -> DbResult<HashMap<[u8; 32], GraphEntity>> {
6525 let mut digests = BTreeSet::new();
6526 for row in rows {
6527 let row_project =
6528 project_from_blob("graph_relations.project_instance_id", row.project.clone())?;
6529 require_project(project, row_project)?;
6530 digests.insert(fixed_bytes::<32>(
6531 "graph_relations.source_entity_key",
6532 row.source.clone(),
6533 )?);
6534 if let Some(target) = &row.target {
6535 digests.insert(fixed_bytes::<32>(
6536 "graph_relations.target_entity_key",
6537 target.clone(),
6538 )?);
6539 }
6540 }
6541 load_graph_entities_by_digest_metered(
6542 store,
6543 &digests.into_iter().collect::<Vec<_>>(),
6544 project,
6545 generation,
6546 control,
6547 meter,
6548 )
6549}
6550
6551fn load_graph_entities_by_digest_metered(
6553 store: &AtlasStore,
6554 digests: &[[u8; 32]],
6555 project: ProjectInstanceId,
6556 generation: IndexGeneration,
6557 control: Option<&IndexWorkControl>,
6558 mut meter: Option<&mut RepositoryGraphReadMeter>,
6559) -> DbResult<HashMap<[u8; 32], GraphEntity>> {
6560 let mut entities = HashMap::with_capacity(digests.len());
6561 for chunk in digests.chunks(GRAPH_ENTITY_HYDRATION_CHUNK) {
6562 if let Some(control) = control {
6563 control.check(IndexWorkStage::RepositoryTraversal)?;
6564 }
6565 let sql = graph_entity_hydration_sql(chunk.len());
6566 let mut bindings = chunk
6567 .iter()
6568 .map(|digest| Value::Blob(digest.to_vec()))
6569 .collect::<Vec<_>>();
6570 bindings.push(Value::Blob(project.as_bytes().to_vec()));
6571 let raw = with_sqlite_read_progress(
6572 &store.connection,
6573 control,
6574 IndexWorkStage::RepositoryTraversal,
6575 || {
6576 let mut statement = store.connection.prepare(&sql)?;
6577 let rows = statement.query(params_from_iter(bindings.iter()))?;
6578 if let Some(meter) = meter.as_deref_mut() {
6579 collect_entity_rows_metered(rows, meter)
6580 } else {
6581 collect_entity_rows(rows)
6582 }
6583 },
6584 )?;
6585 for row in raw {
6586 let entity = entity_from_row(row, project, generation)?;
6587 let digest = entity.key().digest_bytes()?;
6588 if entities.contains_key(&digest) {
6589 return Err(DbError::GraphRowShape {
6590 table: "graph_entities",
6591 reason: "batched entity hydration returned a duplicate key",
6592 });
6593 }
6594 if let Some(meter) = meter.as_deref_mut() {
6595 meter.record_entity(&entity)?;
6596 }
6597 entities.insert(digest, entity);
6598 }
6599 }
6600 Ok(entities)
6601}
6602
6603fn maximum_repository_graph_read_budget() -> DbResult<RepositoryGraphReadBudget> {
6605 Ok(RepositoryGraphReadBudget::new(
6606 RepositoryGraphReadBudget::MAX_REQUESTED_ROWS,
6607 RepositoryGraphReadBudget::MAX_RETURNED_ROWS,
6608 RepositoryGraphReadBudget::MAX_DECODED_BYTES,
6609 RepositoryGraphReadBudget::MAX_HYDRATED_ENTITIES,
6610 RepositoryGraphReadBudget::MAX_HYDRATED_PATHS,
6611 )?)
6612}
6613
6614fn validate_path_coverage_request(paths: &[RepositoryNodePath]) -> DbResult<()> {
6616 if paths.len() > MAX_REPOSITORY_GRAPH_FRONTIER {
6617 return Err(GraphContractError::InvalidLimits {
6618 reason: "graph coverage path set exceeds the product ceiling",
6619 }
6620 .into());
6621 }
6622 if paths
6623 .iter()
6624 .map(RepositoryNodePath::as_str)
6625 .collect::<BTreeSet<_>>()
6626 .len()
6627 != paths.len()
6628 {
6629 return Err(GraphContractError::InvalidLimits {
6630 reason: "graph coverage paths must be unique",
6631 }
6632 .into());
6633 }
6634 Ok(())
6635}
6636
6637fn validate_graph_hydration_request(digests: &[[u8; 32]]) -> DbResult<()> {
6639 if digests.len() > MAX_REPOSITORY_GRAPH_FRONTIER {
6640 return Err(GraphContractError::InvalidLimits {
6641 reason: "graph hydration key set exceeds the product ceiling",
6642 }
6643 .into());
6644 }
6645 if digests.iter().copied().collect::<HashSet<_>>().len() != digests.len() {
6646 return Err(GraphContractError::InvalidLimits {
6647 reason: "graph hydration keys must be unique",
6648 }
6649 .into());
6650 }
6651 Ok(())
6652}
6653
6654fn graph_entity_purpose_owner(entity: &GraphEntity) -> Option<&str> {
6656 match entity.selector() {
6657 EntitySelector::Project => Some("."),
6658 EntitySelector::Folder { path } => Some(path.as_str()),
6659 EntitySelector::File { path } => Some(path.as_str()),
6660 EntitySelector::Package { package } => Some(package.manifest.as_str()),
6661 EntitySelector::Symbol { symbol } => Some(symbol.file.as_str()),
6662 EntitySelector::External { .. } => None,
6663 }
6664}
6665
6666fn graph_entity_classification_owner(entity: &GraphEntity) -> Option<&str> {
6668 match entity.selector() {
6669 EntitySelector::File { path } => Some(path.as_str()),
6670 EntitySelector::Package { package } => Some(package.manifest.as_str()),
6671 EntitySelector::Symbol { symbol } => Some(symbol.file.as_str()),
6672 EntitySelector::Project
6673 | EntitySelector::Folder { .. }
6674 | EntitySelector::External { .. } => None,
6675 }
6676}
6677
6678fn classified_relation_detail_from_row(
6680 entities: &HashMap<[u8; 32], GraphEntity>,
6681 row: ClassifiedRelationRow,
6682 expected_project: ProjectInstanceId,
6683 generation: IndexGeneration,
6684) -> DbResult<RepositoryGraphClassifiedRelationRow> {
6685 let source_classification = row
6686 .source_classification
6687 .map(parse_classification)
6688 .transpose()?;
6689 let detail = relation_detail_from_row(entities, row.relation, expected_project, generation)?;
6690 match (
6691 graph_entity_classification_owner(&detail.source),
6692 source_classification,
6693 ) {
6694 (Some(path), None) => Err(DbError::FileContentClassificationMissing {
6695 path: path.to_string(),
6696 }),
6697 (None, Some(_)) => Err(DbError::GraphRowShape {
6698 table: "file_content_classifications",
6699 reason: "non-file-bearing graph source retained a classification",
6700 }),
6701 (_, source_classification) => Ok(RepositoryGraphClassifiedRelationRow {
6702 detail,
6703 source_classification,
6704 }),
6705 }
6706}
6707
6708fn relation_detail_from_row(
6710 entities: &HashMap<[u8; 32], GraphEntity>,
6711 row: RelationRow,
6712 expected_project: ProjectInstanceId,
6713 generation: IndexGeneration,
6714) -> DbResult<RepositoryGraphRelationRow> {
6715 let source_key = fixed_bytes::<32>("graph_relations.source_entity_key", row.source.clone())?;
6716 let target_key = row
6717 .target
6718 .as_ref()
6719 .map(|target| fixed_bytes::<32>("graph_relations.target_entity_key", target.clone()))
6720 .transpose()?;
6721 let document_unresolved_reason = document_unresolved_reason_from_row(&row)?;
6722 let relation = relation_from_row(entities, row, expected_project, generation)?;
6723 let source = entities
6724 .get(&source_key)
6725 .cloned()
6726 .ok_or(DbError::GraphRowShape {
6727 table: "graph_relations",
6728 reason: "source entity is missing",
6729 })?;
6730 let target = target_key
6731 .map(|key| {
6732 entities.get(&key).cloned().ok_or(DbError::GraphRowShape {
6733 table: "graph_relations",
6734 reason: "retained target entity is missing",
6735 })
6736 })
6737 .transpose()?;
6738 Ok(RepositoryGraphRelationRow {
6739 relation,
6740 source,
6741 target,
6742 document_unresolved_reason,
6743 })
6744}
6745
6746fn relation_from_row(
6748 entities: &HashMap<[u8; 32], GraphEntity>,
6749 row: RelationRow,
6750 expected_project: ProjectInstanceId,
6751 generation: IndexGeneration,
6752) -> DbResult<LogicalRelation> {
6753 let _document_unresolved_reason = document_unresolved_reason_from_row(&row)?;
6754 let project = project_from_blob("graph_relations.project_instance_id", row.project.clone())?;
6755 require_project(expected_project, project)?;
6756 let source_key = fixed_bytes::<32>("graph_relations.source_entity_key", row.source.clone())?;
6757 let source = entities
6758 .get(&source_key)
6759 .cloned()
6760 .ok_or(DbError::GraphRowShape {
6761 table: "graph_relations",
6762 reason: "source entity is missing",
6763 })?;
6764 let kind = parse_relation_kind(&row.relation_scope, &row.relation_kind)?;
6765 let resolution = match row.resolution_status.as_str() {
6766 "resolved" => {
6767 require_relation_resolution_shape(&row, true, false, false)?;
6768 let target_key = fixed_bytes::<32>(
6769 "graph_relations.target_entity_key",
6770 row.target.clone().ok_or(DbError::GraphRowShape {
6771 table: "graph_relations",
6772 reason: "resolved target is missing",
6773 })?,
6774 )?;
6775 let target = entities
6776 .get(&target_key)
6777 .cloned()
6778 .ok_or(DbError::GraphRowShape {
6779 table: "graph_relations",
6780 reason: "resolved target entity is missing",
6781 })?;
6782 RelationResolution::resolved(&target)?
6783 }
6784 "external" => {
6785 require_relation_resolution_shape(&row, true, false, false)?;
6786 let target_key = fixed_bytes::<32>(
6787 "graph_relations.target_entity_key",
6788 row.target.clone().ok_or(DbError::GraphRowShape {
6789 table: "graph_relations",
6790 reason: "external target is missing",
6791 })?,
6792 )?;
6793 let target = entities
6794 .get(&target_key)
6795 .cloned()
6796 .ok_or(DbError::GraphRowShape {
6797 table: "graph_relations",
6798 reason: "external target entity is missing",
6799 })?;
6800 RelationResolution::external(&target)?
6801 }
6802 "ambiguous" => {
6803 require_relation_resolution_shape(&row, false, true, true)?;
6804 let candidates = positive_u32(
6805 "graph_relations.candidate_count",
6806 row.candidate_count.ok_or(DbError::GraphRowShape {
6807 table: "graph_relations",
6808 reason: "ambiguous candidate count is missing",
6809 })?,
6810 )?;
6811 RelationResolution::Ambiguous {
6812 reference: GraphIdentityText::new(row.reference.clone().ok_or(
6813 DbError::GraphRowShape {
6814 table: "graph_relations",
6815 reason: "ambiguous reference is missing",
6816 },
6817 )?)?,
6818 candidates,
6819 }
6820 }
6821 "unresolved" => {
6822 require_relation_resolution_shape(&row, false, true, false)?;
6823 RelationResolution::Unresolved {
6824 reference: GraphIdentityText::new(row.reference.clone().ok_or(
6825 DbError::GraphRowShape {
6826 table: "graph_relations",
6827 reason: "unresolved reference is missing",
6828 },
6829 )?)?,
6830 }
6831 }
6832 value => {
6833 return Err(DbError::InvalidEnum {
6834 field: "graph_relations.resolution_status",
6835 value: value.to_string(),
6836 });
6837 }
6838 };
6839 let relation = LogicalRelation::new(
6840 &source,
6841 kind,
6842 resolution,
6843 parse_confidence(&row.confidence)?,
6844 parse_completeness(&row.completeness)?,
6845 generation,
6846 )?;
6847 validate_relation_key(&relation, row.key, &row.canonical)?;
6848 Ok(relation)
6849}
6850
6851fn document_unresolved_reason_from_row(
6853 row: &RelationRow,
6854) -> DbResult<Option<DocumentTargetUnresolvedReason>> {
6855 match (
6856 row.relation_scope.as_str(),
6857 row.relation_kind.as_str(),
6858 row.resolution_status.as_str(),
6859 row.document_unresolved_reason.as_deref(),
6860 ) {
6861 ("extended", "documents", "unresolved", Some(value)) => {
6862 DocumentTargetUnresolvedReason::from_db(value)
6863 .map(Some)
6864 .ok_or_else(|| DbError::InvalidEnum {
6865 field: "graph_relations.document_unresolved_reason",
6866 value: value.to_string(),
6867 })
6868 }
6869 ("extended", "documents", "unresolved", None) => Err(DbError::GraphRowShape {
6870 table: "graph_relations",
6871 reason: "unresolved document relation is missing its closed reason",
6872 }),
6873 (_, _, _, None) => Ok(None),
6874 _ => Err(DbError::GraphRowShape {
6875 table: "graph_relations",
6876 reason: "document unresolved reason contradicts relation family or status",
6877 }),
6878 }
6879}
6880
6881fn require_relation_resolution_shape(
6883 row: &RelationRow,
6884 target_required: bool,
6885 reference_required: bool,
6886 candidates_required: bool,
6887) -> DbResult<()> {
6888 let valid = row.target.is_some() == target_required
6889 && row.reference.is_some() == reference_required
6890 && row.candidate_count.is_some() == candidates_required;
6891 if !valid {
6892 return Err(DbError::GraphRowShape {
6893 table: "graph_relations",
6894 reason: "resolution columns contradict status",
6895 });
6896 }
6897 Ok(())
6898}
6899
6900fn occurrence_from_row(
6902 row: OccurrenceRow,
6903 relation: &LogicalRelation,
6904 generation: IndexGeneration,
6905) -> DbResult<RelationOccurrence> {
6906 let stored_key = fixed_bytes::<32>("graph_relation_occurrences.relation_key", row.relation)?;
6907 if stored_key != relation.key().digest_bytes()? {
6908 return Err(DbError::GraphRowShape {
6909 table: "graph_relation_occurrences",
6910 reason: "occurrence relation key does not match query",
6911 });
6912 }
6913 RelationOccurrence::new(
6914 relation,
6915 RepositoryFilePath::new(Path::new(&row.file_path))?,
6916 SourceSpan::new(
6917 positive_u32_value("graph_relation_occurrences.start_line", row.start_line)?,
6918 nonnegative_u32("graph_relation_occurrences.start_column", row.start_column)?,
6919 positive_u32_value("graph_relation_occurrences.end_line", row.end_line)?,
6920 nonnegative_u32("graph_relation_occurrences.end_column", row.end_column)?,
6921 )?,
6922 generation,
6923 )
6924 .map_err(Into::into)
6925}
6926
6927fn coverage_from_row(
6929 row: CoverageRow,
6930 expected_project: ProjectInstanceId,
6931 generation: IndexGeneration,
6932) -> DbResult<CoverageRecord> {
6933 let project = project_from_blob("graph_coverage.project_instance_id", row.project)?;
6934 require_project(expected_project, project)?;
6935 let scope = match (row.scope_kind.as_str(), row.scope_path) {
6936 ("project", None) => CoverageScope::Project,
6937 ("path", Some(path)) => CoverageScope::Path {
6938 path: RepositoryNodePath::new(Path::new(&path))?,
6939 },
6940 ("project" | "path", _) => {
6941 return Err(DbError::GraphRowShape {
6942 table: "graph_coverage",
6943 reason: "scope columns contradict scope kind",
6944 });
6945 }
6946 (value, _) => {
6947 return Err(DbError::InvalidEnum {
6948 field: "graph_coverage.scope_kind",
6949 value: value.to_string(),
6950 });
6951 }
6952 };
6953 let relation = match (row.relation_scope, row.relation_kind) {
6954 (None, None) => None,
6955 (Some(scope), Some(kind)) => Some(parse_relation_kind(&scope, &kind)?),
6956 _ => {
6957 return Err(DbError::GraphRowShape {
6958 table: "graph_coverage",
6959 reason: "relation scope and kind must both be present or absent",
6960 });
6961 }
6962 };
6963 let persisted_total = nonnegative_u64("graph_coverage.total", row.total)?;
6964 let covered = nonnegative_u64("graph_coverage.covered", row.covered)?;
6965 let omitted = nonnegative_u64("graph_coverage.omitted", row.omitted)?;
6966 let persisted_state = parse_coverage_state(&row.state)?;
6967 let state = if persisted_state == CoverageState::Complete
6968 && covered == 0
6969 && omitted == 0
6970 && relation == Some(GraphRelationKind::Extended(ExtendedRelationKind::Documents))
6971 {
6972 CoverageState::NoCandidates
6973 } else {
6974 persisted_state
6975 };
6976 let record = CoverageRecord::new(
6977 scope,
6978 relation,
6979 state,
6980 covered,
6981 omitted,
6982 generation,
6983 row.reason.map(GraphIdentityText::new).transpose()?,
6984 row.reached_limit
6985 .as_deref()
6986 .map(parse_limit_kind)
6987 .transpose()?,
6988 )?;
6989 if record.total() != persisted_total {
6990 return Err(DbError::GraphRowShape {
6991 table: "graph_coverage",
6992 reason: "total does not equal covered plus omitted",
6993 });
6994 }
6995 Ok(record)
6996}
6997
6998fn coverage_discovery_from_row(
7000 row: CoverageRow,
7001 expected_project: ProjectInstanceId,
7002 generation: IndexGeneration,
7003) -> DbResult<RepositoryCoverageRow> {
7004 let parser = row
7005 .parser
7006 .as_deref()
7007 .map(|value| parse_parser_kind("source_parse_metadata.source_parser", value))
7008 .transpose()?;
7009 let provider = row
7010 .provider
7011 .as_deref()
7012 .map(|value| parse_parser_kind("source_parse_metadata.fact_parser", value))
7013 .transpose()?;
7014 let coverage = coverage_from_row(row, expected_project, generation)?;
7015 Ok(RepositoryCoverageRow {
7016 coverage,
7017 parser,
7018 provider,
7019 })
7020}
7021
7022fn require_project(expected: ProjectInstanceId, found: ProjectInstanceId) -> DbResult<()> {
7024 if expected != found {
7025 return Err(DbError::GraphProjectIdentityMismatch {
7026 expected: expected.to_string(),
7027 found: found.to_string(),
7028 });
7029 }
7030 Ok(())
7031}
7032
7033fn validate_entity_key(
7035 entity: &GraphEntity,
7036 stored_key: Vec<u8>,
7037 stored_canonical: &str,
7038) -> DbResult<()> {
7039 let stored_key = fixed_bytes::<32>("graph_entities.entity_key", stored_key)?;
7040 if stored_key != entity.key().digest_bytes()? {
7041 return Err(projectatlas_core::graph::GraphContractError::InvalidStableKeyDigest.into());
7042 }
7043 if stored_canonical != entity.key().canonical_identity() {
7044 return Err(
7045 projectatlas_core::graph::GraphContractError::StableKeyCollision {
7046 digest: entity.key().digest().to_string(),
7047 }
7048 .into(),
7049 );
7050 }
7051 Ok(())
7052}
7053
7054fn validate_relation_key(
7056 relation: &LogicalRelation,
7057 stored_key: Vec<u8>,
7058 stored_canonical: &str,
7059) -> DbResult<()> {
7060 let stored_key = fixed_bytes::<32>("graph_relations.relation_key", stored_key)?;
7061 if stored_key != relation.key().digest_bytes()? {
7062 return Err(projectatlas_core::graph::GraphContractError::InvalidStableKeyDigest.into());
7063 }
7064 if stored_canonical != relation.key().canonical_identity() {
7065 return Err(
7066 projectatlas_core::graph::GraphContractError::StableKeyCollision {
7067 digest: relation.key().digest().to_string(),
7068 }
7069 .into(),
7070 );
7071 }
7072 Ok(())
7073}
7074
7075fn collect_entity_rows(mut rows: rusqlite::Rows<'_>) -> DbResult<Vec<EntityRow>> {
7077 let mut collected = Vec::new();
7078 while let Some(row) = rows.next()? {
7079 collected.push(entity_row(row)?);
7080 }
7081 Ok(collected)
7082}
7083
7084fn collect_entity_rows_metered(
7086 mut rows: rusqlite::Rows<'_>,
7087 meter: &mut RepositoryGraphReadMeter,
7088) -> DbResult<Vec<EntityRow>> {
7089 let mut collected = Vec::new();
7090 while let Some(row) = rows.next()? {
7091 let raw = entity_row(row)?;
7092 meter.record_decoded_bytes(entity_row_decoded_bytes(&raw)?)?;
7093 collected.push(raw);
7094 }
7095 Ok(collected)
7096}
7097
7098fn entity_row(row: &Row<'_>) -> rusqlite::Result<EntityRow> {
7100 Ok(EntityRow {
7101 key: row.get(0)?,
7102 project: row.get(1)?,
7103 canonical: row.get(2)?,
7104 kind: row.get(3)?,
7105 repository_path: row.get(4)?,
7106 package_manager: row.get(5)?,
7107 package_name: row.get(6)?,
7108 manifest_path: row.get(7)?,
7109 symbol_name: row.get(8)?,
7110 symbol_kind: row.get(9)?,
7111 symbol_parent: row.get(10)?,
7112 symbol_signature: row.get(11)?,
7113 external_system: row.get(12)?,
7114 external_identity: row.get(13)?,
7115 })
7116}
7117
7118fn entity_row_decoded_bytes(row: &EntityRow) -> DbResult<u64> {
7120 decoded_payload_bytes(
7121 [
7122 row.key.len(),
7123 row.project.len(),
7124 row.canonical.len(),
7125 row.kind.len(),
7126 row.repository_path.as_ref().map_or(0, String::len),
7127 row.package_manager.as_ref().map_or(0, String::len),
7128 row.package_name.as_ref().map_or(0, String::len),
7129 row.manifest_path.as_ref().map_or(0, String::len),
7130 row.symbol_name.as_ref().map_or(0, String::len),
7131 row.symbol_kind.as_ref().map_or(0, String::len),
7132 row.symbol_parent.as_ref().map_or(0, String::len),
7133 row.symbol_signature.as_ref().map_or(0, String::len),
7134 row.external_system.as_ref().map_or(0, String::len),
7135 row.external_identity.as_ref().map_or(0, String::len),
7136 ],
7137 0,
7138 )
7139}
7140
7141fn collect_relation_rows(mut rows: rusqlite::Rows<'_>) -> DbResult<Vec<RelationRow>> {
7143 let mut collected = Vec::new();
7144 while let Some(row) = rows.next()? {
7145 collected.push(relation_row(row)?);
7146 }
7147 Ok(collected)
7148}
7149
7150fn collect_classified_relation_rows(
7152 mut rows: rusqlite::Rows<'_>,
7153) -> DbResult<Vec<ClassifiedRelationRow>> {
7154 let mut collected = Vec::new();
7155 while let Some(row) = rows.next()? {
7156 collected.push(ClassifiedRelationRow {
7157 relation: relation_row_at(row, 0)?,
7158 source_classification: row.get(13)?,
7159 });
7160 }
7161 Ok(collected)
7162}
7163
7164fn collect_relation_rows_metered(
7166 mut rows: rusqlite::Rows<'_>,
7167 meter: &mut RepositoryGraphReadMeter,
7168) -> DbResult<Vec<RelationRow>> {
7169 let mut collected = Vec::new();
7170 while let Some(row) = rows.next()? {
7171 let raw = relation_row(row)?;
7172 meter.record_decoded_bytes(relation_row_decoded_bytes(&raw)?)?;
7173 collected.push(raw);
7174 }
7175 Ok(collected)
7176}
7177
7178fn collect_adjacency_relation_rows_metered(
7180 mut rows: rusqlite::Rows<'_>,
7181 meter: &mut RepositoryGraphReadMeter,
7182) -> DbResult<Vec<AdjacencyRelationRow>> {
7183 let mut collected = Vec::new();
7184 while let Some(row) = rows.next()? {
7185 let raw = AdjacencyRelationRow {
7186 frontier_index: row.get(0)?,
7187 relation: relation_row_at(row, 1)?,
7188 };
7189 meter.record_decoded_bytes(
7190 relation_row_decoded_bytes(&raw.relation)?
7191 .checked_add(8)
7192 .ok_or(GraphContractError::InvalidLimits {
7193 reason: "graph adjacency decoded row size overflowed",
7194 })?,
7195 )?;
7196 collected.push(raw);
7197 }
7198 Ok(collected)
7199}
7200
7201fn relation_row(row: &Row<'_>) -> rusqlite::Result<RelationRow> {
7203 relation_row_at(row, 0)
7204}
7205
7206fn relation_row_at(row: &Row<'_>, offset: usize) -> rusqlite::Result<RelationRow> {
7208 Ok(RelationRow {
7209 key: row.get(offset)?,
7210 project: row.get(offset + 1)?,
7211 canonical: row.get(offset + 2)?,
7212 source: row.get(offset + 3)?,
7213 relation_scope: row.get(offset + 4)?,
7214 relation_kind: row.get(offset + 5)?,
7215 resolution_status: row.get(offset + 6)?,
7216 target: row.get(offset + 7)?,
7217 reference: row.get(offset + 8)?,
7218 candidate_count: row.get(offset + 9)?,
7219 document_unresolved_reason: row.get(offset + 10)?,
7220 confidence: row.get(offset + 11)?,
7221 completeness: row.get(offset + 12)?,
7222 })
7223}
7224
7225fn relation_row_decoded_bytes(row: &RelationRow) -> DbResult<u64> {
7227 decoded_payload_bytes(
7228 [
7229 row.key.len(),
7230 row.project.len(),
7231 row.canonical.len(),
7232 row.source.len(),
7233 row.relation_scope.len(),
7234 row.relation_kind.len(),
7235 row.resolution_status.len(),
7236 row.target.as_ref().map_or(0, Vec::len),
7237 row.reference.as_ref().map_or(0, String::len),
7238 row.document_unresolved_reason
7239 .as_ref()
7240 .map_or(0, String::len),
7241 row.confidence.len(),
7242 row.completeness.len(),
7243 ],
7244 8,
7245 )
7246}
7247
7248fn decoded_payload_bytes(
7250 lengths: impl IntoIterator<Item = usize>,
7251 fixed_bytes: u64,
7252) -> DbResult<u64> {
7253 let mut decoded = fixed_bytes;
7254 for length in lengths {
7255 let length =
7256 u64::try_from(length).map_err(|_source| GraphContractError::InvalidLimits {
7257 reason: "graph read decoded field length overflowed",
7258 })?;
7259 decoded = decoded
7260 .checked_add(length)
7261 .ok_or(GraphContractError::InvalidLimits {
7262 reason: "graph read decoded row size overflowed",
7263 })?;
7264 }
7265 Ok(decoded)
7266}
7267
7268fn occurrence_row(row: &Row<'_>) -> rusqlite::Result<OccurrenceRow> {
7270 occurrence_row_at(row, 0)
7271}
7272
7273fn occurrence_row_at(row: &Row<'_>, offset: usize) -> rusqlite::Result<OccurrenceRow> {
7275 Ok(OccurrenceRow {
7276 relation: row.get(offset)?,
7277 file_path: row.get(offset + 1)?,
7278 start_line: row.get(offset + 2)?,
7279 start_column: row.get(offset + 3)?,
7280 end_line: row.get(offset + 4)?,
7281 end_column: row.get(offset + 5)?,
7282 })
7283}
7284
7285fn occurrence_row_decoded_bytes(row: &OccurrenceRow) -> DbResult<u64> {
7287 decoded_payload_bytes([row.relation.len(), row.file_path.len()], 32)
7288}
7289
7290fn coverage_row(row: &Row<'_>) -> rusqlite::Result<CoverageRow> {
7292 Ok(CoverageRow {
7293 project: row.get(0)?,
7294 scope_kind: row.get(1)?,
7295 scope_path: row.get(2)?,
7296 relation_scope: row.get(3)?,
7297 relation_kind: row.get(4)?,
7298 state: row.get(5)?,
7299 total: row.get(6)?,
7300 covered: row.get(7)?,
7301 omitted: row.get(8)?,
7302 reason: row.get(9)?,
7303 reached_limit: row.get(10)?,
7304 parser: row.get(11)?,
7305 provider: row.get(12)?,
7306 })
7307}
7308
7309fn coverage_row_decoded_bytes(row: &CoverageRow) -> DbResult<u64> {
7311 decoded_payload_bytes(
7312 [
7313 row.project.len(),
7314 row.scope_kind.len(),
7315 row.scope_path.as_ref().map_or(0, String::len),
7316 row.relation_scope.as_ref().map_or(0, String::len),
7317 row.relation_kind.as_ref().map_or(0, String::len),
7318 row.state.len(),
7319 row.reason.as_ref().map_or(0, String::len),
7320 row.reached_limit.as_ref().map_or(0, String::len),
7321 row.parser.as_ref().map_or(0, String::len),
7322 row.provider.as_ref().map_or(0, String::len),
7323 ],
7324 24,
7325 )
7326}
7327
7328fn page_from_raw<Raw, Domain>(
7330 raw: Vec<Raw>,
7331 limit: u32,
7332 mut convert: impl FnMut(Raw) -> DbResult<Domain>,
7333) -> DbResult<RepositoryGraphPage<Domain>> {
7334 let mut rows = raw
7335 .into_iter()
7336 .map(&mut convert)
7337 .collect::<DbResult<Vec<_>>>()?;
7338 let truncated = rows.len() > limit as usize;
7339 if truncated {
7340 rows.pop();
7341 }
7342 Ok(RepositoryGraphPage { rows, truncated })
7343}
7344
7345fn empty_page<T>() -> RepositoryGraphPage<T> {
7347 RepositoryGraphPage {
7348 rows: Vec::new(),
7349 truncated: false,
7350 }
7351}
7352
7353fn empty_adjacency_page() -> RepositoryGraphAdjacencyPage {
7355 RepositoryGraphAdjacencyPage {
7356 rows: Vec::new(),
7357 truncated: false,
7358 continuation: None,
7359 }
7360}
7361
7362const fn empty_affected_source_footprint() -> RepositoryAffectedSourceFootprint {
7364 RepositoryAffectedSourceFootprint {
7365 rows: 0,
7366 retained_bytes: 0,
7367 truncated: false,
7368 }
7369}
7370
7371fn validated_limit_plus_one(limit: u32, ceiling: u32, reason: &'static str) -> DbResult<i64> {
7373 if limit == 0 || limit > ceiling {
7374 return Err(projectatlas_core::graph::GraphContractError::InvalidLimits { reason }.into());
7375 }
7376 Ok(i64::from(limit) + 1)
7377}
7378
7379fn fixed_bytes<const WIDTH: usize>(field: &'static str, bytes: Vec<u8>) -> DbResult<[u8; WIDTH]> {
7381 let found = bytes.len();
7382 bytes
7383 .try_into()
7384 .map_err(|_bytes| DbError::InvalidBlobLength {
7385 field,
7386 expected: WIDTH,
7387 found,
7388 })
7389}
7390
7391fn project_from_blob(field: &'static str, bytes: Vec<u8>) -> DbResult<ProjectInstanceId> {
7393 ProjectInstanceId::from_bytes(fixed_bytes::<16>(field, bytes)?).map_err(Into::into)
7394}
7395
7396fn required_text<'value>(
7398 table: &'static str,
7399 reason: &'static str,
7400 value: Option<&'value str>,
7401) -> DbResult<&'value str> {
7402 value.ok_or(DbError::GraphRowShape { table, reason })
7403}
7404
7405fn nonnegative_u64(field: &'static str, value: i64) -> DbResult<u64> {
7407 u64::try_from(value).map_err(|source| DbError::InvalidCount {
7408 field,
7409 value,
7410 source,
7411 })
7412}
7413
7414fn positive_u32(field: &'static str, value: i64) -> DbResult<NonZeroU32> {
7416 let value = positive_u32_value(field, value)?;
7417 NonZeroU32::new(value).ok_or(DbError::GraphRowShape {
7418 table: "graph_relations",
7419 reason: "candidate count must be positive",
7420 })
7421}
7422
7423fn positive_u32_value(field: &'static str, value: i64) -> DbResult<u32> {
7425 let converted = u32::try_from(value).map_err(|source| DbError::InvalidCount {
7426 field,
7427 value,
7428 source,
7429 })?;
7430 if converted == 0 {
7431 return Err(DbError::GraphRowShape {
7432 table: "repository_graph",
7433 reason: "positive integer column contains zero",
7434 });
7435 }
7436 Ok(converted)
7437}
7438
7439fn nonnegative_u32(field: &'static str, value: i64) -> DbResult<u32> {
7441 u32::try_from(value).map_err(|source| DbError::InvalidCount {
7442 field,
7443 value,
7444 source,
7445 })
7446}
7447
7448fn sqlite_count(field: &'static str, value: u64) -> DbResult<i64> {
7450 i64::try_from(value).map_err(|_source| DbError::GraphCountOverflow { field, value })
7451}
7452
7453const fn relation_parts(relation: GraphRelationKind) -> (&'static str, &'static str) {
7455 match relation {
7456 GraphRelationKind::Legacy(RelationKind::Contains) => ("legacy", "contains"),
7457 GraphRelationKind::Legacy(RelationKind::Imports) => ("legacy", "imports"),
7458 GraphRelationKind::Legacy(RelationKind::Calls) => ("legacy", "calls"),
7459 GraphRelationKind::Legacy(RelationKind::DependsOn) => ("legacy", "depends-on"),
7460 GraphRelationKind::Extended(ExtendedRelationKind::References) => ("extended", "references"),
7461 GraphRelationKind::Extended(ExtendedRelationKind::Tests) => ("extended", "tests"),
7462 GraphRelationKind::Extended(ExtendedRelationKind::RoutesTo) => ("extended", "routes-to"),
7463 GraphRelationKind::Extended(ExtendedRelationKind::Configures) => ("extended", "configures"),
7464 GraphRelationKind::Extended(ExtendedRelationKind::Deploys) => ("extended", "deploys"),
7465 GraphRelationKind::Extended(ExtendedRelationKind::Reads) => ("extended", "reads"),
7466 GraphRelationKind::Extended(ExtendedRelationKind::Writes) => ("extended", "writes"),
7467 GraphRelationKind::Extended(ExtendedRelationKind::Documents) => ("extended", "documents"),
7468 }
7469}
7470
7471fn parse_relation_kind(scope: &str, kind: &str) -> DbResult<GraphRelationKind> {
7473 match (scope, kind) {
7474 ("legacy", "contains") => Ok(GraphRelationKind::Legacy(RelationKind::Contains)),
7475 ("legacy", "imports") => Ok(GraphRelationKind::Legacy(RelationKind::Imports)),
7476 ("legacy", "calls") => Ok(GraphRelationKind::Legacy(RelationKind::Calls)),
7477 ("legacy", "depends-on") => Ok(GraphRelationKind::Legacy(RelationKind::DependsOn)),
7478 ("extended", "references") => Ok(GraphRelationKind::Extended(
7479 ExtendedRelationKind::References,
7480 )),
7481 ("extended", "tests") => Ok(GraphRelationKind::Extended(ExtendedRelationKind::Tests)),
7482 ("extended", "routes-to") => {
7483 Ok(GraphRelationKind::Extended(ExtendedRelationKind::RoutesTo))
7484 }
7485 ("extended", "configures") => Ok(GraphRelationKind::Extended(
7486 ExtendedRelationKind::Configures,
7487 )),
7488 ("extended", "deploys") => Ok(GraphRelationKind::Extended(ExtendedRelationKind::Deploys)),
7489 ("extended", "reads") => Ok(GraphRelationKind::Extended(ExtendedRelationKind::Reads)),
7490 ("extended", "writes") => Ok(GraphRelationKind::Extended(ExtendedRelationKind::Writes)),
7491 ("extended", "documents") => {
7492 Ok(GraphRelationKind::Extended(ExtendedRelationKind::Documents))
7493 }
7494 _ => Err(DbError::InvalidEnum {
7495 field: "graph_relations.relation_kind",
7496 value: format!("{scope}:{kind}"),
7497 }),
7498 }
7499}
7500
7501const fn symbol_kind_name(kind: SymbolKind) -> &'static str {
7503 match kind {
7504 SymbolKind::Function => "function",
7505 SymbolKind::Method => "method",
7506 SymbolKind::Class => "class",
7507 SymbolKind::Struct => "struct",
7508 SymbolKind::Enum => "enum",
7509 SymbolKind::Trait => "trait",
7510 SymbolKind::Interface => "interface",
7511 SymbolKind::Module => "module",
7512 SymbolKind::Type => "type",
7513 SymbolKind::Value => "value",
7514 SymbolKind::Import => "import",
7515 SymbolKind::Package => "package",
7516 SymbolKind::Workspace => "workspace",
7517 SymbolKind::Dependency => "dependency",
7518 SymbolKind::Heading => "heading",
7519 SymbolKind::Unknown => "unknown",
7520 }
7521}
7522
7523fn parse_symbol_kind(value: &str) -> DbResult<SymbolKind> {
7525 match value {
7526 "function" => Ok(SymbolKind::Function),
7527 "method" => Ok(SymbolKind::Method),
7528 "class" => Ok(SymbolKind::Class),
7529 "struct" => Ok(SymbolKind::Struct),
7530 "enum" => Ok(SymbolKind::Enum),
7531 "trait" => Ok(SymbolKind::Trait),
7532 "interface" => Ok(SymbolKind::Interface),
7533 "module" => Ok(SymbolKind::Module),
7534 "type" => Ok(SymbolKind::Type),
7535 "value" => Ok(SymbolKind::Value),
7536 "import" => Ok(SymbolKind::Import),
7537 "package" => Ok(SymbolKind::Package),
7538 "workspace" => Ok(SymbolKind::Workspace),
7539 "dependency" => Ok(SymbolKind::Dependency),
7540 "heading" => Ok(SymbolKind::Heading),
7541 "unknown" => Ok(SymbolKind::Unknown),
7542 _ => Err(DbError::InvalidEnum {
7543 field: "graph_entities.symbol_kind",
7544 value: value.to_string(),
7545 }),
7546 }
7547}
7548
7549const fn confidence_name(confidence: ConfidenceClass) -> &'static str {
7551 match confidence {
7552 ConfidenceClass::Exact => "exact",
7553 ConfidenceClass::High => "high",
7554 ConfidenceClass::Medium => "medium",
7555 ConfidenceClass::Low => "low",
7556 }
7557}
7558
7559fn parse_confidence(value: &str) -> DbResult<ConfidenceClass> {
7561 match value {
7562 "exact" => Ok(ConfidenceClass::Exact),
7563 "high" => Ok(ConfidenceClass::High),
7564 "medium" => Ok(ConfidenceClass::Medium),
7565 "low" => Ok(ConfidenceClass::Low),
7566 _ => Err(DbError::InvalidEnum {
7567 field: "graph_relations.confidence",
7568 value: value.to_string(),
7569 }),
7570 }
7571}
7572
7573const fn completeness_name(completeness: Completeness) -> &'static str {
7575 match completeness {
7576 Completeness::Complete => "complete",
7577 Completeness::Partial => "partial",
7578 }
7579}
7580
7581fn parse_completeness(value: &str) -> DbResult<Completeness> {
7583 match value {
7584 "complete" => Ok(Completeness::Complete),
7585 "partial" => Ok(Completeness::Partial),
7586 _ => Err(DbError::InvalidEnum {
7587 field: "graph_relations.completeness",
7588 value: value.to_string(),
7589 }),
7590 }
7591}
7592
7593fn coverage_scope_parts(scope: &CoverageScope) -> (&'static str, Option<&str>) {
7595 match scope {
7596 CoverageScope::Project => ("project", None),
7597 CoverageScope::Path { path } => ("path", Some(path.as_str())),
7598 }
7599}
7600
7601const fn coverage_state_name(state: CoverageState) -> &'static str {
7603 match state {
7604 CoverageState::Complete | CoverageState::NoCandidates => "complete",
7605 CoverageState::Partial => "partial",
7606 CoverageState::Failed => "failed",
7607 CoverageState::Ignored => "ignored",
7608 CoverageState::Oversized => "oversized",
7609 CoverageState::Quarantined => "quarantined",
7610 CoverageState::Stale => "stale",
7611 }
7612}
7613
7614pub(crate) fn parse_coverage_state(value: &str) -> DbResult<CoverageState> {
7616 match value {
7617 "complete" => Ok(CoverageState::Complete),
7618 "partial" => Ok(CoverageState::Partial),
7619 "failed" => Ok(CoverageState::Failed),
7620 "ignored" => Ok(CoverageState::Ignored),
7621 "oversized" => Ok(CoverageState::Oversized),
7622 "quarantined" => Ok(CoverageState::Quarantined),
7623 "stale" => Ok(CoverageState::Stale),
7624 _ => Err(DbError::InvalidEnum {
7625 field: "graph_coverage.state",
7626 value: value.to_string(),
7627 }),
7628 }
7629}
7630
7631fn parse_parser_kind(field: &'static str, value: &str) -> DbResult<ParserKind> {
7633 match value {
7634 "tree-sitter" => Ok(ParserKind::TreeSitter),
7635 "manifest" => Ok(ParserKind::Manifest),
7636 "structural" => Ok(ParserKind::Structural),
7637 "fallback" => Ok(ParserKind::Fallback),
7638 _ => Err(DbError::InvalidEnum {
7639 field,
7640 value: value.to_string(),
7641 }),
7642 }
7643}
7644
7645fn parse_graph_identity_field(value: String) -> DbResult<GraphIdentityField> {
7647 match value.as_str() {
7648 "package" => Ok(GraphIdentityField::Package),
7649 "symbol" => Ok(GraphIdentityField::Symbol),
7650 "parent" => Ok(GraphIdentityField::Parent),
7651 "signature" => Ok(GraphIdentityField::Signature),
7652 "relation.source" => Ok(GraphIdentityField::RelationSource),
7653 "relation.target" => Ok(GraphIdentityField::RelationTarget),
7654 "resolution-key" => Ok(GraphIdentityField::ResolutionKey),
7655 _ => Err(DbError::InvalidEnum {
7656 field: "graph_identity_rejections.field",
7657 value,
7658 }),
7659 }
7660}
7661
7662fn parse_graph_identity_rejection_reason(value: String) -> DbResult<GraphIdentityRejectionReason> {
7664 match value.as_str() {
7665 "empty" => Ok(GraphIdentityRejectionReason::Empty),
7666 "surrounding-whitespace" => Ok(GraphIdentityRejectionReason::SurroundingWhitespace),
7667 "control-characters" => Ok(GraphIdentityRejectionReason::ControlCharacters),
7668 "oversized" => Ok(GraphIdentityRejectionReason::Oversized),
7669 "reserved-namespace" => Ok(GraphIdentityRejectionReason::ReservedNamespace),
7670 "contract" => Ok(GraphIdentityRejectionReason::Contract),
7671 _ => Err(DbError::InvalidEnum {
7672 field: "graph_identity_rejections.reason",
7673 value,
7674 }),
7675 }
7676}
7677
7678fn graph_identity_rejection_from_row(row: &Row<'_>) -> DbResult<GraphIdentityRejection> {
7680 let file_path = row.get::<_, String>(0)?;
7681 let start_line =
7682 u32::try_from(row.get::<_, i64>(1)?).map_err(|_error| DbError::GraphRowShape {
7683 table: "graph_identity_rejections",
7684 reason: "start line does not fit the graph span domain",
7685 })?;
7686 let start_column =
7687 u32::try_from(row.get::<_, i64>(2)?).map_err(|_error| DbError::GraphRowShape {
7688 table: "graph_identity_rejections",
7689 reason: "start column does not fit the graph span domain",
7690 })?;
7691 let end_line =
7692 u32::try_from(row.get::<_, i64>(3)?).map_err(|_error| DbError::GraphRowShape {
7693 table: "graph_identity_rejections",
7694 reason: "end line does not fit the graph span domain",
7695 })?;
7696 let end_column =
7697 u32::try_from(row.get::<_, i64>(4)?).map_err(|_error| DbError::GraphRowShape {
7698 table: "graph_identity_rejections",
7699 reason: "end column does not fit the graph span domain",
7700 })?;
7701 Ok(GraphIdentityRejection {
7702 path: RepositoryNodePath::new(Path::new(&file_path))?,
7703 span: SourceSpan::new(start_line, start_column, end_line, end_column)?,
7704 parser: parse_parser_kind(
7705 "graph_identity_rejections.parser",
7706 &row.get::<_, String>(5)?,
7707 )?,
7708 field: parse_graph_identity_field(row.get(6)?)?,
7709 reason: parse_graph_identity_rejection_reason(row.get(7)?)?,
7710 fact_index: u64::try_from(row.get::<_, i64>(8)?).map_err(|_error| {
7711 DbError::GraphRowShape {
7712 table: "graph_identity_rejections",
7713 reason: "fact index does not fit the graph identity domain",
7714 }
7715 })?,
7716 })
7717}
7718
7719pub(crate) fn parse_limit_kind(value: &str) -> DbResult<GraphLimitKind> {
7721 GraphLimitKind::from_stable_name(value).ok_or_else(|| DbError::InvalidEnum {
7722 field: "graph_coverage.reached_limit",
7723 value: value.to_string(),
7724 })
7725}
7726
7727#[cfg(test)]
7728mod tests {
7729 use super::*;
7730 use crate::IndexedFileText;
7731 use projectatlas_core::symbols::{CodeSymbol, ParserKind, SymbolGraph, SymbolRelation};
7732 use projectatlas_core::{IndexCancellation, Node, NodeKind};
7733 use std::cell::RefCell;
7734 use std::error::Error;
7735 use std::fmt::Debug;
7736 use std::fs;
7737 use std::io;
7738 use std::sync::{
7739 Arc,
7740 atomic::{AtomicUsize, Ordering},
7741 };
7742 use std::time::{Duration, Instant};
7743
7744 thread_local! {
7745 static TRACED_STATEMENTS: RefCell<Vec<String>> = const { RefCell::new(Vec::new()) };
7747 }
7748
7749 fn record_traced_statement(sql: &str) {
7751 TRACED_STATEMENTS.with(|statements| statements.borrow_mut().push(sql.to_string()));
7752 }
7753
7754 fn trace_statements<T>(
7756 store: &mut AtlasStore,
7757 operation: impl FnOnce(&AtlasStore) -> DbResult<T>,
7758 ) -> Result<(T, Vec<String>), Box<dyn Error>> {
7759 TRACED_STATEMENTS.with(|statements| statements.borrow_mut().clear());
7760 store.connection.trace_v2(
7761 rusqlite::trace::TraceEventCodes::SQLITE_TRACE_STMT,
7762 Some(record_traced_event),
7763 );
7764 let result = operation(store);
7765 store
7766 .connection
7767 .trace_v2(rusqlite::trace::TraceEventCodes::empty(), None);
7768 let statements =
7769 TRACED_STATEMENTS.with(|statements| std::mem::take(&mut *statements.borrow_mut()));
7770 Ok((result?, statements))
7771 }
7772
7773 #[allow(clippy::needless_pass_by_value)]
7775 fn record_traced_event(event: rusqlite::trace::TraceEvent<'_>) {
7776 if let rusqlite::trace::TraceEvent::Stmt(_, sql) = event {
7777 record_traced_statement(sql);
7778 }
7779 }
7780
7781 #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
7783 enum DetailedRelationTraceStatement {
7784 ProjectIdentity,
7786 PublicationMetadata,
7788 ActiveGraphGeneration,
7790 AdjacencyRelations,
7792 RelationsByDigest,
7794 RelationEntities,
7796 RelationOccurrences,
7798 PurposeOwners,
7800 }
7801
7802 fn classify_detailed_relation_statement(
7804 statement: &str,
7805 ) -> Option<DetailedRelationTraceStatement> {
7806 if statement
7807 .contains("SELECT project_instance_id FROM project_identity WHERE singleton = 1")
7808 {
7809 Some(DetailedRelationTraceStatement::ProjectIdentity)
7810 } else if statement.contains("SELECT state.value, fingerprint.value, generation.value")
7811 && statement.contains("FROM metadata AS state")
7812 {
7813 Some(DetailedRelationTraceStatement::PublicationMetadata)
7814 } else if statement
7815 .contains("SELECT active_generation FROM project_identity WHERE singleton = 1")
7816 {
7817 Some(DetailedRelationTraceStatement::ActiveGraphGeneration)
7818 } else if statement.contains("FROM graph_relations AS relation INDEXED BY")
7819 && statement.contains("idx_graph_relations_source_kind")
7820 {
7821 Some(DetailedRelationTraceStatement::AdjacencyRelations)
7822 } else if statement
7823 .contains("JOIN graph_relations AS relation INDEXED BY idx_graph_relations_project_key")
7824 {
7825 Some(DetailedRelationTraceStatement::RelationsByDigest)
7826 } else if statement.contains("JOIN graph_entities AS entity") {
7827 Some(DetailedRelationTraceStatement::RelationEntities)
7828 } else if statement.contains("FROM graph_relation_occurrences") {
7829 Some(DetailedRelationTraceStatement::RelationOccurrences)
7830 } else if statement.contains("FROM nodes n")
7831 && statement.contains("JOIN purposes p")
7832 && statement.contains("LEFT JOIN summaries s")
7833 {
7834 Some(DetailedRelationTraceStatement::PurposeOwners)
7835 } else {
7836 None
7837 }
7838 }
7839
7840 fn require_traced_statement_multiset(
7842 statements: &[String],
7843 expected: &[(DetailedRelationTraceStatement, usize)],
7844 context: &str,
7845 ) -> Result<(), Box<dyn Error>> {
7846 let mut actual = BTreeMap::new();
7847 for statement in statements {
7848 let family = classify_detailed_relation_statement(statement).ok_or_else(|| {
7849 io::Error::other(format!(
7850 "{context} executed an unclassified production statement: {statement}"
7851 ))
7852 })?;
7853 *actual.entry(family).or_insert(0) += 1;
7854 }
7855 let expected = expected.iter().copied().collect::<BTreeMap<_, _>>();
7856 require_eq(
7857 &actual,
7858 &expected,
7859 &format!("{context} complete statement multiset: {statements:?}"),
7860 )
7861 }
7862
7863 fn collect_bounded_outbound_calls(
7865 store: &AtlasStore,
7866 anchor: &GraphEntityKey,
7867 ) -> Result<(Vec<[u8; 32]>, usize, usize), Box<dyn Error>> {
7868 let mut visited = BTreeMap::new();
7869 visited.insert(anchor.digest_bytes()?, anchor.clone());
7870 let mut frontier = vec![anchor.clone()];
7871 let mut inspected_edges = 0_usize;
7872 let mut peak_frontier = frontier.len();
7873 let calls = GraphRelationKind::Legacy(RelationKind::Calls);
7874
7875 while !frontier.is_empty() {
7876 let mut next = BTreeMap::new();
7877 for chunk in frontier.chunks(MAX_REPOSITORY_GRAPH_FRONTIER) {
7878 let work_per_row = MAX_REPOSITORY_GRAPH_ADJACENCY_WORK_ROWS / chunk.len();
7879 let page_limit = work_per_row
7880 .saturating_sub(1)
7881 .min(GraphLimits::MAX_ROWS as usize)
7882 .max(1) as u32;
7883 let mut continuation = None;
7884 loop {
7885 let page = store.repository_graph_adjacency_page_filtered(
7886 chunk,
7887 RepositoryGraphDirection::Outbound,
7888 Some(calls),
7889 continuation.as_ref(),
7890 page_limit,
7891 None,
7892 )?;
7893 inspected_edges = inspected_edges
7894 .checked_add(page.rows.len())
7895 .ok_or_else(|| io::Error::other("measured edge count overflowed"))?;
7896 for row in page.rows {
7897 if let Some(target) = row.detail.target {
7898 let digest = target.key().digest_bytes()?;
7899 if !visited.contains_key(&digest) {
7900 next.entry(digest).or_insert_with(|| target.key().clone());
7901 }
7902 }
7903 }
7904 if !page.truncated {
7905 break;
7906 }
7907 continuation = Some(page.continuation.ok_or_else(|| {
7908 io::Error::other("truncated adjacency page omitted its continuation")
7909 })?);
7910 }
7911 }
7912 frontier = next.into_values().collect();
7913 peak_frontier = peak_frontier.max(frontier.len());
7914 for key in &frontier {
7915 visited.insert(key.digest_bytes()?, key.clone());
7916 }
7917 }
7918
7919 Ok((
7920 visited.into_keys().collect(),
7921 inspected_edges,
7922 peak_frontier,
7923 ))
7924 }
7925
7926 struct GraphFixture {
7928 project: ProjectInstanceId,
7930 entities: Vec<GraphEntity>,
7932 relations: Vec<LogicalRelation>,
7934 occurrences: Vec<RelationOccurrence>,
7936 coverage: Vec<CoverageRecord>,
7938 }
7939
7940 struct DetailedRelationTraceFixture {
7942 project: ProjectInstanceId,
7944 source: GraphEntity,
7946 relations: Vec<LogicalRelation>,
7948 }
7949
7950 struct ResolutionFixture {
7952 graph: GraphFixture,
7954 resolved: CanonicalResolutionKey,
7956 ambiguous: CanonicalResolutionKey,
7958 unresolved: CanonicalResolutionKey,
7960 }
7961
7962 fn graph_fixture(
7964 project: ProjectInstanceId,
7965 generation: IndexGeneration,
7966 ) -> Result<GraphFixture, Box<dyn Error>> {
7967 let project_entity = GraphEntity::new(project, EntitySelector::Project, generation)?;
7968 let folder = GraphEntity::new(
7969 project,
7970 EntitySelector::Folder {
7971 path: RepositoryNodePath::new(Path::new("src"))?,
7972 },
7973 generation,
7974 )?;
7975 let file = GraphEntity::new(
7976 project,
7977 EntitySelector::File {
7978 path: RepositoryFilePath::new(Path::new("src/Äuth.rs"))?,
7979 },
7980 generation,
7981 )?;
7982 let package = GraphEntity::new(
7983 project,
7984 EntitySelector::Package {
7985 package: PackageSelector {
7986 manager: GraphIdentityText::new("cargo")?,
7987 name: GraphIdentityText::new("ProjectAtlas")?,
7988 manifest: RepositoryFilePath::new(Path::new("Cargo.toml"))?,
7989 },
7990 },
7991 generation,
7992 )?;
7993 let symbol = GraphEntity::new(
7994 project,
7995 EntitySelector::Symbol {
7996 symbol: SymbolSelector {
7997 file: RepositoryFilePath::new(Path::new("src/Äuth.rs"))?,
7998 name: GraphIdentityText::new("verifyToken")?,
7999 kind: SymbolKind::Function,
8000 parent: Some(GraphIdentityText::new("Auth")?),
8001 signature: GraphIdentityText::new("verifyToken(&str)")?,
8002 },
8003 },
8004 generation,
8005 )?;
8006 let external = GraphEntity::new(
8007 project,
8008 EntitySelector::External {
8009 external: ExternalSelector {
8010 system: GraphIdentityText::new("crates.io")?,
8011 identity: GraphIdentityText::new("serde@1")?,
8012 },
8013 },
8014 generation,
8015 )?;
8016
8017 let resolved = LogicalRelation::new(
8018 &file,
8019 GraphRelationKind::Legacy(RelationKind::Calls),
8020 RelationResolution::resolved(&symbol)?,
8021 ConfidenceClass::Exact,
8022 Completeness::Complete,
8023 generation,
8024 )?;
8025 let ambiguous = LogicalRelation::new(
8026 &file,
8027 GraphRelationKind::Extended(ExtendedRelationKind::References),
8028 RelationResolution::Ambiguous {
8029 reference: GraphIdentityText::new("Session")?,
8030 candidates: NonZeroU32::new(2)
8031 .ok_or_else(|| io::Error::other("fixture candidate count is zero"))?,
8032 },
8033 ConfidenceClass::Medium,
8034 Completeness::Partial,
8035 generation,
8036 )?;
8037 let unresolved = LogicalRelation::new(
8038 &file,
8039 GraphRelationKind::Extended(ExtendedRelationKind::Configures),
8040 RelationResolution::Unresolved {
8041 reference: GraphIdentityText::new("AUTH_KEY")?,
8042 },
8043 ConfidenceClass::Low,
8044 Completeness::Partial,
8045 generation,
8046 )?;
8047 let external_relation = LogicalRelation::new(
8048 &file,
8049 GraphRelationKind::Legacy(RelationKind::DependsOn),
8050 RelationResolution::external(&external)?,
8051 ConfidenceClass::High,
8052 Completeness::Complete,
8053 generation,
8054 )?;
8055 let occurrences = vec![
8056 RelationOccurrence::new(
8057 &resolved,
8058 RepositoryFilePath::new(Path::new("src/Äuth.rs"))?,
8059 SourceSpan::new(10, 4, 10, 18)?,
8060 generation,
8061 )?,
8062 RelationOccurrence::new(
8063 &resolved,
8064 RepositoryFilePath::new(Path::new("src/Äuth.rs"))?,
8065 SourceSpan::new(22, 2, 22, 16)?,
8066 generation,
8067 )?,
8068 ];
8069 let coverage = vec![
8070 CoverageRecord::new(
8071 CoverageScope::Project,
8072 None,
8073 CoverageState::Complete,
8074 4,
8075 0,
8076 generation,
8077 None,
8078 None,
8079 )?,
8080 CoverageRecord::new(
8081 CoverageScope::Project,
8082 Some(GraphRelationKind::Extended(ExtendedRelationKind::Documents)),
8083 CoverageState::NoCandidates,
8084 0,
8085 0,
8086 generation,
8087 None,
8088 None,
8089 )?,
8090 CoverageRecord::new(
8091 CoverageScope::Project,
8092 Some(GraphRelationKind::Extended(ExtendedRelationKind::Tests)),
8093 CoverageState::Complete,
8094 0,
8095 0,
8096 generation,
8097 None,
8098 None,
8099 )?,
8100 CoverageRecord::new(
8101 CoverageScope::Path {
8102 path: RepositoryNodePath::new(Path::new("src"))?,
8103 },
8104 None,
8105 CoverageState::Partial,
8106 3,
8107 1,
8108 generation,
8109 Some(GraphIdentityText::new("one parser region omitted")?),
8110 Some(GraphLimitKind::Rows),
8111 )?,
8112 incomplete_coverage(
8113 GraphRelationKind::Legacy(RelationKind::Calls),
8114 CoverageState::Failed,
8115 "parser failed",
8116 generation,
8117 )?,
8118 incomplete_coverage(
8119 GraphRelationKind::Legacy(RelationKind::Imports),
8120 CoverageState::Ignored,
8121 "ignored by policy",
8122 generation,
8123 )?,
8124 incomplete_coverage(
8125 GraphRelationKind::Legacy(RelationKind::Contains),
8126 CoverageState::Oversized,
8127 "file exceeded limit",
8128 generation,
8129 )?,
8130 incomplete_coverage(
8131 GraphRelationKind::Legacy(RelationKind::DependsOn),
8132 CoverageState::Quarantined,
8133 "provider quarantined",
8134 generation,
8135 )?,
8136 incomplete_coverage(
8137 GraphRelationKind::Extended(ExtendedRelationKind::References),
8138 CoverageState::Stale,
8139 "source changed",
8140 generation,
8141 )?,
8142 ];
8143 Ok(GraphFixture {
8144 project,
8145 entities: vec![project_entity, folder, file, package, symbol, external],
8146 relations: vec![resolved, ambiguous, unresolved, external_relation],
8147 occurrences,
8148 coverage,
8149 })
8150 }
8151
8152 fn declaration_key(
8154 project: ProjectInstanceId,
8155 identity: &str,
8156 relation: GraphRelationKind,
8157 ) -> Result<CanonicalResolutionKey, Box<dyn Error>> {
8158 Ok(CanonicalResolutionKey::new(
8159 project,
8160 ResolutionKeyDomain::Declaration,
8161 &GraphIdentityText::new("tree-sitter")?,
8162 &GraphIdentityText::new("rust")?,
8163 None,
8164 Some(&GraphIdentityText::new("crate")?),
8165 Some(relation),
8166 &GraphIdentityText::new(identity)?,
8167 ))
8168 }
8169
8170 fn publish_resolution_fixture(
8172 store: &mut AtlasStore,
8173 fingerprint: &str,
8174 ) -> Result<ResolutionFixture, Box<dyn Error>> {
8175 let project = store
8176 .project_instance_id()?
8177 .ok_or_else(|| io::Error::other("bound fixture identity is missing"))?;
8178 let graph = graph_fixture(project, IndexGeneration::new(1))?;
8179 let resolved = declaration_key(
8180 project,
8181 "verifyToken",
8182 GraphRelationKind::Legacy(RelationKind::Calls),
8183 )?;
8184 let ambiguous = declaration_key(
8185 project,
8186 "Session",
8187 GraphRelationKind::Extended(ExtendedRelationKind::References),
8188 )?;
8189 let unresolved = declaration_key(
8190 project,
8191 "AUTH_KEY",
8192 GraphRelationKind::Extended(ExtendedRelationKind::Configures),
8193 )?;
8194 let export = EntityResolutionKey::new(graph.entities[4].key().clone(), resolved.clone())?;
8195 let exports = vec![export.clone(), export];
8196 let dependencies = vec![
8197 RelationDependencyKey::new(graph.relations[0].key().clone(), resolved.clone())?,
8198 RelationDependencyKey::new(graph.relations[1].key().clone(), ambiguous.clone())?,
8199 RelationDependencyKey::new(graph.relations[2].key().clone(), unresolved.clone())?,
8200 ];
8201 let mut publication = store.begin_index_publication(fingerprint)?;
8202 publication.begin_scan_replacement()?;
8203 publication.upsert_scan_node_batch(&[
8204 graph_node(".", NodeKind::Folder, None),
8205 graph_node("src", NodeKind::Folder, Some(".")),
8206 graph_node("src/Äuth.rs", NodeKind::File, Some("src")),
8207 graph_node("Cargo.toml", NodeKind::File, Some(".")),
8208 ])?;
8209 publication.finish_scan_replacement()?;
8210 publication.replace_repository_graph_with_resolution_keys(
8211 graph.project,
8212 &graph.entities,
8213 &graph.relations,
8214 &graph.occurrences,
8215 &graph.coverage,
8216 &exports,
8217 &dependencies,
8218 )?;
8219 publication.complete()?;
8220 Ok(ResolutionFixture {
8221 graph,
8222 resolved,
8223 ambiguous,
8224 unresolved,
8225 })
8226 }
8227
8228 fn replace_fixture_symbol_rows(
8230 store: &mut AtlasStore,
8231 path: &str,
8232 symbol_count: usize,
8233 ) -> Result<(), Box<dyn Error>> {
8234 let symbols = (0..symbol_count)
8235 .map(|index| CodeSymbol {
8236 path: path.to_string(),
8237 language: Some("rust".to_string()),
8238 name: format!("symbol_{index}"),
8239 kind: SymbolKind::Function,
8240 signature: format!("fn symbol_{index}()"),
8241 exported: index == 0,
8242 documentation: None,
8243 line_start: index + 1,
8244 line_end: index + 1,
8245 source_selector: None,
8246 parent: None,
8247 parser: ParserKind::TreeSitter,
8248 detail: Some("function_item".to_string()),
8249 })
8250 .collect();
8251 store.replace_symbol_graph(&SymbolGraph {
8252 path: path.to_string(),
8253 language: Some("rust".to_string()),
8254 parser: ParserKind::TreeSitter,
8255 symbols,
8256 relations: vec![SymbolRelation {
8257 path: path.to_string(),
8258 source_name: "symbol_0".to_string(),
8259 target_name: "dependency".to_string(),
8260 kind: RelationKind::Calls,
8261 line: 1,
8262 context: "dependency()".to_string(),
8263 parser: ParserKind::TreeSitter,
8264 }],
8265 })?;
8266 Ok(())
8267 }
8268
8269 fn incomplete_coverage(
8271 relation: GraphRelationKind,
8272 state: CoverageState,
8273 reason: &str,
8274 generation: IndexGeneration,
8275 ) -> Result<CoverageRecord, Box<dyn Error>> {
8276 Ok(CoverageRecord::new(
8277 CoverageScope::Project,
8278 Some(relation),
8279 state,
8280 0,
8281 1,
8282 generation,
8283 Some(GraphIdentityText::new(reason)?),
8284 None,
8285 )?)
8286 }
8287
8288 fn graph_node(path: &str, kind: NodeKind, parent_path: Option<&str>) -> Node {
8290 Node {
8291 path: path.to_string(),
8292 kind,
8293 parent_path: parent_path.map(str::to_string),
8294 extension: None,
8295 language: None,
8296 size_bytes: None,
8297 mtime_ns: None,
8298 content_hash: None,
8299 }
8300 }
8301
8302 struct NavigationFixture {
8304 project: ProjectInstanceId,
8306 api_path: String,
8308 manifest_path: String,
8310 }
8311
8312 fn publish_navigation_fixture(
8314 store: &mut AtlasStore,
8315 fingerprint: &str,
8316 ) -> Result<NavigationFixture, Box<dyn Error>> {
8317 let project = store
8318 .project_instance_id()?
8319 .ok_or_else(|| io::Error::other("navigation fixture identity is missing"))?;
8320 let generation = IndexGeneration::new(1);
8321 let api_path = "src/auth/api.rs".to_string();
8322 let manifest_path = "Cargo.toml".to_string();
8323 let mut nodes = vec![
8324 graph_node(".", NodeKind::Folder, None),
8325 graph_node("src", NodeKind::Folder, Some(".")),
8326 graph_node("src/auth", NodeKind::Folder, Some("src")),
8327 graph_node(&api_path, NodeKind::File, Some("src/auth")),
8328 graph_node("src/auth/caller.rs", NodeKind::File, Some("src/auth")),
8329 graph_node("src/other.rs", NodeKind::File, Some("src")),
8330 graph_node("src/authz.rs", NodeKind::File, Some("src")),
8331 graph_node("tests", NodeKind::Folder, Some(".")),
8332 graph_node("tests/api_test.rs", NodeKind::File, Some("tests")),
8333 graph_node("clients", NodeKind::Folder, Some(".")),
8334 graph_node(&manifest_path, NodeKind::File, Some(".")),
8335 ];
8336 let api = GraphEntity::new(
8337 project,
8338 EntitySelector::File {
8339 path: RepositoryFilePath::new(Path::new(&api_path))?,
8340 },
8341 generation,
8342 )?;
8343 let internal_caller = GraphEntity::new(
8344 project,
8345 EntitySelector::File {
8346 path: RepositoryFilePath::new(Path::new("src/auth/caller.rs"))?,
8347 },
8348 generation,
8349 )?;
8350 let other = GraphEntity::new(
8351 project,
8352 EntitySelector::File {
8353 path: RepositoryFilePath::new(Path::new("src/other.rs"))?,
8354 },
8355 generation,
8356 )?;
8357 let sibling = GraphEntity::new(
8358 project,
8359 EntitySelector::File {
8360 path: RepositoryFilePath::new(Path::new("src/authz.rs"))?,
8361 },
8362 generation,
8363 )?;
8364 let test = GraphEntity::new(
8365 project,
8366 EntitySelector::File {
8367 path: RepositoryFilePath::new(Path::new("tests/api_test.rs"))?,
8368 },
8369 generation,
8370 )?;
8371 let package = GraphEntity::new(
8372 project,
8373 EntitySelector::Package {
8374 package: PackageSelector {
8375 manager: GraphIdentityText::new("cargo")?,
8376 name: GraphIdentityText::new("projectatlas-navigation")?,
8377 manifest: RepositoryFilePath::new(Path::new(&manifest_path))?,
8378 },
8379 },
8380 generation,
8381 )?;
8382 let mut entities = vec![
8383 api.clone(),
8384 internal_caller.clone(),
8385 other.clone(),
8386 sibling.clone(),
8387 test.clone(),
8388 package.clone(),
8389 ];
8390 let mut relations = vec![
8391 LogicalRelation::new(
8392 &api,
8393 GraphRelationKind::Legacy(RelationKind::DependsOn),
8394 RelationResolution::resolved(&package)?,
8395 ConfidenceClass::Exact,
8396 Completeness::Complete,
8397 generation,
8398 )?,
8399 LogicalRelation::new(
8400 &api,
8401 GraphRelationKind::Legacy(RelationKind::Imports),
8402 RelationResolution::resolved(&other)?,
8403 ConfidenceClass::Exact,
8404 Completeness::Complete,
8405 generation,
8406 )?,
8407 LogicalRelation::new(
8408 &api,
8409 GraphRelationKind::Legacy(RelationKind::Calls),
8410 RelationResolution::resolved(&other)?,
8411 ConfidenceClass::Exact,
8412 Completeness::Complete,
8413 generation,
8414 )?,
8415 LogicalRelation::new(
8416 &api,
8417 GraphRelationKind::Extended(ExtendedRelationKind::References),
8418 RelationResolution::Unresolved {
8419 reference: GraphIdentityText::new("SessionStore")?,
8420 },
8421 ConfidenceClass::High,
8422 Completeness::Partial,
8423 generation,
8424 )?,
8425 LogicalRelation::new(
8426 &test,
8427 GraphRelationKind::Extended(ExtendedRelationKind::Tests),
8428 RelationResolution::resolved(&api)?,
8429 ConfidenceClass::Exact,
8430 Completeness::Complete,
8431 generation,
8432 )?,
8433 LogicalRelation::new(
8434 &api,
8435 GraphRelationKind::Extended(ExtendedRelationKind::RoutesTo),
8436 RelationResolution::resolved(&other)?,
8437 ConfidenceClass::High,
8438 Completeness::Complete,
8439 generation,
8440 )?,
8441 LogicalRelation::new(
8442 &api,
8443 GraphRelationKind::Extended(ExtendedRelationKind::Configures),
8444 RelationResolution::Unresolved {
8445 reference: GraphIdentityText::new("AUTH_MODE")?,
8446 },
8447 ConfidenceClass::High,
8448 Completeness::Partial,
8449 generation,
8450 )?,
8451 LogicalRelation::new(
8452 &internal_caller,
8453 GraphRelationKind::Legacy(RelationKind::Calls),
8454 RelationResolution::resolved(&api)?,
8455 ConfidenceClass::Exact,
8456 Completeness::Complete,
8457 generation,
8458 )?,
8459 LogicalRelation::new(
8460 &sibling,
8461 GraphRelationKind::Legacy(RelationKind::Imports),
8462 RelationResolution::resolved(&other)?,
8463 ConfidenceClass::Exact,
8464 Completeness::Complete,
8465 generation,
8466 )?,
8467 ];
8468 for index in 0..4 {
8469 let path = format!("clients/caller-{index}.rs");
8470 nodes.push(graph_node(&path, NodeKind::File, Some("clients")));
8471 let caller = GraphEntity::new(
8472 project,
8473 EntitySelector::File {
8474 path: RepositoryFilePath::new(Path::new(&path))?,
8475 },
8476 generation,
8477 )?;
8478 relations.push(LogicalRelation::new(
8479 &caller,
8480 GraphRelationKind::Legacy(RelationKind::Calls),
8481 RelationResolution::resolved(&api)?,
8482 ConfidenceClass::Exact,
8483 Completeness::Complete,
8484 generation,
8485 )?);
8486 entities.push(caller);
8487 }
8488
8489 let mut publication = store.begin_index_publication(fingerprint)?;
8490 publication.begin_scan_replacement()?;
8491 publication.upsert_scan_node_batch(&nodes)?;
8492 publication.finish_scan_replacement()?;
8493 publication.replace_repository_graph(project, &entities, &relations, &[], &[])?;
8494 publication.complete()?;
8495 Ok(NavigationFixture {
8496 project,
8497 api_path,
8498 manifest_path,
8499 })
8500 }
8501
8502 fn require(condition: bool, message: &str) -> Result<(), Box<dyn Error>> {
8504 if condition {
8505 Ok(())
8506 } else {
8507 Err(io::Error::other(message.to_string()).into())
8508 }
8509 }
8510
8511 fn require_eq<T: Debug + PartialEq>(
8513 actual: &T,
8514 expected: &T,
8515 context: &str,
8516 ) -> Result<(), Box<dyn Error>> {
8517 require(
8518 actual == expected,
8519 &format!("{context}: expected {expected:?}, found {actual:?}"),
8520 )
8521 }
8522
8523 fn require_db_error<T>(result: DbResult<T>, message: &str) -> Result<DbError, Box<dyn Error>> {
8525 let Err(error) = result else {
8526 return Err(io::Error::other(message.to_string()).into());
8527 };
8528 Ok(error)
8529 }
8530
8531 fn assert_cursor_hydration_indexes(store: &AtlasStore) -> Result<(), Box<dyn Error>> {
8533 let project = store
8534 .project_instance_id()?
8535 .ok_or_else(|| io::Error::other("bound fixture identity is missing"))?;
8536 let cases = [
8537 (
8538 "entity cursor hydration",
8539 graph_entity_hydration_sql(2),
8540 vec![
8541 Value::Blob(vec![0; 32]),
8542 Value::Blob(vec![1; 32]),
8543 Value::Blob(project.as_bytes().to_vec()),
8544 ],
8545 "project_instance_id=? AND entity_key=?",
8546 "SCAN entity",
8547 false,
8548 ),
8549 (
8550 "relation cursor hydration",
8551 graph_relation_hydration_sql(2),
8552 vec![
8553 Value::Blob(vec![0; 32]),
8554 Value::Blob(vec![1; 32]),
8555 Value::Blob(project.as_bytes().to_vec()),
8556 ],
8557 "idx_graph_relations_project_key",
8558 "SCAN relation",
8559 false,
8560 ),
8561 (
8562 "batched occurrence hydration",
8563 occurrence_pages_sql(2),
8564 vec![
8565 Value::Blob(vec![0; 32]),
8566 Value::Integer(2),
8567 Value::Blob(vec![1; 32]),
8568 Value::Integer(2),
8569 ],
8570 "sqlite_autoindex_graph_relation_occurrences_1",
8571 "SCAN graph_relation_occurrences",
8572 true,
8573 ),
8574 (
8575 "batched path coverage hydration",
8576 path_coverage_sql(2),
8577 vec![
8578 Value::Blob(project.as_bytes().to_vec()),
8579 Value::Text("path".to_string()),
8580 Value::Text("Cargo.toml".to_string()),
8581 Value::Text("src/Äuth.rs".to_string()),
8582 Value::Integer(i64::from(GraphLimits::MAX_ROWS) + 1),
8583 ],
8584 "idx_graph_coverage_scope_order",
8585 "SCAN graph_coverage",
8586 false,
8587 ),
8588 ];
8589 for (context, sql, bindings, required_plan, forbidden_scan, allow_bounded_sort) in cases {
8590 let mut statement = store
8591 .connection
8592 .prepare(&format!("EXPLAIN QUERY PLAN {sql}"))?;
8593 let details = statement
8594 .query_map(params_from_iter(bindings.iter()), |row| {
8595 row.get::<_, String>(3)
8596 })?
8597 .collect::<Result<Vec<_>, _>>()?;
8598 require(
8599 details.iter().any(|detail| detail.contains(required_plan))
8600 && details.iter().all(|detail| {
8601 !detail.contains(forbidden_scan)
8602 && (allow_bounded_sort || !detail.contains("USE TEMP B-TREE"))
8603 }),
8604 &format!(
8605 "{context} did not use {required_plan} without a scan or sort: {details:?}"
8606 ),
8607 )?;
8608 }
8609 Ok(())
8610 }
8611
8612 fn assert_query_indexes(store: &AtlasStore) -> Result<(), Box<dyn Error>> {
8614 let cases: &[(&str, &str, &[&str])] = &[
8615 (
8616 "entity path lookup",
8617 "EXPLAIN QUERY PLAN
8618 SELECT entity_key FROM graph_entities
8619 WHERE project_instance_id = zeroblob(16)
8620 AND repository_path = 'src/Äuth.rs'
8621 ORDER BY entity_kind, entity_key
8622 LIMIT 11",
8623 &["idx_graph_entities_path"],
8624 ),
8625 (
8626 "outbound relation lookup",
8627 "EXPLAIN QUERY PLAN
8628 SELECT relation_key FROM graph_relations
8629 WHERE source_entity_key = zeroblob(32)
8630 ORDER BY relation_scope, relation_kind, relation_key
8631 LIMIT 11",
8632 &["idx_graph_relations_source_kind"],
8633 ),
8634 (
8635 "inbound relation lookup",
8636 "EXPLAIN QUERY PLAN
8637 SELECT relation_key FROM graph_relations
8638 WHERE target_entity_key = zeroblob(32)
8639 ORDER BY relation_scope, relation_kind, relation_key
8640 LIMIT 11",
8641 &["idx_graph_relations_target_kind"],
8642 ),
8643 (
8644 "inbound documents lookup",
8645 "EXPLAIN QUERY PLAN
8646 SELECT relation_key FROM graph_relations
8647 WHERE target_entity_key = zeroblob(32)
8648 AND relation_scope = 'extended'
8649 AND relation_kind = 'documents'
8650 ORDER BY relation_scope, relation_kind, relation_key
8651 LIMIT 11",
8652 &["idx_graph_relations_target_kind"],
8653 ),
8654 (
8655 "relation family lookup",
8656 "EXPLAIN QUERY PLAN
8657 SELECT relation_key FROM graph_relations
8658 WHERE project_instance_id = zeroblob(16)
8659 AND relation_scope = 'legacy'
8660 AND relation_kind = 'calls'
8661 ORDER BY relation_key
8662 LIMIT 11",
8663 &["idx_graph_relations_kind_order"],
8664 ),
8665 (
8666 "relation occurrence lookup",
8667 "EXPLAIN QUERY PLAN
8668 SELECT file_path FROM graph_relation_occurrences
8669 WHERE relation_key = zeroblob(32)
8670 ORDER BY file_path, start_line, start_column, end_line, end_column
8671 LIMIT 11",
8672 &["sqlite_autoindex_graph_relation_occurrences_1"],
8673 ),
8674 (
8675 "occurrence path invalidation",
8676 "EXPLAIN QUERY PLAN
8677 SELECT relation_key FROM graph_relation_occurrences
8678 WHERE file_path = 'src'
8679 OR (file_path >= 'src/' AND file_path < 'src0')",
8680 &["idx_graph_occurrences_file_span"],
8681 ),
8682 (
8683 "coverage path invalidation",
8684 "EXPLAIN QUERY PLAN
8685 SELECT id FROM graph_coverage
8686 INDEXED BY idx_graph_coverage_path
8687 WHERE scope_kind = 'path'
8688 AND (scope_path = 'src'
8689 OR (scope_path >= 'src/' AND scope_path < 'src0'))",
8690 &["idx_graph_coverage_path"],
8691 ),
8692 (
8693 "entity repository-path invalidation",
8694 "EXPLAIN QUERY PLAN
8695 SELECT entity_key FROM graph_entities
8696 WHERE repository_path = 'src'
8697 OR (repository_path >= 'src/' AND repository_path < 'src0')",
8698 &["idx_graph_entities_path"],
8699 ),
8700 (
8701 "entity manifest-path invalidation",
8702 "EXPLAIN QUERY PLAN
8703 SELECT entity_key FROM graph_entities
8704 WHERE manifest_path = 'src'
8705 OR (manifest_path >= 'src/' AND manifest_path < 'src0')",
8706 &["idx_graph_entities_manifest_path"],
8707 ),
8708 (
8709 "outbound external cleanup candidate",
8710 "EXPLAIN QUERY PLAN
8711 SELECT relation.target_entity_key
8712 FROM graph_relations AS relation
8713 INDEXED BY idx_graph_relations_source_kind
8714 JOIN graph_entities AS external
8715 ON external.entity_key = relation.target_entity_key
8716 WHERE relation.source_entity_key = zeroblob(32)
8717 AND external.entity_kind = 'external'",
8718 &["idx_graph_relations_source_kind"],
8719 ),
8720 (
8721 "inbound external cleanup candidate",
8722 "EXPLAIN QUERY PLAN
8723 SELECT relation.source_entity_key
8724 FROM graph_relations AS relation
8725 INDEXED BY idx_graph_relations_target_kind
8726 JOIN graph_entities AS external
8727 ON external.entity_key = relation.source_entity_key
8728 WHERE relation.target_entity_key = zeroblob(32)
8729 AND external.entity_kind = 'external'",
8730 &["idx_graph_relations_target_kind"],
8731 ),
8732 (
8733 "candidate-bounded external cleanup",
8734 "EXPLAIN QUERY PLAN
8735 DELETE FROM graph_entities
8736 WHERE entity_key = zeroblob(32) AND entity_kind = 'external'
8737 AND NOT EXISTS (
8738 SELECT 1 FROM graph_relations
8739 INDEXED BY idx_graph_relations_source_kind
8740 WHERE source_entity_key = zeroblob(32)
8741 )
8742 AND NOT EXISTS (
8743 SELECT 1 FROM graph_relations
8744 INDEXED BY idx_graph_relations_target_kind
8745 WHERE target_entity_key = zeroblob(32)
8746 )",
8747 &[
8748 "sqlite_autoindex_graph_entities_1",
8749 "idx_graph_relations_source_kind",
8750 "idx_graph_relations_target_kind",
8751 ],
8752 ),
8753 (
8754 "coverage scope lookup",
8755 "EXPLAIN QUERY PLAN
8756 SELECT id FROM graph_coverage
8757 WHERE project_instance_id = zeroblob(16)
8758 AND scope_kind = 'path'
8759 AND scope_path IS 'src'
8760 ORDER BY relation_scope, relation_kind, state, id
8761 LIMIT 11",
8762 &["idx_graph_coverage_scope_order"],
8763 ),
8764 (
8765 "resolution witness lookup",
8766 "EXPLAIN QUERY PLAN
8767 SELECT canonical_identity FROM graph_resolution_keys
8768 WHERE project_instance_id = zeroblob(16)
8769 AND resolution_domain = 'declaration'
8770 AND key_digest = zeroblob(32)",
8771 &["sqlite_autoindex_graph_resolution_keys_1"],
8772 ),
8773 (
8774 "resolution export lookup",
8775 "EXPLAIN QUERY PLAN
8776 SELECT entity_key FROM graph_entity_exports
8777 WHERE project_instance_id = zeroblob(16)
8778 AND resolution_domain = 'declaration'
8779 AND key_digest = zeroblob(32)
8780 ORDER BY entity_key",
8781 &["idx_graph_entity_exports_key"],
8782 ),
8783 (
8784 "resolution export owner lookup",
8785 "EXPLAIN QUERY PLAN
8786 SELECT resolution_domain, key_digest, entity_key
8787 FROM graph_entity_exports
8788 WHERE project_instance_id = zeroblob(16)
8789 AND owner_path = 'src/Äuth.rs'
8790 ORDER BY resolution_domain, key_digest, entity_key",
8791 &["idx_graph_entity_exports_owner"],
8792 ),
8793 (
8794 "resolution dependency lookup",
8795 "EXPLAIN QUERY PLAN
8796 SELECT owner_path, relation_key FROM graph_relation_dependencies
8797 WHERE project_instance_id = zeroblob(16)
8798 AND resolution_domain = 'declaration'
8799 AND key_digest = zeroblob(32)
8800 ORDER BY owner_path, relation_key",
8801 &["idx_graph_relation_dependencies_key"],
8802 ),
8803 (
8804 "resolution dependency owner lookup",
8805 "EXPLAIN QUERY PLAN
8806 SELECT resolution_domain, key_digest, relation_key
8807 FROM graph_relation_dependencies
8808 WHERE project_instance_id = zeroblob(16)
8809 AND owner_path = 'src/Äuth.rs'
8810 ORDER BY resolution_domain, key_digest, relation_key",
8811 &["idx_graph_relation_dependencies_owner"],
8812 ),
8813 (
8814 "relation composite integrity lookup",
8815 "EXPLAIN QUERY PLAN
8816 SELECT relation_key FROM graph_relations
8817 WHERE project_instance_id = zeroblob(16)
8818 AND relation_key = zeroblob(32)",
8819 &["idx_graph_relations_project_key"],
8820 ),
8821 (
8822 "document unresolved reason validation",
8823 "EXPLAIN QUERY PLAN
8824 SELECT EXISTS(
8825 SELECT 1
8826 FROM graph_relations
8827 INDEXED BY idx_graph_relations_project_key
8828 WHERE relation_key = zeroblob(32)
8829 AND project_instance_id = zeroblob(16)
8830 AND relation_scope = 'extended'
8831 AND relation_kind = 'documents'
8832 AND resolution_status = 'unresolved'
8833 )",
8834 &["idx_graph_relations_project_key"],
8835 ),
8836 ];
8837
8838 for (context, sql, required_indexes) in cases {
8839 let mut statement = store.connection.prepare(sql)?;
8840 let details = statement
8841 .query_map([], |row| row.get::<_, String>(3))?
8842 .collect::<Result<Vec<_>, _>>()?;
8843 require(
8844 required_indexes
8845 .iter()
8846 .all(|index| details.iter().any(|detail| detail.contains(index))),
8847 &format!("{context} did not use {required_indexes:?}; query plan was {details:?}"),
8848 )?;
8849 require(
8850 details.iter().all(|detail| {
8851 !detail.contains("SCAN graph_") && !detail.contains("USE TEMP B-TREE")
8852 }),
8853 &format!("{context} was not bounded by index order: {details:?}"),
8854 )?;
8855 }
8856
8857 for key_count in [2, EXTERNAL_CANDIDATE_KEYS_PER_QUERY] {
8858 let sql = format!(
8859 "EXPLAIN QUERY PLAN {}",
8860 external_candidate_batch_sql(key_count)
8861 );
8862 let bindings = (0..key_count)
8863 .map(|_| Value::Blob(vec![0_u8; 32]))
8864 .collect::<Vec<_>>();
8865 let mut statement = store.connection.prepare(&sql)?;
8866 let details = statement
8867 .query_map(params_from_iter(bindings.iter()), |row| {
8868 row.get::<_, String>(3)
8869 })?
8870 .collect::<Result<Vec<_>, _>>()?;
8871 require(
8872 [
8873 "SEARCH relation USING INDEX idx_graph_relations_source_kind (source_entity_key=?)",
8874 "SEARCH relation USING INDEX idx_graph_relations_target_kind (target_entity_key=?)",
8875 ]
8876 .iter()
8877 .all(|seek| details.iter().any(|detail| detail.contains(seek)))
8878 && details.iter().all(|detail| {
8879 !detail.contains("SCAN relation")
8880 && !detail.contains("SCAN graph_relations")
8881 && !detail.contains("USE TEMP B-TREE")
8882 }),
8883 &format!(
8884 "{key_count}-key external cleanup batch was not driven by indexed point seeks: {details:?}"
8885 ),
8886 )?;
8887 }
8888 Ok(())
8889 }
8890
8891 fn assert_coverage_discovery_plan(
8893 connection: &Connection,
8894 sql: &str,
8895 values: &[Value],
8896 required_indexes: &[&str],
8897 allow_bounded_partial_sort: bool,
8898 context: &str,
8899 ) -> Result<(), Box<dyn Error>> {
8900 let mut statement = connection.prepare(sql)?;
8901 let details = statement
8902 .query_map(params_from_iter(values.iter()), |row| {
8903 row.get::<_, String>(3)
8904 })?
8905 .collect::<Result<Vec<_>, _>>()?;
8906 require(
8907 required_indexes
8908 .iter()
8909 .all(|index| details.iter().any(|detail| detail.contains(index))),
8910 &format!("{context} did not use {required_indexes:?}; query plan was {details:?}"),
8911 )?;
8912 require(
8913 details.iter().all(|detail| {
8914 let uses_temporary_sort = detail.contains("USE TEMP B-TREE");
8915 let bounded_partial_sort = detail.contains("USE TEMP B-TREE FOR LAST");
8916 !detail.contains("SCAN coverage")
8917 && (!uses_temporary_sort
8918 || (allow_bounded_partial_sort && bounded_partial_sort))
8919 }),
8920 &format!("{context} used an unbounded scan or sort: {details:?}"),
8921 )?;
8922 Ok(())
8923 }
8924
8925 fn publish_detailed_relation_trace_fixture(
8927 store: &mut AtlasStore,
8928 fingerprint: &str,
8929 ) -> Result<DetailedRelationTraceFixture, Box<dyn Error>> {
8930 let project = store
8931 .project_instance_id()?
8932 .ok_or_else(|| io::Error::other("bound trace fixture identity is missing"))?;
8933 let generation = IndexGeneration::new(1);
8934 let source_path = RepositoryFilePath::new(Path::new("src/trace-source.rs"))?;
8935 let source = GraphEntity::new(
8936 project,
8937 EntitySelector::File {
8938 path: source_path.clone(),
8939 },
8940 generation,
8941 )?;
8942 let mut entities = Vec::with_capacity(MAX_REPOSITORY_GRAPH_FRONTIER + 1);
8943 let mut relations = Vec::with_capacity(MAX_REPOSITORY_GRAPH_FRONTIER);
8944 let mut occurrences = Vec::with_capacity(MAX_REPOSITORY_GRAPH_FRONTIER * 2);
8945 entities.push(source.clone());
8946 for index in 0..MAX_REPOSITORY_GRAPH_FRONTIER {
8947 let name = format!("trace_target_{index:03}");
8948 let target = GraphEntity::new(
8949 project,
8950 EntitySelector::Symbol {
8951 symbol: SymbolSelector {
8952 file: source_path.clone(),
8953 name: GraphIdentityText::new(&name)?,
8954 kind: SymbolKind::Function,
8955 parent: None,
8956 signature: GraphIdentityText::new(format!("{name}()"))?,
8957 },
8958 },
8959 generation,
8960 )?;
8961 let relation = LogicalRelation::new(
8962 &source,
8963 GraphRelationKind::Legacy(RelationKind::Calls),
8964 RelationResolution::resolved(&target)?,
8965 ConfidenceClass::Exact,
8966 Completeness::Complete,
8967 generation,
8968 )?;
8969 let first_line = u32::try_from(index * 2 + 1)?;
8970 let second_line = first_line + 1;
8971 occurrences.push(RelationOccurrence::new(
8972 &relation,
8973 source_path.clone(),
8974 SourceSpan::new(first_line, 1, first_line, 2)?,
8975 generation,
8976 )?);
8977 occurrences.push(RelationOccurrence::new(
8978 &relation,
8979 source_path.clone(),
8980 SourceSpan::new(second_line, 1, second_line, 2)?,
8981 generation,
8982 )?);
8983 entities.push(target);
8984 relations.push(relation);
8985 }
8986
8987 let mut publication = store.begin_index_publication(fingerprint)?;
8988 publication.begin_scan_replacement()?;
8989 publication.upsert_scan_node_batch(&[
8990 graph_node(".", NodeKind::Folder, None),
8991 graph_node("src", NodeKind::Folder, Some(".")),
8992 graph_node("src/trace-source.rs", NodeKind::File, Some("src")),
8993 ])?;
8994 publication.finish_scan_replacement()?;
8995 publication.replace_repository_graph(project, &entities, &relations, &occurrences, &[])?;
8996 publication.complete()?;
8997 Ok(DetailedRelationTraceFixture {
8998 project,
8999 source,
9000 relations,
9001 })
9002 }
9003
9004 fn publish_fixture(
9006 store: &mut AtlasStore,
9007 fingerprint: &str,
9008 ) -> Result<GraphFixture, Box<dyn Error>> {
9009 let project = store
9010 .project_instance_id()?
9011 .ok_or_else(|| io::Error::other("bound fixture identity is missing"))?;
9012 let mut fixture = graph_fixture(project, IndexGeneration::new(1))?;
9013 fixture.coverage.push(CoverageRecord::new(
9014 CoverageScope::Path {
9015 path: RepositoryNodePath::new(Path::new("src/Äuth.rs"))?,
9016 },
9017 None,
9018 CoverageState::Complete,
9019 1,
9020 0,
9021 IndexGeneration::new(1),
9022 None,
9023 None,
9024 )?);
9025 let mut occurrences = fixture.occurrences.clone();
9026 occurrences.push(fixture.occurrences[0].clone());
9027 let mut publication = store.begin_index_publication(fingerprint)?;
9028 publication.begin_scan_replacement()?;
9029 publication.upsert_scan_node_batch(&[
9030 graph_node(".", NodeKind::Folder, None),
9031 graph_node("src", NodeKind::Folder, Some(".")),
9032 graph_node("src/Äuth.rs", NodeKind::File, Some("src")),
9033 graph_node("Cargo.toml", NodeKind::File, Some(".")),
9034 ])?;
9035 publication.finish_scan_replacement()?;
9036 publication.replace_symbol_graph(&SymbolGraph {
9037 path: "src/Äuth.rs".to_string(),
9038 language: Some("rust".to_string()),
9039 parser: ParserKind::TreeSitter,
9040 symbols: Vec::new(),
9041 relations: vec![SymbolRelation {
9042 path: "src/Äuth.rs".to_string(),
9043 source_name: "verifyToken".to_string(),
9044 target_name: "legacyTarget".to_string(),
9045 kind: RelationKind::Calls,
9046 line: 10,
9047 context: "legacyTarget()".to_string(),
9048 parser: ParserKind::TreeSitter,
9049 }],
9050 })?;
9051 publication.replace_file_texts_for_paths(
9052 &["src/Äuth.rs".to_string()],
9053 &[IndexedFileText {
9054 path: "src/Äuth.rs".to_string(),
9055 content_hash: Some("hash-old".to_string()),
9056 byte_count: 16,
9057 line_count: 1,
9058 content: "fn verifyToken()".to_string(),
9059 }],
9060 )?;
9061 publication.replace_repository_graph(
9062 fixture.project,
9063 &fixture.entities,
9064 &fixture.relations,
9065 &occurrences,
9066 &fixture.coverage,
9067 )?;
9068 publication.complete()?;
9069 Ok(fixture)
9070 }
9071
9072 #[test]
9073 fn classified_relation_family_filters_before_limit_and_preserves_legacy_page()
9074 -> Result<(), Box<dyn Error>> {
9075 let temp = tempfile::tempdir()?;
9076 let root = temp.path().join("classified-relation-family");
9077 fs::create_dir_all(&root)?;
9078 let mut store = AtlasStore::open_for_project(&root.join("projectatlas.db"), &root)?;
9079 let project = store
9080 .project_instance_id()?
9081 .ok_or_else(|| io::Error::other("classified fixture identity is missing"))?;
9082 let generation = IndexGeneration::new(1);
9083 let paths = (0..6)
9084 .map(|index| format!("src/source-{index}.rs"))
9085 .collect::<Vec<_>>();
9086 let sources = paths
9087 .iter()
9088 .map(|path| {
9089 GraphEntity::new(
9090 project,
9091 EntitySelector::File {
9092 path: RepositoryFilePath::new(Path::new(path))?,
9093 },
9094 generation,
9095 )
9096 .map_err(Into::into)
9097 })
9098 .collect::<Result<Vec<_>, Box<dyn Error>>>()?;
9099 let external = GraphEntity::new(
9100 project,
9101 EntitySelector::External {
9102 external: ExternalSelector {
9103 system: GraphIdentityText::new("classified-family")?,
9104 identity: GraphIdentityText::new("shared-target")?,
9105 },
9106 },
9107 generation,
9108 )?;
9109 let calls = GraphRelationKind::Legacy(RelationKind::Calls);
9110 let mut relations = sources
9111 .iter()
9112 .map(|source| {
9113 LogicalRelation::new(
9114 source,
9115 calls,
9116 RelationResolution::external(&external)?,
9117 ConfidenceClass::Exact,
9118 Completeness::Complete,
9119 generation,
9120 )
9121 .map_err(Into::into)
9122 })
9123 .collect::<Result<Vec<_>, Box<dyn Error>>>()?;
9124 relations.sort_by(|left, right| left.key().digest().cmp(right.key().digest()));
9125 let classifications = [
9126 ContentClassification::Documentation,
9127 ContentClassification::ConfigurationData,
9128 ContentClassification::OtherText,
9129 ContentClassification::Opaque,
9130 ContentClassification::Source,
9131 ContentClassification::Documentation,
9132 ];
9133 let rows = relations
9134 .iter()
9135 .zip(classifications)
9136 .map(|(relation, classification)| {
9137 let source = sources
9138 .iter()
9139 .find(|source| source.key() == relation.source())
9140 .ok_or_else(|| io::Error::other("relation source fixture is missing"))?;
9141 let EntitySelector::File { path } = source.selector() else {
9142 return Err(io::Error::other("relation source fixture is not a file"));
9143 };
9144 Ok(crate::FileContentClassification {
9145 path: path.as_str().to_string(),
9146 classification,
9147 })
9148 })
9149 .collect::<Result<Vec<_>, io::Error>>()?;
9150 let mut nodes = vec![
9151 graph_node(".", NodeKind::Folder, None),
9152 graph_node("src", NodeKind::Folder, Some(".")),
9153 ];
9154 nodes.extend(
9155 paths
9156 .iter()
9157 .map(|path| graph_node(path, NodeKind::File, Some("src"))),
9158 );
9159 let mut entities = sources;
9160 entities.push(external);
9161 let mut publication = store.begin_index_publication("classified-relation-family")?;
9162 publication.begin_scan_replacement()?;
9163 publication.upsert_scan_node_batch(&nodes)?;
9164 publication.finish_scan_replacement()?;
9165 publication.upsert_file_content_classification_batch(&rows)?;
9166 publication.replace_repository_graph(project, &entities, &relations, &[], &[])?;
9167 publication.complete()?;
9168
9169 let legacy = store.repository_graph_relation_rows(
9170 RepositoryGraphRelationQuery::Family { relation: calls },
9171 2,
9172 None,
9173 )?;
9174 let classified_legacy = store.repository_graph_classified_relation_family_rows(
9175 calls,
9176 ContentSelection::UnspecifiedLegacy,
9177 2,
9178 None,
9179 )?;
9180 require_eq(
9181 &classified_legacy
9182 .rows
9183 .iter()
9184 .map(|row| row.detail.clone())
9185 .collect::<Vec<_>>(),
9186 &legacy.rows,
9187 "omitted-selection relation rows and order",
9188 )?;
9189 require_eq(
9190 &classified_legacy.truncated,
9191 &legacy.truncated,
9192 "omitted-selection truncation",
9193 )?;
9194 require_eq(
9195 &classified_legacy
9196 .rows
9197 .iter()
9198 .map(|row| row.source_classification)
9199 .collect::<Vec<_>>(),
9200 &vec![
9201 Some(ContentClassification::Documentation),
9202 Some(ContentClassification::ConfigurationData),
9203 ],
9204 "omitted-selection additive classifications",
9205 )?;
9206
9207 let (source, source_statements) = trace_statements(&mut store, |store| {
9208 store.repository_graph_classified_relation_family_rows(
9209 calls,
9210 ContentSelection::Source,
9211 1,
9212 None,
9213 )
9214 })?;
9215 require(
9216 source.rows.len() == 1
9217 && !source.truncated
9218 && source.rows[0].detail.relation == relations[4]
9219 && source.rows[0].source_classification == Some(ContentClassification::Source),
9220 "source selection filtered only after a bounded mixed page",
9221 )?;
9222 require_eq(
9223 &source_statements
9224 .iter()
9225 .filter(|statement| statement.contains("file_content_classifications"))
9226 .count(),
9227 &1,
9228 "source classification statement count",
9229 )?;
9230 let documentation = store.repository_graph_classified_relation_family_rows(
9231 calls,
9232 ContentSelection::Documentation,
9233 1,
9234 None,
9235 )?;
9236 require(
9237 documentation.rows.len() == 1
9238 && documentation.truncated
9239 && documentation.rows[0].detail.relation == relations[0]
9240 && documentation.rows[0].source_classification
9241 == Some(ContentClassification::Documentation),
9242 "documentation selection did not preserve pre-limit ordering",
9243 )?;
9244 let both = store.repository_graph_classified_relation_family_rows(
9245 calls,
9246 ContentSelection::Both,
9247 2,
9248 None,
9249 )?;
9250 require(
9251 both.rows.len() == 2
9252 && both.truncated
9253 && both.rows[0].detail.relation == relations[0]
9254 && both.rows[1].detail.relation == relations[4]
9255 && both.rows.iter().all(|row| {
9256 row.source_classification.is_some_and(|classification| {
9257 ContentSelection::Both.includes(classification)
9258 })
9259 }),
9260 "combined selection admitted an excluded class or filtered after limit",
9261 )?;
9262
9263 for selection in [
9264 ContentSelection::UnspecifiedLegacy,
9265 ContentSelection::Source,
9266 ContentSelection::Both,
9267 ] {
9268 let mut bindings = vec![
9269 Value::Blob(project.as_bytes().to_vec()),
9270 Value::Text("legacy".to_string()),
9271 Value::Text("calls".to_string()),
9272 ];
9273 match selection {
9274 ContentSelection::UnspecifiedLegacy => {}
9275 ContentSelection::Source => bindings.push(Value::Text("source".to_string())),
9276 ContentSelection::Documentation => {
9277 bindings.push(Value::Text("documentation".to_string()));
9278 }
9279 ContentSelection::Both => bindings.extend([
9280 Value::Text("source".to_string()),
9281 Value::Text("documentation".to_string()),
9282 ]),
9283 }
9284 bindings.push(Value::Integer(3));
9285 let sql = format!(
9286 "EXPLAIN QUERY PLAN {}",
9287 classified_relation_family_sql(selection)
9288 );
9289 let mut statement = store.connection.prepare(&sql)?;
9290 let details = statement
9291 .query_map(params_from_iter(bindings.iter()), |row| {
9292 row.get::<_, String>(3)
9293 })?
9294 .collect::<Result<Vec<_>, _>>()?;
9295 require(
9296 details
9297 .iter()
9298 .any(|detail| detail.contains("idx_graph_relations_kind_order"))
9299 && details
9300 .iter()
9301 .any(|detail| detail.contains("file_content_classifications"))
9302 && details
9303 .iter()
9304 .all(|detail| !detail.contains("SCAN relation"))
9305 && details
9306 .iter()
9307 .all(|detail| !detail.contains("USE TEMP B-TREE")),
9308 &format!("classified family query did not retain indexed ordering: {details:?}"),
9309 )?;
9310 }
9311
9312 let invalid = require_db_error(
9313 store.repository_graph_classified_relation_family_rows(
9314 calls,
9315 ContentSelection::Source,
9316 0,
9317 None,
9318 ),
9319 "zero classified family limit succeeded",
9320 )?;
9321 require(
9322 matches!(
9323 invalid,
9324 DbError::GraphContract(GraphContractError::InvalidLimits { .. })
9325 ),
9326 "zero classified family limit returned the wrong error",
9327 )?;
9328 Ok(())
9329 }
9330
9331 #[test]
9332 fn filtered_terminal_probe_classifies_the_directional_endpoint() -> Result<(), Box<dyn Error>> {
9333 let temp = tempfile::tempdir()?;
9334 let root = temp.path().join("filtered-terminal-probe");
9335 fs::create_dir_all(&root)?;
9336 let mut store = AtlasStore::open_for_project(&root.join("projectatlas.db"), &root)?;
9337 let project = store
9338 .project_instance_id()?
9339 .ok_or_else(|| io::Error::other("filtered probe fixture identity is missing"))?;
9340 let generation = IndexGeneration::new(1);
9341 let doc_source = GraphEntity::new(
9342 project,
9343 EntitySelector::File {
9344 path: RepositoryFilePath::new(Path::new("docs/source.md"))?,
9345 },
9346 generation,
9347 )?;
9348 let source_target = GraphEntity::new(
9349 project,
9350 EntitySelector::File {
9351 path: RepositoryFilePath::new(Path::new("src/target.rs"))?,
9352 },
9353 generation,
9354 )?;
9355 let source_source = GraphEntity::new(
9356 project,
9357 EntitySelector::File {
9358 path: RepositoryFilePath::new(Path::new("src/source.rs"))?,
9359 },
9360 generation,
9361 )?;
9362 let doc_target = GraphEntity::new(
9363 project,
9364 EntitySelector::File {
9365 path: RepositoryFilePath::new(Path::new("docs/target.md"))?,
9366 },
9367 generation,
9368 )?;
9369 let external_document_target = GraphEntity::new(
9370 project,
9371 EntitySelector::External {
9372 external: ExternalSelector {
9373 system: GraphIdentityText::new("docs.example")?,
9374 identity: GraphIdentityText::new("external-page")?,
9375 },
9376 },
9377 generation,
9378 )?;
9379 let calls = GraphRelationKind::Legacy(RelationKind::Calls);
9380 let documents = GraphRelationKind::Extended(ExtendedRelationKind::Documents);
9381 let relations = vec![
9382 LogicalRelation::new(
9383 &doc_source,
9384 calls,
9385 RelationResolution::resolved(&source_target)?,
9386 ConfidenceClass::Exact,
9387 Completeness::Complete,
9388 generation,
9389 )?,
9390 LogicalRelation::new(
9391 &source_source,
9392 calls,
9393 RelationResolution::resolved(&doc_target)?,
9394 ConfidenceClass::Exact,
9395 Completeness::Complete,
9396 generation,
9397 )?,
9398 LogicalRelation::new(
9399 &doc_source,
9400 documents,
9401 RelationResolution::external(&external_document_target)?,
9402 ConfidenceClass::Exact,
9403 Completeness::Complete,
9404 generation,
9405 )?,
9406 LogicalRelation::new(
9407 &source_source,
9408 calls,
9409 RelationResolution::external(&external_document_target)?,
9410 ConfidenceClass::Exact,
9411 Completeness::Complete,
9412 generation,
9413 )?,
9414 ];
9415 let mut publication = store.begin_index_publication("filtered-terminal-probe")?;
9416 publication.begin_scan_replacement()?;
9417 publication.upsert_scan_node_batch(&[
9418 graph_node(".", NodeKind::Folder, None),
9419 graph_node("src", NodeKind::Folder, Some(".")),
9420 graph_node("docs", NodeKind::Folder, Some(".")),
9421 graph_node("src/target.rs", NodeKind::File, Some("src")),
9422 graph_node("src/source.rs", NodeKind::File, Some("src")),
9423 graph_node("docs/source.md", NodeKind::File, Some("docs")),
9424 graph_node("docs/target.md", NodeKind::File, Some("docs")),
9425 ])?;
9426 publication.finish_scan_replacement()?;
9427 publication.upsert_file_content_classification_batch(&[
9428 crate::FileContentClassification {
9429 path: "docs/source.md".to_string(),
9430 classification: ContentClassification::Documentation,
9431 },
9432 crate::FileContentClassification {
9433 path: "src/target.rs".to_string(),
9434 classification: ContentClassification::Source,
9435 },
9436 crate::FileContentClassification {
9437 path: "src/source.rs".to_string(),
9438 classification: ContentClassification::Source,
9439 },
9440 crate::FileContentClassification {
9441 path: "docs/target.md".to_string(),
9442 classification: ContentClassification::Documentation,
9443 },
9444 ])?;
9445 publication.replace_repository_graph(
9446 project,
9447 &[
9448 doc_source.clone(),
9449 source_target.clone(),
9450 source_source.clone(),
9451 doc_target.clone(),
9452 external_document_target,
9453 ],
9454 &relations,
9455 &[],
9456 &[],
9457 )?;
9458 publication.complete()?;
9459
9460 require(
9461 store.repository_graph_adjacency_is_empty_filtered(
9462 source_target.key(),
9463 RepositoryGraphDirection::Inbound,
9464 calls,
9465 ConfidenceClass::Exact,
9466 ContentSelection::Source,
9467 None,
9468 )?,
9469 "inbound documentation source was admitted under source selection",
9470 )?;
9471 require(
9472 !store.repository_graph_adjacency_is_empty_filtered(
9473 doc_target.key(),
9474 RepositoryGraphDirection::Inbound,
9475 calls,
9476 ConfidenceClass::Exact,
9477 ContentSelection::Source,
9478 None,
9479 )?,
9480 "inbound source endpoint was excluded under source selection",
9481 )?;
9482 require(
9483 !store.repository_graph_adjacency_is_empty_filtered(
9484 doc_source.key(),
9485 RepositoryGraphDirection::Outbound,
9486 calls,
9487 ConfidenceClass::Exact,
9488 ContentSelection::Source,
9489 None,
9490 )?,
9491 "outbound source-target endpoint was excluded under source selection",
9492 )?;
9493 require(
9494 !store.repository_graph_adjacency_is_empty_filtered(
9495 doc_source.key(),
9496 RepositoryGraphDirection::Outbound,
9497 documents,
9498 ConfidenceClass::Exact,
9499 ContentSelection::Source,
9500 None,
9501 )?,
9502 "external document endpoint was hidden under source selection",
9503 )?;
9504 require(
9505 !store.repository_graph_adjacency_is_empty_filtered(
9506 source_source.key(),
9507 RepositoryGraphDirection::Outbound,
9508 calls,
9509 ConfidenceClass::Exact,
9510 ContentSelection::Source,
9511 None,
9512 )?,
9513 "external non-document endpoint was hidden under source selection",
9514 )?;
9515 Ok(())
9516 }
9517
9518 #[test]
9519 fn resolved_relation_hubs_rank_the_complete_family_through_the_resolution_index()
9520 -> Result<(), Box<dyn Error>> {
9521 let temp = tempfile::tempdir()?;
9522 let root = temp.path().join("resolved-relation-hubs");
9523 fs::create_dir_all(&root)?;
9524 let mut store = AtlasStore::open_for_project(&root.join("projectatlas.db"), &root)?;
9525 let fixture = publish_fixture(&mut store, "resolved-relation-hubs")?;
9526
9527 let calls = store.repository_graph_resolved_relation_hubs(
9528 GraphRelationKind::Legacy(RelationKind::Calls),
9529 2,
9530 None,
9531 )?;
9532 let expected = BTreeSet::from([
9533 fixture.relations[0].source().digest().to_string(),
9534 fixture.relations[0]
9535 .resolution()
9536 .resolved_target()
9537 .ok_or_else(|| io::Error::other("resolved fixture target is missing"))?
9538 .digest()
9539 .to_string(),
9540 ]);
9541 let actual = calls
9542 .rows
9543 .iter()
9544 .map(|entity| entity.key().digest().to_string())
9545 .collect::<BTreeSet<_>>();
9546 require_eq(&actual, &expected, "resolved relation hubs")?;
9547 require_eq(&calls.truncated, &false, "resolved hub truncation")?;
9548
9549 let one = store.repository_graph_resolved_relation_hubs(
9550 GraphRelationKind::Legacy(RelationKind::Calls),
9551 1,
9552 None,
9553 )?;
9554 require_eq(&one.rows.len(), &1, "bounded resolved hub count")?;
9555 require_eq(&one.truncated, &true, "bounded resolved hub overflow")?;
9556 let unresolved = store.repository_graph_resolved_relation_hubs(
9557 GraphRelationKind::Extended(ExtendedRelationKind::Configures),
9558 2,
9559 None,
9560 )?;
9561 require_eq(&unresolved.rows.len(), &0, "unresolved hub exclusion")?;
9562 let external = store.repository_graph_resolved_relation_hubs(
9563 GraphRelationKind::Legacy(RelationKind::DependsOn),
9564 2,
9565 None,
9566 )?;
9567 require_eq(&external.rows.len(), &0, "external hub exclusion")?;
9568
9569 let invalid = require_db_error(
9570 store.repository_graph_resolved_relation_hubs(
9571 GraphRelationKind::Legacy(RelationKind::Calls),
9572 0,
9573 None,
9574 ),
9575 "zero resolved hub limit succeeded",
9576 )?;
9577 require(
9578 matches!(
9579 invalid,
9580 DbError::GraphContract(GraphContractError::InvalidLimits { .. })
9581 ),
9582 &format!("zero resolved hub limit returned {invalid}"),
9583 )?;
9584
9585 let mut statement = store.connection.prepare(&format!(
9586 "EXPLAIN QUERY PLAN {}",
9587 resolved_relation_hub_keys_sql()
9588 ))?;
9589 let details = statement
9590 .query_map(
9591 params![
9592 &fixture.project.as_bytes()[..],
9593 "legacy",
9594 "calls",
9595 RESOLUTION_STATUS_RESOLVED,
9596 3_i64,
9597 ],
9598 |row| row.get::<_, String>(3),
9599 )?
9600 .collect::<Result<Vec<_>, _>>()?;
9601 require(
9602 details
9603 .iter()
9604 .filter(|detail| detail.contains("idx_graph_relations_kind_resolution"))
9605 .count()
9606 >= 2
9607 && details
9608 .iter()
9609 .all(|detail| !detail.contains("SCAN graph_relations"))
9610 && details
9611 .iter()
9612 .all(|detail| !detail.contains("graph_entities")),
9613 &format!(
9614 "resolved hub ranking did not limit indexed endpoint keys before hydration: {details:?}"
9615 ),
9616 )?;
9617 let cancellation = projectatlas_core::IndexCancellation::new();
9618 cancellation.cancel();
9619 let cancelled = IndexWorkControl::new(cancellation, None);
9620 let error = require_db_error(
9621 store.repository_graph_resolved_relation_hubs(
9622 GraphRelationKind::Legacy(RelationKind::Calls),
9623 2,
9624 Some(&cancelled),
9625 ),
9626 "cancelled resolved hub ranking succeeded",
9627 )?;
9628 require(
9629 matches!(
9630 error,
9631 DbError::IndexWork(projectatlas_core::IndexWorkFailure::Cancelled {
9632 stage: IndexWorkStage::RepositoryTraversal
9633 })
9634 ),
9635 &format!("resolved hub cancellation was not typed: {error}"),
9636 )?;
9637 Ok(())
9638 }
9639
9640 #[test]
9641 fn resolved_preview_reads_bound_work_and_reject_corruption_when_paged()
9642 -> Result<(), Box<dyn Error>> {
9643 let temp = tempfile::tempdir()?;
9644 let root = temp.path().join("resolved-preview-bounds");
9645 fs::create_dir_all(&root)?;
9646 let mut store = AtlasStore::open_for_project(&root.join("projectatlas.db"), &root)?;
9647 let fixture =
9648 publish_detailed_relation_trace_fixture(&mut store, "resolved-preview-bounds")?;
9649 let source = fixture.source;
9650 let calls = GraphRelationKind::Legacy(RelationKind::Calls);
9651 let (_, hub_statements) = trace_statements(&mut store, |store| {
9652 store.repository_graph_resolved_relation_hubs(calls, 1, None)
9653 })?;
9654 require(
9655 hub_statements.iter().all(|statement| {
9656 !statement.contains("ORDER BY relation.relation_key")
9657 && !statement.contains("relation.canonical_identity")
9658 }),
9659 &format!("resolved hub lookup reconstructed omitted relation rows: {hub_statements:?}"),
9660 )?;
9661
9662 let budget = RepositoryGraphReadBudget::new(
9663 1,
9664 1,
9665 RepositoryGraphReadBudget::MAX_DECODED_BYTES,
9666 3,
9667 2,
9668 )?;
9669 let (first, adjacency_statements) = trace_statements(&mut store, |store| {
9670 store.repository_graph_adjacency_page_filtered_by_resolution_bounded(
9671 &[source.key().clone()],
9672 RepositoryGraphDirection::Outbound,
9673 Some(calls),
9674 true,
9675 false,
9676 None,
9677 1,
9678 budget,
9679 None,
9680 )
9681 })?;
9682 require(
9683 first.page.truncated
9684 && first.page.rows.len() == 1
9685 && first.page.continuation.is_some()
9686 && first.work.requested_rows == 1
9687 && first.work.returned_rows == 1
9688 && first.work.hydrated_entities <= 3
9689 && adjacency_statements.iter().all(|statement| {
9690 !statement.contains("ORDER BY relation.relation_key")
9691 || statement.contains("UNION ALL")
9692 }),
9693 &format!(
9694 "resolved adjacency exceeded its bounded evidence query: work={:?}, statements={adjacency_statements:?}",
9695 first.work
9696 ),
9697 )?;
9698
9699 let expired = IndexWorkControl::with_deadline(
9700 projectatlas_core::IndexCancellation::new(),
9701 Instant::now(),
9702 );
9703 for error in [
9704 require_db_error(
9705 store.repository_graph_resolved_relation_hubs(calls, 1, Some(&expired)),
9706 "expired resolved hub lookup succeeded",
9707 )?,
9708 require_db_error(
9709 store.repository_graph_resolved_adjacency_page(
9710 &[source.key().clone()],
9711 RepositoryGraphDirection::Outbound,
9712 calls,
9713 None,
9714 1,
9715 Some(&expired),
9716 ),
9717 "expired resolved adjacency lookup succeeded",
9718 )?,
9719 ] {
9720 require(
9721 matches!(
9722 error,
9723 DbError::IndexWork(projectatlas_core::IndexWorkFailure::DeadlineExceeded {
9724 stage: IndexWorkStage::RepositoryTraversal
9725 })
9726 ),
9727 &format!("resolved preview deadline was not typed: {error}"),
9728 )?;
9729 }
9730
9731 let continuation = first
9732 .page
9733 .continuation
9734 .ok_or_else(|| io::Error::other("resolved preview continuation is missing"))?;
9735 let mut relations = fixture.relations;
9736 relations.sort_by(|left, right| left.key().digest().cmp(right.key().digest()));
9737 let corrupt = relations
9738 .get(2)
9739 .ok_or_else(|| io::Error::other("late-page corruption relation is missing"))?;
9740 let corrupt_key = corrupt.key().digest_bytes()?;
9741 store
9742 .connection
9743 .execute_batch("PRAGMA ignore_check_constraints = ON")?;
9744 store.connection.execute(
9745 "UPDATE graph_relations
9746 SET canonical_identity = 'contradictory late-page relation'
9747 WHERE relation_key = ?1",
9748 [&corrupt_key[..]],
9749 )?;
9750 let healthy_first = store.repository_graph_resolved_adjacency_page(
9751 &[source.key().clone()],
9752 RepositoryGraphDirection::Outbound,
9753 calls,
9754 None,
9755 1,
9756 None,
9757 )?;
9758 require(
9759 healthy_first.rows.len() == 1 && healthy_first.continuation.is_some(),
9760 "corruption outside the bounded evidence page hid the healthy first page",
9761 )?;
9762 let error = require_db_error(
9763 store.repository_graph_resolved_adjacency_page(
9764 &[source.key().clone()],
9765 RepositoryGraphDirection::Outbound,
9766 calls,
9767 Some(&continuation),
9768 1,
9769 None,
9770 ),
9771 "late-page corrupt relation returned a partial adjacency page",
9772 )?;
9773 require(
9774 matches!(
9775 error,
9776 DbError::GraphContract(GraphContractError::StableKeyCollision { .. })
9777 ),
9778 &format!("late-page corrupt relation returned {error}"),
9779 )?;
9780 Ok(())
9781 }
9782
9783 #[test]
9784 fn resolved_preview_reads_reject_selected_relation_and_endpoint_corruption()
9785 -> Result<(), Box<dyn Error>> {
9786 let temp = tempfile::tempdir()?;
9787 let root = temp.path().join("resolved-preview-corruption");
9788 fs::create_dir_all(&root)?;
9789 let mut store = AtlasStore::open_for_project(&root.join("projectatlas.db"), &root)?;
9790 let fixture =
9791 publish_detailed_relation_trace_fixture(&mut store, "resolved-preview-corruption")?;
9792 let source = fixture.source;
9793 let mut relations = fixture.relations;
9794
9795 let calls = GraphRelationKind::Legacy(RelationKind::Calls);
9796 let healthy_hubs = store.repository_graph_resolved_relation_hubs(calls, 1, None)?;
9797 require(
9798 healthy_hubs.truncated
9799 && healthy_hubs.rows.len() == 1
9800 && healthy_hubs.rows[0].key() == source.key(),
9801 "healthy corruption fixture did not rank its source hub first",
9802 )?;
9803 let healthy_adjacency = store.repository_graph_resolved_adjacency_page(
9804 &[source.key().clone()],
9805 RepositoryGraphDirection::Outbound,
9806 calls,
9807 None,
9808 1,
9809 None,
9810 )?;
9811 require(
9812 healthy_adjacency.truncated && healthy_adjacency.rows.len() == 1,
9813 "healthy corruption fixture did not retain its adjacency sentinel",
9814 )?;
9815
9816 relations.sort_by(|left, right| left.key().digest().cmp(right.key().digest()));
9817 let top = relations
9818 .first()
9819 .ok_or_else(|| io::Error::other("top corruption relation is missing"))?;
9820 let source_key = source.key().digest_bytes()?;
9821 let top_key = top.key().digest_bytes()?;
9822 let top_target_key = top
9823 .resolution()
9824 .resolved_target()
9825 .ok_or_else(|| io::Error::other("top corruption target is missing"))?;
9826 let top_target = top_target_key.digest_bytes()?;
9827 store.connection.execute_batch(
9828 "PRAGMA foreign_keys = OFF;
9829 PRAGMA ignore_check_constraints = ON;",
9830 )?;
9831 store.connection.execute(
9832 "UPDATE graph_relations
9833 SET canonical_identity = 'contradictory visible relation identity'
9834 WHERE relation_key = ?1",
9835 [&top_key[..]],
9836 )?;
9837 let visible_identity = require_db_error(
9838 store.repository_graph_resolved_adjacency_page(
9839 &[source.key().clone()],
9840 RepositoryGraphDirection::Outbound,
9841 calls,
9842 None,
9843 1,
9844 None,
9845 ),
9846 "visible canonical-identity corruption returned an adjacency page",
9847 )?;
9848 require(
9849 matches!(
9850 visible_identity,
9851 DbError::GraphContract(GraphContractError::StableKeyCollision { .. })
9852 ),
9853 &format!("visible canonical-identity corruption returned {visible_identity}"),
9854 )?;
9855 store.connection.execute(
9856 "UPDATE graph_relations SET canonical_identity = ?1 WHERE relation_key = ?2",
9857 params![top.key().canonical_identity(), &top_key[..]],
9858 )?;
9859
9860 let visible_wrong_digest = [0_u8; 32];
9861 store.connection.execute(
9862 "UPDATE graph_relations SET relation_key = ?1 WHERE relation_key = ?2",
9863 params![&visible_wrong_digest[..], &top_key[..]],
9864 )?;
9865 let visible_digest = require_db_error(
9866 store.repository_graph_resolved_adjacency_page(
9867 &[source.key().clone()],
9868 RepositoryGraphDirection::Outbound,
9869 calls,
9870 None,
9871 1,
9872 None,
9873 ),
9874 "visible well-typed wrong digest returned an adjacency page",
9875 )?;
9876 require(
9877 matches!(
9878 visible_digest,
9879 DbError::GraphContract(GraphContractError::InvalidStableKeyDigest)
9880 ),
9881 &format!("visible well-typed wrong digest returned {visible_digest}"),
9882 )?;
9883 store.connection.execute(
9884 "UPDATE graph_relations SET relation_key = ?1 WHERE canonical_identity = ?2",
9885 params![&top_key[..], top.key().canonical_identity()],
9886 )?;
9887
9888 store.connection.execute(
9889 "UPDATE graph_entities SET canonical_identity = '' WHERE entity_key = ?1",
9890 [&source_key[..]],
9891 )?;
9892 let top_hub_endpoint = require_db_error(
9893 store.repository_graph_resolved_relation_hubs(calls, 1, None),
9894 "top-ranked corrupt hub endpoint returned a partial page",
9895 )?;
9896 require(
9897 matches!(top_hub_endpoint, DbError::GraphContract(_)),
9898 &format!("top-ranked corrupt hub endpoint returned {top_hub_endpoint}"),
9899 )?;
9900 store.connection.execute(
9901 "UPDATE graph_entities SET canonical_identity = ?1 WHERE entity_key = ?2",
9902 params![source.key().canonical_identity(), &source_key[..]],
9903 )?;
9904
9905 store.connection.execute(
9906 "UPDATE graph_entities SET canonical_identity = '' WHERE entity_key = ?1",
9907 [&top_target[..]],
9908 )?;
9909 let top_adjacency_endpoint = require_db_error(
9910 store.repository_graph_resolved_adjacency_page(
9911 &[source.key().clone()],
9912 RepositoryGraphDirection::Outbound,
9913 calls,
9914 None,
9915 1,
9916 None,
9917 ),
9918 "top-page corrupt adjacency endpoint returned a partial page",
9919 )?;
9920 require(
9921 matches!(top_adjacency_endpoint, DbError::GraphContract(_)),
9922 &format!("top-page corrupt adjacency endpoint returned {top_adjacency_endpoint}"),
9923 )?;
9924 store.connection.execute(
9925 "UPDATE graph_entities SET canonical_identity = ?1 WHERE entity_key = ?2",
9926 params![top_target_key.canonical_identity(), &top_target[..]],
9927 )?;
9928
9929 Ok(())
9930 }
9931
9932 #[test]
9933 fn resolved_adjacency_filters_self_edges_before_limits_and_binds_its_cursor()
9934 -> Result<(), Box<dyn Error>> {
9935 let temp = tempfile::tempdir()?;
9936 let root = temp.path().join("resolved-adjacency-self-edge");
9937 fs::create_dir_all(&root)?;
9938 let mut store = AtlasStore::open_for_project(&root.join("projectatlas.db"), &root)?;
9939 let fixture = publish_fixture(&mut store, "resolved-adjacency-self-edge")?;
9940 let generation = IndexGeneration::new(1);
9941 let calls = GraphRelationKind::Legacy(RelationKind::Calls);
9942 let mut selected = None;
9943 for index in 0..1_000 {
9944 let source_name = format!("self_edge_source_{index}");
9945 let target_name = format!("self_edge_target_{index}");
9946 let symbol = |name: &str| -> Result<GraphEntity, Box<dyn Error>> {
9947 Ok(GraphEntity::new(
9948 fixture.project,
9949 EntitySelector::Symbol {
9950 symbol: SymbolSelector {
9951 file: RepositoryFilePath::new(Path::new("src/Äuth.rs"))?,
9952 name: GraphIdentityText::new(name)?,
9953 kind: SymbolKind::Function,
9954 parent: None,
9955 signature: GraphIdentityText::new(format!("{name}()"))?,
9956 },
9957 },
9958 generation,
9959 )?)
9960 };
9961 let source = symbol(&source_name)?;
9962 let target = symbol(&target_name)?;
9963 let self_relation = LogicalRelation::new(
9964 &source,
9965 calls,
9966 RelationResolution::resolved(&source)?,
9967 ConfidenceClass::Exact,
9968 Completeness::Complete,
9969 generation,
9970 )?;
9971 let useful = LogicalRelation::new(
9972 &source,
9973 calls,
9974 RelationResolution::resolved(&target)?,
9975 ConfidenceClass::Exact,
9976 Completeness::Complete,
9977 generation,
9978 )?;
9979 if self_relation.key().digest() < useful.key().digest() {
9980 selected = Some((source, target, self_relation, useful));
9981 break;
9982 }
9983 }
9984 let (source, target, self_relation, useful) = selected
9985 .ok_or_else(|| io::Error::other("could not order a self edge before a useful edge"))?;
9986 insert_entities(&store.connection, fixture.project, [&source, &target])?;
9987 insert_relations(
9988 &store.connection,
9989 fixture.project,
9990 &[self_relation.clone(), useful.clone()],
9991 )?;
9992
9993 let frontier = [source.key().clone()];
9994 let first = store.repository_graph_resolved_adjacency_page(
9995 &frontier,
9996 RepositoryGraphDirection::Outbound,
9997 calls,
9998 None,
9999 1,
10000 None,
10001 )?;
10002 require(
10003 first.rows.len() == 1
10004 && first.rows[0].detail.relation.key() == useful.key()
10005 && first.rows[0].detail.relation.key() != self_relation.key(),
10006 "self relation consumed the resolved adjacency limit before a useful edge",
10007 )?;
10008
10009 let cancellation = projectatlas_core::IndexCancellation::new();
10010 cancellation.cancel();
10011 let cancelled = IndexWorkControl::new(cancellation, None);
10012 let cancellation_error = require_db_error(
10013 store.repository_graph_resolved_adjacency_page(
10014 &frontier,
10015 RepositoryGraphDirection::Outbound,
10016 calls,
10017 None,
10018 1,
10019 Some(&cancelled),
10020 ),
10021 "cancelled resolved adjacency succeeded",
10022 )?;
10023 require(
10024 matches!(
10025 cancellation_error,
10026 DbError::IndexWork(projectatlas_core::IndexWorkFailure::Cancelled {
10027 stage: IndexWorkStage::RepositoryTraversal
10028 })
10029 ),
10030 &format!("resolved adjacency cancellation was not typed: {cancellation_error}"),
10031 )?;
10032 Ok(())
10033 }
10034
10035 #[test]
10036 fn repository_graph_staging_checkpoints_ownership_before_graph_writes()
10037 -> Result<(), Box<dyn Error>> {
10038 let temp = tempfile::tempdir()?;
10039 let project_root = temp.path().join("staging-ownership");
10040 let stage = project_root.join(".projectatlas").join("graph-stage-test");
10041 fs::create_dir_all(&stage)?;
10042 let database = stage.join("projectatlas.db");
10043 let project = ProjectInstanceId::from_bytes([7; 16])?;
10044 let _store =
10045 AtlasStore::create_repository_graph_staging(&database, &project_root, project)?;
10046
10047 let main_database_only = temp.path().join("staging-main-only.db");
10048 fs::copy(&database, &main_database_only)?;
10049 require(
10050 AtlasStore::repository_graph_staging_belongs_to(
10051 &main_database_only,
10052 &project_root,
10053 project,
10054 )?,
10055 "staging ownership depended on WAL sidecars before graph writes",
10056 )
10057 }
10058
10059 #[cfg(windows)]
10060 #[test]
10061 fn repository_graph_staging_accepts_case_only_root_rename() -> Result<(), Box<dyn Error>> {
10062 let temp = tempfile::tempdir()?;
10063 let original_root = temp.path().join("CaseOnlyStageRoot");
10064 let intermediate_root = temp.path().join("CaseOnlyStageRoot-intermediate");
10065 let renamed_root = temp.path().join("caseonlystageroot");
10066 let stage = original_root.join(".projectatlas").join("graph-stage-case");
10067 fs::create_dir_all(&stage)?;
10068 let database = stage.join("projectatlas.db");
10069 let project = ProjectInstanceId::from_bytes([8; 16])?;
10070 drop(AtlasStore::create_repository_graph_staging(
10071 &database,
10072 &original_root,
10073 project,
10074 )?);
10075
10076 fs::rename(&original_root, &intermediate_root)?;
10077 fs::rename(&intermediate_root, &renamed_root)?;
10078 let renamed_database = renamed_root
10079 .join(".projectatlas")
10080 .join("graph-stage-case")
10081 .join("projectatlas.db");
10082 require(
10083 AtlasStore::repository_graph_staging_belongs_to(
10084 &renamed_database,
10085 &renamed_root,
10086 project,
10087 )?,
10088 "case-only renamed staging root was not re-canonicalized",
10089 )
10090 }
10091
10092 #[test]
10093 fn repository_graph_staging_persists_document_reasons_for_bounded_copy()
10094 -> Result<(), Box<dyn Error>> {
10095 let temp = tempfile::tempdir()?;
10096 let project_root = temp.path().join("staging-document-reasons");
10097 fs::create_dir(&project_root)?;
10098 let main_database = project_root.join("projectatlas.db");
10099 let mut main = AtlasStore::open_for_project(&main_database, &project_root)?;
10100 let project = main
10101 .project_instance_id()?
10102 .ok_or_else(|| io::Error::other("staging fixture identity is missing"))?;
10103 let generation = IndexGeneration::new(1);
10104 let stage_database = project_root.join("graph-stage.db");
10105 let mut stage =
10106 AtlasStore::create_repository_graph_staging(&stage_database, &project_root, project)?;
10107 let nodes = [
10108 graph_node(".", NodeKind::Folder, None),
10109 graph_node("docs", NodeKind::Folder, Some(".")),
10110 graph_node("docs/guide.md", NodeKind::File, Some("docs")),
10111 ];
10112 stage.replace_scan(&nodes)?;
10113 let document = GraphEntity::new(
10114 project,
10115 EntitySelector::File {
10116 path: RepositoryFilePath::new(Path::new("docs/guide.md"))?,
10117 },
10118 generation,
10119 )?;
10120 let unresolved_document = LogicalRelation::new(
10121 &document,
10122 GraphRelationKind::Extended(ExtendedRelationKind::Documents),
10123 RelationResolution::Unresolved {
10124 reference: GraphIdentityText::new("missing.md")?,
10125 },
10126 ConfidenceClass::Exact,
10127 Completeness::Complete,
10128 generation,
10129 )?;
10130 let unrelated = LogicalRelation::new(
10131 &document,
10132 GraphRelationKind::Extended(ExtendedRelationKind::Configures),
10133 RelationResolution::Unresolved {
10134 reference: GraphIdentityText::new("setting")?,
10135 },
10136 ConfidenceClass::Low,
10137 Completeness::Partial,
10138 generation,
10139 )?;
10140 let mut staging = stage.begin_repository_graph_staging(project, generation)?;
10141 staging.append_batch(
10142 std::slice::from_ref(&document),
10143 &[unresolved_document.clone(), unrelated.clone()],
10144 &[],
10145 &[],
10146 &[],
10147 &[],
10148 )?;
10149 let mixed = require_db_error(
10150 staging.set_document_unresolved_reasons(&[
10151 (
10152 unresolved_document.key().clone(),
10153 DocumentTargetUnresolvedReason::Missing,
10154 ),
10155 (
10156 unrelated.key().clone(),
10157 DocumentTargetUnresolvedReason::Unsupported,
10158 ),
10159 ]),
10160 "staging accepted a reason for a non-document relation",
10161 )?;
10162 require(
10163 matches!(mixed, DbError::GraphRowShape { .. }),
10164 "staging mixed reason batch returned the wrong error",
10165 )?;
10166 let relation_key = unresolved_document.key().digest_bytes()?;
10167 let staged_reason = staging.transaction.query_row(
10168 "SELECT document_unresolved_reason FROM graph_relations WHERE relation_key = ?1",
10169 [&relation_key[..]],
10170 |row| row.get::<_, Option<String>>(0),
10171 )?;
10172 require(
10173 staged_reason.is_none(),
10174 "failed staged reason batch exposed partial mutation",
10175 )?;
10176 staging.set_document_unresolved_reasons(&[(
10177 unresolved_document.key().clone(),
10178 DocumentTargetUnresolvedReason::Missing,
10179 )])?;
10180 staging.complete()?;
10181 stage.checkpoint_repository_graph_staging()?;
10182
10183 let cancellation = projectatlas_core::IndexCancellation::new();
10184 cancellation.cancel();
10185 let canceled = IndexWorkControl::new(cancellation, None);
10186 let mut canceled_publication =
10187 main.begin_index_publication("canceled-staged-document-reasons")?;
10188 canceled_publication.begin_scan_replacement()?;
10189 canceled_publication.upsert_scan_node_batch(&nodes)?;
10190 canceled_publication.finish_scan_replacement()?;
10191 let cancellation_error = require_db_error(
10192 canceled_publication.replace_repository_graph_from_staging(
10193 project,
10194 &stage,
10195 Some(&canceled),
10196 ),
10197 "canceled stage copy committed",
10198 )?;
10199 require(
10200 matches!(
10201 cancellation_error,
10202 DbError::IndexWork(projectatlas_core::IndexWorkFailure::Cancelled {
10203 stage: IndexWorkStage::Publication
10204 })
10205 ),
10206 "canceled stage copy returned the wrong error",
10207 )?;
10208 drop(canceled_publication);
10209 require(
10210 main.repository_graph_generation()?.is_none(),
10211 "canceled stage copy exposed a graph generation",
10212 )?;
10213
10214 let mut publication = main.begin_index_publication("staged-document-reasons")?;
10215 publication.begin_scan_replacement()?;
10216 publication.upsert_scan_node_batch(&nodes)?;
10217 publication.finish_scan_replacement()?;
10218 publication.replace_repository_graph_from_staging(project, &stage, None)?;
10219 publication.complete()?;
10220
10221 let copied = main.repository_graph_relation_rows(
10222 RepositoryGraphRelationQuery::Family {
10223 relation: GraphRelationKind::Extended(ExtendedRelationKind::Documents),
10224 },
10225 10,
10226 None,
10227 )?;
10228 require(
10229 copied.rows.len() == 1
10230 && copied.rows[0].document_unresolved_reason
10231 == Some(DocumentTargetUnresolvedReason::Missing),
10232 "stage-to-publication copy lost the closed document reason",
10233 )?;
10234 Ok(())
10235 }
10236
10237 #[test]
10238 fn resolution_keys_round_trip_reopen_and_preserve_all_dependency_states()
10239 -> Result<(), Box<dyn Error>> {
10240 let temp = tempfile::tempdir()?;
10241 let project_root = temp.path().join("resolution-round-trip");
10242 let atlas_dir = project_root.join(".projectatlas");
10243 fs::create_dir_all(&atlas_dir)?;
10244 let db_path = atlas_dir.join("projectatlas.db");
10245 let mut writer = AtlasStore::open_for_project(&db_path, &project_root)?;
10246 let fixture = publish_resolution_fixture(&mut writer, "resolution-round-trip")?;
10247 assert_query_indexes(&writer)?;
10248
10249 let counts = writer.connection.query_row(
10250 "SELECT
10251 (SELECT COUNT(*) FROM graph_resolution_keys),
10252 (SELECT COUNT(*) FROM graph_entity_exports),
10253 (SELECT COUNT(*) FROM graph_relation_dependencies)",
10254 [],
10255 |row| {
10256 Ok((
10257 row.get::<_, i64>(0)?,
10258 row.get::<_, i64>(1)?,
10259 row.get::<_, i64>(2)?,
10260 ))
10261 },
10262 )?;
10263 require_eq(&counts, &(3, 1, 3), "deduplicated resolution-key rows")?;
10264 let states = writer
10265 .connection
10266 .prepare(
10267 "SELECT relation.resolution_status, COUNT(*)
10268 FROM graph_relation_dependencies AS dependency
10269 JOIN graph_relations AS relation
10270 ON relation.project_instance_id = dependency.project_instance_id
10271 AND relation.relation_key = dependency.relation_key
10272 GROUP BY relation.resolution_status
10273 ORDER BY relation.resolution_status",
10274 )?
10275 .query_map([], |row| {
10276 Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
10277 })?
10278 .collect::<Result<Vec<_>, _>>()?;
10279 require_eq(
10280 &states,
10281 &vec![
10282 ("ambiguous".to_string(), 1),
10283 ("resolved".to_string(), 1),
10284 ("unresolved".to_string(), 1),
10285 ],
10286 "dependency resolution states",
10287 )?;
10288 drop(writer);
10289
10290 let reader = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
10291 let exports = reader.repository_export_keys_for_paths(
10292 fixture.graph.project,
10293 &["src/Äuth.rs".to_string()],
10294 10,
10295 )?;
10296 require_eq(&exports.truncated, &false, "export key truncation")?;
10297 require_eq(
10298 &exports.rows,
10299 &vec![fixture.resolved.clone()],
10300 "export key round trip",
10301 )?;
10302 let candidates = reader.repository_resolution_candidates(&fixture.resolved, 10)?;
10303 require_eq(&candidates.truncated, &false, "candidate truncation")?;
10304 require_eq(
10305 &candidates.rows,
10306 &vec![fixture.graph.entities[4].clone()],
10307 "candidate export round trip",
10308 )?;
10309 let batch_candidates = reader.repository_resolution_candidates_for_keys(
10310 fixture.graph.project,
10311 &[
10312 fixture.unresolved.clone(),
10313 fixture.resolved.clone(),
10314 fixture.ambiguous.clone(),
10315 fixture.resolved.clone(),
10316 ],
10317 10,
10318 )?;
10319 require_eq(
10320 &batch_candidates.truncated,
10321 &false,
10322 "batch candidate truncation",
10323 )?;
10324 require_eq(
10325 &batch_candidates.rows.len(),
10326 &1,
10327 "batch candidate deduplication",
10328 )?;
10329 require_eq(
10330 batch_candidates.rows[0].key(),
10331 &fixture.resolved,
10332 "batch candidate selecting key",
10333 )?;
10334 require_eq(
10335 batch_candidates.rows[0].entity(),
10336 &fixture.graph.entities[4],
10337 "batch candidate entity",
10338 )?;
10339 let affected = reader.repository_affected_source_paths(
10340 fixture.graph.project,
10341 &[
10342 fixture.resolved.clone(),
10343 fixture.resolved,
10344 fixture.ambiguous,
10345 fixture.unresolved,
10346 ],
10347 10,
10348 )?;
10349 require_eq(&affected.truncated, &false, "affected path truncation")?;
10350 require_eq(
10351 &affected.rows,
10352 &vec![RepositoryFilePath::new(Path::new("src/Äuth.rs"))?],
10353 "resolved ambiguous and unresolved dependency owners",
10354 )?;
10355 reader.finish_index_read_snapshot()?;
10356 Ok(())
10357 }
10358
10359 #[test]
10360 fn high_degree_dependency_closure_is_unique_and_reports_overflow_before_mutation()
10361 -> Result<(), Box<dyn Error>> {
10362 let temp = tempfile::tempdir()?;
10363 let project_root = temp.path().join("high-degree-resolution");
10364 fs::create_dir_all(&project_root)?;
10365 let db_path = project_root.join("projectatlas.db");
10366 let mut store = AtlasStore::open_for_project(&db_path, &project_root)?;
10367 let project = store
10368 .project_instance_id()?
10369 .ok_or_else(|| io::Error::other("bound identity is missing"))?;
10370 let generation = IndexGeneration::new(1);
10371 let dependency_key = declaration_key(
10372 project,
10373 "sharedTarget",
10374 GraphRelationKind::Extended(ExtendedRelationKind::References),
10375 )?;
10376 let mut nodes = vec![
10377 graph_node(".", NodeKind::Folder, None),
10378 graph_node("src", NodeKind::Folder, Some(".")),
10379 ];
10380 let mut entities = Vec::new();
10381 let mut relations = Vec::new();
10382 let mut dependencies = Vec::new();
10383 for index in 0..5 {
10384 let path = format!("src/caller-{index}.rs");
10385 nodes.push(graph_node(&path, NodeKind::File, Some("src")));
10386 let entity = GraphEntity::new(
10387 project,
10388 EntitySelector::File {
10389 path: RepositoryFilePath::new(Path::new(&path))?,
10390 },
10391 generation,
10392 )?;
10393 let relation = LogicalRelation::new(
10394 &entity,
10395 GraphRelationKind::Extended(ExtendedRelationKind::References),
10396 RelationResolution::Unresolved {
10397 reference: GraphIdentityText::new("sharedTarget")?,
10398 },
10399 ConfidenceClass::High,
10400 Completeness::Complete,
10401 generation,
10402 )?;
10403 dependencies.push(RelationDependencyKey::new(
10404 relation.key().clone(),
10405 dependency_key.clone(),
10406 )?);
10407 entities.push(entity);
10408 relations.push(relation);
10409 }
10410 dependencies.push(dependencies[0].clone());
10411 let mut publication = store.begin_index_publication("high-degree-resolution")?;
10412 publication.begin_scan_replacement()?;
10413 publication.upsert_scan_node_batch(&nodes)?;
10414 publication.finish_scan_replacement()?;
10415 publication.replace_repository_graph_with_resolution_keys(
10416 project,
10417 &entities,
10418 &relations,
10419 &[],
10420 &[],
10421 &[],
10422 &dependencies,
10423 )?;
10424 publication.complete()?;
10425
10426 let before = store.index_publication()?;
10427 let bounded = store.repository_affected_source_paths(
10428 project,
10429 std::slice::from_ref(&dependency_key),
10430 2,
10431 )?;
10432 require_eq(&bounded.rows.len(), &2, "bounded affected path count")?;
10433 require_eq(&bounded.truncated, &true, "bounded affected path overflow")?;
10434 require_eq(
10435 &store.index_publication()?,
10436 &before,
10437 "overflow lookup mutated publication state",
10438 )?;
10439 let complete = store.repository_affected_source_paths(project, &[dependency_key], 10)?;
10440 require_eq(&complete.rows.len(), &5, "complete affected path count")?;
10441 require_eq(
10442 &complete.truncated,
10443 &false,
10444 "complete affected path overflow",
10445 )?;
10446 let unique = complete.rows.iter().collect::<BTreeSet<_>>();
10447 require_eq(&unique.len(), &5, "affected paths are unique")?;
10448 Ok(())
10449 }
10450
10451 #[test]
10452 fn affected_source_footprint_accounts_exact_owned_rows_and_uses_path_indexes()
10453 -> Result<(), Box<dyn Error>> {
10454 let temp = tempfile::tempdir()?;
10455 let project_root = temp.path().join("affected-source-footprint");
10456 fs::create_dir_all(&project_root)?;
10457 let db_path = project_root.join("projectatlas.db");
10458 let mut store = AtlasStore::open_for_project(&db_path, &project_root)?;
10459 let fixture = publish_resolution_fixture(&mut store, "affected-source-footprint")?;
10460 replace_fixture_symbol_rows(&mut store, "src/Äuth.rs", 1)?;
10461 store.connection.execute(
10462 "INSERT INTO graph_coverage(
10463 project_instance_id, scope_kind, scope_path, relation_scope,
10464 relation_kind, state, total, covered, omitted, reason, reached_limit
10465 ) VALUES(?1, 'path', 'src/Äuth.rs', NULL, NULL,
10466 'complete', 1, 1, 0, NULL, NULL)",
10467 [&fixture.graph.project.as_bytes()[..]],
10468 )?;
10469
10470 let mut paths = (0..=RESOLUTION_PATHS_PER_QUERY)
10471 .map(|index| format!("missing/{index:04}.rs"))
10472 .collect::<Vec<_>>();
10473 paths.extend(["src/Äuth.rs".to_string(), "src/Äuth.rs".to_string()]);
10474 let footprint =
10475 store.repository_affected_source_footprint(fixture.graph.project, &paths, 100)?;
10476 require_eq(&footprint.rows, &20, "exact affected persisted rows")?;
10477 require(
10478 footprint.retained_bytes > footprint.rows,
10479 "affected footprint omitted decoded bytes",
10480 )?;
10481 require_eq(&footprint.truncated, &false, "exact footprint truncation")?;
10482
10483 let sql = format!("EXPLAIN QUERY PLAN {}", affected_source_footprint_sql(1));
10484 let values = [
10485 Value::Blob(fixture.graph.project.as_bytes().to_vec()),
10486 Value::Text("src/Äuth.rs".to_string()),
10487 Value::Integer(101),
10488 ];
10489 let mut statement = store.connection.prepare(&sql)?;
10490 let details = statement
10491 .query_map(params_from_iter(values.iter()), |row| {
10492 row.get::<_, String>(3)
10493 })?
10494 .collect::<Result<Vec<_>, _>>()?;
10495 for index in [
10496 "sqlite_autoindex_source_parse_metadata_1",
10497 "idx_symbols_path",
10498 "idx_symbol_relations_path",
10499 "idx_graph_entities_path",
10500 "idx_graph_entities_manifest_path",
10501 "idx_graph_relations_source_kind",
10502 "idx_graph_occurrences_file_span",
10503 "idx_graph_coverage_path",
10504 "idx_graph_entity_exports_owner",
10505 "idx_graph_relation_dependencies_owner",
10506 "sqlite_autoindex_graph_resolution_keys_1",
10507 ] {
10508 require(
10509 details.iter().any(|detail| detail.contains(index)),
10510 &format!("affected footprint missed {index}; plan was {details:?}"),
10511 )?;
10512 }
10513 require(
10514 details.iter().all(|detail| !detail.contains("SCAN graph_")),
10515 &format!("affected footprint scanned graph storage: {details:?}"),
10516 )?;
10517 Ok(())
10518 }
10519
10520 #[test]
10521 fn affected_source_footprint_reports_high_degree_overflow_before_mutation()
10522 -> Result<(), Box<dyn Error>> {
10523 let temp = tempfile::tempdir()?;
10524 let project_root = temp.path().join("affected-source-degree");
10525 fs::create_dir_all(&project_root)?;
10526 let db_path = project_root.join("projectatlas.db");
10527 let mut store = AtlasStore::open_for_project(&db_path, &project_root)?;
10528 let fixture = publish_resolution_fixture(&mut store, "affected-source-degree")?;
10529 replace_fixture_symbol_rows(&mut store, "src/Äuth.rs", 25)?;
10530 let before = store.index_publication()?;
10531
10532 let bounded = store.repository_affected_source_footprint(
10533 fixture.graph.project,
10534 &["src/Äuth.rs".to_string()],
10535 5,
10536 )?;
10537 require_eq(&bounded.rows, &6, "footprint limit plus one sentinel")?;
10538 require_eq(&bounded.truncated, &true, "high-degree footprint overflow")?;
10539 require(
10540 bounded.retained_bytes > 0,
10541 "bounded footprint lost retained bytes",
10542 )?;
10543 require_eq(
10544 &store.index_publication()?,
10545 &before,
10546 "footprint overflow mutated publication",
10547 )?;
10548 Ok(())
10549 }
10550
10551 #[test]
10552 fn affected_source_footprint_validates_inputs_identity_and_graph_availability()
10553 -> Result<(), Box<dyn Error>> {
10554 let temp = tempfile::tempdir()?;
10555 let selected_root = temp.path().join("selected");
10556 let other_root = temp.path().join("other");
10557 fs::create_dir_all(&selected_root)?;
10558 fs::create_dir_all(&other_root)?;
10559 let mut selected =
10560 AtlasStore::open_for_project(&selected_root.join("projectatlas.db"), &selected_root)?;
10561 let fixture = publish_resolution_fixture(&mut selected, "footprint-validation")?;
10562 let other = AtlasStore::open_for_project(&other_root.join("projectatlas.db"), &other_root)?;
10563 let other_project = other
10564 .project_instance_id()?
10565 .ok_or_else(|| io::Error::other("other bound identity is missing"))?;
10566
10567 let invalid_limit = require_db_error(
10568 selected.repository_affected_source_footprint(
10569 fixture.graph.project,
10570 &["src/Äuth.rs".to_string()],
10571 0,
10572 ),
10573 "zero footprint limit was accepted",
10574 )?;
10575 require(
10576 matches!(invalid_limit, DbError::GraphContract(_)),
10577 &format!("invalid footprint limit returned {invalid_limit}"),
10578 )?;
10579 let invalid_path = require_db_error(
10580 selected.repository_affected_source_footprint(
10581 fixture.graph.project,
10582 &["../outside.rs".to_string()],
10583 10,
10584 ),
10585 "escaping footprint path was accepted",
10586 )?;
10587 require(
10588 matches!(invalid_path, DbError::GraphContract(_)),
10589 &format!("invalid footprint path returned {invalid_path}"),
10590 )?;
10591 let mismatched = require_db_error(
10592 selected.repository_affected_source_footprint(
10593 other_project,
10594 &["src/Äuth.rs".to_string()],
10595 10,
10596 ),
10597 "mismatched project footprint was accepted",
10598 )?;
10599 require(
10600 matches!(mismatched, DbError::GraphProjectIdentityMismatch { .. }),
10601 &format!("mismatched footprint returned {mismatched}"),
10602 )?;
10603 require_eq(
10604 &other.repository_affected_source_footprint(
10605 other_project,
10606 &["src/Äuth.rs".to_string()],
10607 10,
10608 )?,
10609 &empty_affected_source_footprint(),
10610 "unpublished graph footprint",
10611 )?;
10612 Ok(())
10613 }
10614
10615 #[test]
10616 fn affected_source_footprint_rejects_missing_resolution_witnesses() -> Result<(), Box<dyn Error>>
10617 {
10618 let temp = tempfile::tempdir()?;
10619 let project_root = temp.path().join("affected-source-corruption");
10620 fs::create_dir_all(&project_root)?;
10621 let db_path = project_root.join("projectatlas.db");
10622 let mut store = AtlasStore::open_for_project(&db_path, &project_root)?;
10623 let fixture = publish_resolution_fixture(&mut store, "affected-source-corruption")?;
10624 store
10625 .connection
10626 .execute_batch("PRAGMA foreign_keys = OFF")?;
10627 store.connection.execute(
10628 "DELETE FROM graph_resolution_keys
10629 WHERE project_instance_id = ?1
10630 AND resolution_domain = ?2
10631 AND key_digest = ?3",
10632 params![
10633 &fixture.graph.project.as_bytes()[..],
10634 fixture.resolved.domain().as_str(),
10635 &fixture.resolved.digest_bytes()[..],
10636 ],
10637 )?;
10638 let error = require_db_error(
10639 store.repository_affected_source_footprint(
10640 fixture.graph.project,
10641 &["src/Äuth.rs".to_string()],
10642 100,
10643 ),
10644 "missing resolution witness returned a partial footprint",
10645 )?;
10646 require(
10647 matches!(error, DbError::Sqlite(_)),
10648 &format!("missing witness returned the wrong error: {error}"),
10649 )?;
10650 Ok(())
10651 }
10652
10653 #[test]
10654 fn resolution_key_failures_roll_back_and_owner_foreign_keys_cascade()
10655 -> Result<(), Box<dyn Error>> {
10656 let temp = tempfile::tempdir()?;
10657 let project_root = temp.path().join("resolution-failures");
10658 fs::create_dir_all(&project_root)?;
10659 let db_path = project_root.join("projectatlas.db");
10660 let mut store = AtlasStore::open_for_project(&db_path, &project_root)?;
10661 let fixture = publish_resolution_fixture(&mut store, "resolution-failures")?;
10662 let before_publication = store.index_publication()?;
10663 let before_counts = store.connection.query_row(
10664 "SELECT
10665 (SELECT COUNT(*) FROM graph_resolution_keys),
10666 (SELECT COUNT(*) FROM graph_entity_exports),
10667 (SELECT COUNT(*) FROM graph_relation_dependencies)",
10668 [],
10669 |row| {
10670 Ok((
10671 row.get::<_, i64>(0)?,
10672 row.get::<_, i64>(1)?,
10673 row.get::<_, i64>(2)?,
10674 ))
10675 },
10676 )?;
10677
10678 let replacement = graph_fixture(fixture.graph.project, IndexGeneration::new(2))?;
10679 let invalid_export = EntityResolutionKey::new(
10680 replacement.entities[0].key().clone(),
10681 fixture.resolved.clone(),
10682 )?;
10683 {
10684 let mut publication = store.begin_index_publication("resolution-failures")?;
10685 let error = require_db_error(
10686 publication.replace_repository_graph_with_resolution_keys(
10687 replacement.project,
10688 &replacement.entities,
10689 &replacement.relations,
10690 &replacement.occurrences,
10691 &replacement.coverage,
10692 &[invalid_export],
10693 &[],
10694 ),
10695 "source-less export owner unexpectedly published",
10696 )?;
10697 require(
10698 matches!(error, DbError::GraphRowShape { .. }),
10699 &format!("invalid export owner returned the wrong error: {error}"),
10700 )?;
10701 }
10702 require_eq(
10703 &store.index_publication()?,
10704 &before_publication,
10705 "failed key publication generation",
10706 )?;
10707 let after_counts = store.connection.query_row(
10708 "SELECT
10709 (SELECT COUNT(*) FROM graph_resolution_keys),
10710 (SELECT COUNT(*) FROM graph_entity_exports),
10711 (SELECT COUNT(*) FROM graph_relation_dependencies)",
10712 [],
10713 |row| {
10714 Ok((
10715 row.get::<_, i64>(0)?,
10716 row.get::<_, i64>(1)?,
10717 row.get::<_, i64>(2)?,
10718 ))
10719 },
10720 )?;
10721 require_eq(
10722 &after_counts,
10723 &before_counts,
10724 "failed key publication durable rows",
10725 )?;
10726 require_eq(
10727 &store
10728 .repository_resolution_candidates(&fixture.resolved, 10)?
10729 .rows,
10730 &vec![fixture.graph.entities[4].clone()],
10731 "failed key publication previous candidates",
10732 )?;
10733
10734 store.connection.execute(
10735 "UPDATE graph_resolution_keys
10736 SET canonical_identity = 'conflicting collision witness'
10737 WHERE project_instance_id = ?1
10738 AND resolution_domain = ?2
10739 AND key_digest = ?3",
10740 params![
10741 &fixture.graph.project.as_bytes()[..],
10742 fixture.resolved.domain().as_str(),
10743 &fixture.resolved.digest_bytes()[..],
10744 ],
10745 )?;
10746 let collision = require_db_error(
10747 store.repository_resolution_candidates(&fixture.resolved, 10),
10748 "conflicting resolution witness was accepted",
10749 )?;
10750 require(
10751 matches!(collision, DbError::ResolutionKeyCollision { .. }),
10752 &format!("conflicting witness returned the wrong error: {collision}"),
10753 )?;
10754 let corrupt_page = require_db_error(
10755 store.repository_export_keys_for_paths(
10756 fixture.graph.project,
10757 &["src/Äuth.rs".to_string()],
10758 10,
10759 ),
10760 "corrupt witness returned a partial key page",
10761 )?;
10762 require(
10763 matches!(corrupt_page, DbError::GraphContract(_)),
10764 &format!("corrupt witness returned the wrong page error: {corrupt_page}"),
10765 )?;
10766 store.connection.execute(
10767 "UPDATE graph_resolution_keys
10768 SET canonical_identity = ?1
10769 WHERE project_instance_id = ?2
10770 AND resolution_domain = ?3
10771 AND key_digest = ?4",
10772 params![
10773 fixture.resolved.canonical_identity(),
10774 &fixture.graph.project.as_bytes()[..],
10775 fixture.resolved.domain().as_str(),
10776 &fixture.resolved.digest_bytes()[..],
10777 ],
10778 )?;
10779
10780 let relation_digest = fixture.graph.relations[2].key().digest_bytes()?;
10781 store.connection.execute(
10782 "DELETE FROM graph_relations
10783 WHERE project_instance_id = ?1 AND relation_key = ?2",
10784 params![&fixture.graph.project.as_bytes()[..], &relation_digest[..]],
10785 )?;
10786 let remaining_dependencies = store.connection.query_row(
10787 "SELECT COUNT(*) FROM graph_relation_dependencies",
10788 [],
10789 |row| row.get::<_, i64>(0),
10790 )?;
10791 require_eq(
10792 &remaining_dependencies,
10793 &(before_counts.2 - 1),
10794 "relation-owner dependency cascade",
10795 )?;
10796 let entity_digest = fixture.graph.entities[4].key().digest_bytes()?;
10797 store.connection.execute(
10798 "DELETE FROM graph_entities
10799 WHERE project_instance_id = ?1 AND entity_key = ?2",
10800 params![&fixture.graph.project.as_bytes()[..], &entity_digest[..]],
10801 )?;
10802 let remaining_exports =
10803 store
10804 .connection
10805 .query_row("SELECT COUNT(*) FROM graph_entity_exports", [], |row| {
10806 row.get::<_, i64>(0)
10807 })?;
10808 require_eq(&remaining_exports, &0, "entity-owner export cascade")?;
10809 Ok(())
10810 }
10811
10812 #[test]
10813 fn affected_replacement_swaps_resolution_keys_and_collects_only_touched_orphans()
10814 -> Result<(), Box<dyn Error>> {
10815 let temp = tempfile::tempdir()?;
10816 let project_root = temp.path().join("resolution-replacement");
10817 fs::create_dir_all(&project_root)?;
10818 let db_path = project_root.join("projectatlas.db");
10819 let mut store = AtlasStore::open_for_project(&db_path, &project_root)?;
10820 let first = publish_resolution_fixture(&mut store, "resolution-replacement")?;
10821 let replacement = graph_fixture(first.graph.project, IndexGeneration::new(2))?;
10822 let renamed = declaration_key(
10823 first.graph.project,
10824 "verifySession",
10825 GraphRelationKind::Legacy(RelationKind::Calls),
10826 )?;
10827 let exports = vec![EntityResolutionKey::new(
10828 replacement.entities[4].key().clone(),
10829 renamed.clone(),
10830 )?];
10831 let dependencies = vec![
10832 RelationDependencyKey::new(replacement.relations[0].key().clone(), renamed.clone())?,
10833 RelationDependencyKey::new(
10834 replacement.relations[1].key().clone(),
10835 first.ambiguous.clone(),
10836 )?,
10837 RelationDependencyKey::new(
10838 replacement.relations[2].key().clone(),
10839 first.unresolved.clone(),
10840 )?,
10841 ];
10842 let mut publication = store.begin_index_publication("resolution-replacement")?;
10843 publication.replace_repository_graph_for_paths_with_resolution_keys(
10844 replacement.project,
10845 &["src/Äuth.rs".to_string()],
10846 &replacement.entities,
10847 &replacement.relations,
10848 &replacement.occurrences,
10849 &replacement.coverage,
10850 &exports,
10851 &dependencies,
10852 )?;
10853 publication.complete()?;
10854
10855 require_eq(
10856 &store
10857 .repository_resolution_candidates(&first.resolved, 10)?
10858 .rows
10859 .len(),
10860 &0,
10861 "removed export candidates",
10862 )?;
10863 require_eq(
10864 &store
10865 .repository_affected_source_paths(
10866 first.graph.project,
10867 std::slice::from_ref(&first.resolved),
10868 10,
10869 )?
10870 .rows
10871 .len(),
10872 &0,
10873 "removed dependency owners",
10874 )?;
10875 require_eq(
10876 &store.repository_resolution_candidates(&renamed, 10)?.rows,
10877 &vec![replacement.entities[4].clone()],
10878 "renamed export candidates",
10879 )?;
10880 let exports = store.repository_export_keys_for_paths(
10881 first.graph.project,
10882 &["src/Äuth.rs".to_string()],
10883 10,
10884 )?;
10885 require_eq(&exports.rows, &vec![renamed], "replacement export keys")?;
10886 let registry_rows = store.connection.query_row(
10887 "SELECT COUNT(*) FROM graph_resolution_keys",
10888 [],
10889 |row| row.get::<_, i64>(0),
10890 )?;
10891 require_eq(®istry_rows, &3, "touched orphan witness cleanup")?;
10892 require_eq(
10893 &store
10894 .index_publication()?
10895 .ok_or_else(|| io::Error::other("replacement publication is missing"))?
10896 .generation,
10897 &IndexGeneration::new(2),
10898 "replacement generation",
10899 )?;
10900 Ok(())
10901 }
10902
10903 #[test]
10904 fn affected_graph_replacement_preserves_only_the_unaffected_closure()
10905 -> Result<(), Box<dyn Error>> {
10906 let temp = tempfile::tempdir()?;
10907 let project_root = temp.path().join("affected-closure");
10908 let atlas_dir = project_root.join(".projectatlas");
10909 fs::create_dir_all(&atlas_dir)?;
10910 let db_path = atlas_dir.join("projectatlas.db");
10911 let mut store = AtlasStore::open_for_project(&db_path, &project_root)?;
10912
10913 let project = store
10914 .project_instance_id()?
10915 .ok_or_else(|| io::Error::other("bound affected-closure identity is missing"))?;
10916 let generation_one = IndexGeneration::new(1);
10917 let project_entity = GraphEntity::new(project, EntitySelector::Project, generation_one)?;
10918 let affected_folder = GraphEntity::new(
10919 project,
10920 EntitySelector::Folder {
10921 path: RepositoryNodePath::new(Path::new("src/a"))?,
10922 },
10923 generation_one,
10924 )?;
10925 let affected_file = GraphEntity::new(
10926 project,
10927 EntitySelector::File {
10928 path: RepositoryFilePath::new(Path::new("src/a/local.rs"))?,
10929 },
10930 generation_one,
10931 )?;
10932 let case_distinct_file = GraphEntity::new(
10933 project,
10934 EntitySelector::File {
10935 path: RepositoryFilePath::new(Path::new("src/A/keep.rs"))?,
10936 },
10937 generation_one,
10938 )?;
10939 let package = GraphEntity::new(
10940 project,
10941 EntitySelector::Package {
10942 package: PackageSelector {
10943 manager: GraphIdentityText::new("cargo")?,
10944 name: GraphIdentityText::new("api")?,
10945 manifest: RepositoryFilePath::new(Path::new("packages/api/Cargo.toml"))?,
10946 },
10947 },
10948 generation_one,
10949 )?;
10950 let orphan_external = GraphEntity::new(
10951 project,
10952 EntitySelector::External {
10953 external: ExternalSelector {
10954 system: GraphIdentityText::new("crates.io")?,
10955 identity: GraphIdentityText::new("orphan@1")?,
10956 },
10957 },
10958 generation_one,
10959 )?;
10960 let retained_external = GraphEntity::new(
10961 project,
10962 EntitySelector::External {
10963 external: ExternalSelector {
10964 system: GraphIdentityText::new("crates.io")?,
10965 identity: GraphIdentityText::new("retained@1")?,
10966 },
10967 },
10968 generation_one,
10969 )?;
10970 let occurrence_owned_external = GraphEntity::new(
10971 project,
10972 EntitySelector::External {
10973 external: ExternalSelector {
10974 system: GraphIdentityText::new("crates.io")?,
10975 identity: GraphIdentityText::new("occurrence-owned@1")?,
10976 },
10977 },
10978 generation_one,
10979 )?;
10980 let affected_relation = LogicalRelation::new(
10981 &affected_file,
10982 GraphRelationKind::Legacy(RelationKind::DependsOn),
10983 RelationResolution::external(&orphan_external)?,
10984 ConfidenceClass::Exact,
10985 Completeness::Complete,
10986 generation_one,
10987 )?;
10988 let package_relation = LogicalRelation::new(
10989 &package,
10990 GraphRelationKind::Legacy(RelationKind::DependsOn),
10991 RelationResolution::external(&orphan_external)?,
10992 ConfidenceClass::Exact,
10993 Completeness::Complete,
10994 generation_one,
10995 )?;
10996 let retained_relation = LogicalRelation::new(
10997 &case_distinct_file,
10998 GraphRelationKind::Legacy(RelationKind::DependsOn),
10999 RelationResolution::external(&retained_external)?,
11000 ConfidenceClass::Exact,
11001 Completeness::Complete,
11002 generation_one,
11003 )?;
11004 let project_external_relation = LogicalRelation::new(
11005 &project_entity,
11006 GraphRelationKind::Legacy(RelationKind::DependsOn),
11007 RelationResolution::external(&retained_external)?,
11008 ConfidenceClass::Exact,
11009 Completeness::Complete,
11010 generation_one,
11011 )?;
11012 let occurrence_backed_project_relation = LogicalRelation::new(
11013 &project_entity,
11014 GraphRelationKind::Legacy(RelationKind::DependsOn),
11015 RelationResolution::external(&occurrence_owned_external)?,
11016 ConfidenceClass::Exact,
11017 Completeness::Complete,
11018 generation_one,
11019 )?;
11020 let affected_occurrence = RelationOccurrence::new(
11021 &affected_relation,
11022 RepositoryFilePath::new(Path::new("src/a/local.rs"))?,
11023 SourceSpan::new(3, 0, 3, 12)?,
11024 generation_one,
11025 )?;
11026 let retained_occurrence = RelationOccurrence::new(
11027 &retained_relation,
11028 RepositoryFilePath::new(Path::new("src/A/keep.rs"))?,
11029 SourceSpan::new(5, 0, 5, 14)?,
11030 generation_one,
11031 )?;
11032 let retained_relation_affected_occurrence = RelationOccurrence::new(
11033 &retained_relation,
11034 RepositoryFilePath::new(Path::new("src/a/local.rs"))?,
11035 SourceSpan::new(6, 0, 6, 14)?,
11036 generation_one,
11037 )?;
11038 let project_relation_occurrence = RelationOccurrence::new(
11039 &occurrence_backed_project_relation,
11040 RepositoryFilePath::new(Path::new("src/a/local.rs"))?,
11041 SourceSpan::new(9, 0, 9, 16)?,
11042 generation_one,
11043 )?;
11044 let initial_coverage = vec![
11045 CoverageRecord::new(
11046 CoverageScope::Project,
11047 None,
11048 CoverageState::Complete,
11049 4,
11050 0,
11051 generation_one,
11052 None,
11053 None,
11054 )?,
11055 CoverageRecord::new(
11056 CoverageScope::Path {
11057 path: RepositoryNodePath::new(Path::new("src/a"))?,
11058 },
11059 None,
11060 CoverageState::Partial,
11061 1,
11062 1,
11063 generation_one,
11064 Some(GraphIdentityText::new("affected coverage")?),
11065 Some(GraphLimitKind::Rows),
11066 )?,
11067 CoverageRecord::new(
11068 CoverageScope::Path {
11069 path: RepositoryNodePath::new(Path::new("src/A"))?,
11070 },
11071 None,
11072 CoverageState::Complete,
11073 1,
11074 0,
11075 generation_one,
11076 None,
11077 None,
11078 )?,
11079 ];
11080 {
11081 let mut publication = store.begin_index_publication("affected-closure")?;
11082 publication.begin_scan_replacement()?;
11083 publication.upsert_scan_node_batch(&[
11084 graph_node(".", NodeKind::Folder, None),
11085 graph_node("src", NodeKind::Folder, Some(".")),
11086 graph_node("src/a", NodeKind::Folder, Some("src")),
11087 graph_node("src/a/local.rs", NodeKind::File, Some("src/a")),
11088 graph_node("src/a/new.rs", NodeKind::File, Some("src/a")),
11089 graph_node("src/A", NodeKind::Folder, Some("src")),
11090 graph_node("src/A/keep.rs", NodeKind::File, Some("src/A")),
11091 graph_node("packages", NodeKind::Folder, Some(".")),
11092 graph_node("packages/api", NodeKind::Folder, Some("packages")),
11093 graph_node(
11094 "packages/api/Cargo.toml",
11095 NodeKind::File,
11096 Some("packages/api"),
11097 ),
11098 graph_node("README.md", NodeKind::File, Some(".")),
11099 ])?;
11100 publication.finish_scan_replacement()?;
11101 publication.replace_repository_graph(
11102 project,
11103 &[
11104 project_entity.clone(),
11105 affected_folder,
11106 affected_file.clone(),
11107 case_distinct_file.clone(),
11108 package.clone(),
11109 orphan_external.clone(),
11110 retained_external.clone(),
11111 occurrence_owned_external.clone(),
11112 ],
11113 &[
11114 affected_relation,
11115 package_relation,
11116 retained_relation,
11117 project_external_relation,
11118 occurrence_backed_project_relation,
11119 ],
11120 &[
11121 affected_occurrence,
11122 retained_occurrence,
11123 retained_relation_affected_occurrence,
11124 project_relation_occurrence,
11125 ],
11126 &initial_coverage,
11127 )?;
11128 publication.complete()?;
11129 }
11130
11131 let affected_paths = [
11132 RepositoryNodePath::new(Path::new("src/a"))?,
11133 RepositoryNodePath::new(Path::new("packages/api/Cargo.toml"))?,
11134 ];
11135 let (candidates, statements) = trace_statements(&mut store, |store| {
11136 affected_external_candidates(&store.connection, &affected_paths)
11137 })?;
11138 require(
11139 candidates.contains(&orphan_external.key().digest_bytes()?),
11140 "batched external cleanup omitted an affected candidate",
11141 )?;
11142 require_eq(
11143 &statements
11144 .iter()
11145 .filter(|statement| {
11146 statement.contains("WITH affected(entity_key)")
11147 && statement.contains("idx_graph_relations_source_kind")
11148 && statement.contains("idx_graph_relations_target_kind")
11149 })
11150 .count(),
11151 &1,
11152 "bounded external candidate adjacency batches",
11153 )?;
11154
11155 let generation_two = IndexGeneration::new(2);
11156 let replacement_folder = GraphEntity::new(
11157 project,
11158 EntitySelector::Folder {
11159 path: RepositoryNodePath::new(Path::new("src/a"))?,
11160 },
11161 generation_two,
11162 )?;
11163 let replacement_file = GraphEntity::new(
11164 project,
11165 EntitySelector::File {
11166 path: RepositoryFilePath::new(Path::new("src/a/new.rs"))?,
11167 },
11168 generation_two,
11169 )?;
11170 let retained_external_for_relation = GraphEntity::new(
11171 project,
11172 retained_external.selector().clone(),
11173 generation_two,
11174 )?;
11175 let replacement_relation = LogicalRelation::new(
11176 &replacement_file,
11177 GraphRelationKind::Legacy(RelationKind::DependsOn),
11178 RelationResolution::external(&retained_external_for_relation)?,
11179 ConfidenceClass::Exact,
11180 Completeness::Complete,
11181 generation_two,
11182 )?;
11183 let replacement_occurrence = RelationOccurrence::new(
11184 &replacement_relation,
11185 RepositoryFilePath::new(Path::new("src/a/new.rs"))?,
11186 SourceSpan::new(7, 0, 7, 10)?,
11187 generation_two,
11188 )?;
11189 let replacement_coverage = CoverageRecord::new(
11190 CoverageScope::Path {
11191 path: RepositoryNodePath::new(Path::new("src/a"))?,
11192 },
11193 None,
11194 CoverageState::Complete,
11195 1,
11196 0,
11197 generation_two,
11198 None,
11199 None,
11200 )?;
11201 {
11202 let mut publication = store.begin_index_publication("affected-closure")?;
11203 publication.replace_repository_graph_for_paths(
11204 project,
11205 &["src/a".to_string(), "packages/api/Cargo.toml".to_string()],
11206 &[replacement_folder, replacement_file.clone()],
11207 &[replacement_relation],
11208 &[replacement_occurrence],
11209 &[replacement_coverage],
11210 )?;
11211 publication.complete()?;
11212 }
11213
11214 drop(store);
11215 let store = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
11216
11217 require_eq(
11218 &store.repository_graph_entity(affected_file.key())?,
11219 &None,
11220 "affected descendant removal",
11221 )?;
11222 require_eq(
11223 &store.repository_graph_entity(package.key())?,
11224 &None,
11225 "manifest-owned package removal",
11226 )?;
11227 require_eq(
11228 &store.repository_graph_entity(orphan_external.key())?,
11229 &None,
11230 "candidate-bounded orphan external cleanup",
11231 )?;
11232 require_eq(
11233 &store.repository_graph_entity(occurrence_owned_external.key())?,
11234 &None,
11235 "final affected occurrence relation and external cleanup",
11236 )?;
11237 let preserved_case = store
11238 .repository_graph_entity(case_distinct_file.key())?
11239 .ok_or_else(|| io::Error::other("case-distinct sibling was removed"))?;
11240 require_eq(
11241 &preserved_case.generation(),
11242 &generation_two,
11243 "case-distinct sibling generation injection",
11244 )?;
11245 let preserved_external = store
11246 .repository_graph_entity(retained_external.key())?
11247 .ok_or_else(|| io::Error::other("referenced external entity was removed"))?;
11248 require_eq(
11249 &preserved_external.generation(),
11250 &generation_two,
11251 "unaffected external generation injection",
11252 )?;
11253 let retained_relations = store.repository_graph_relations(
11254 RepositoryGraphRelationQuery::Outbound {
11255 source: case_distinct_file.key().clone(),
11256 },
11257 10,
11258 )?;
11259 require_eq(
11260 &retained_relations.rows.len(),
11261 &1,
11262 "relation with one unaffected occurrence",
11263 )?;
11264 require_eq(
11265 &store
11266 .repository_graph_occurrences(&retained_relations.rows[0], 10)?
11267 .rows
11268 .len(),
11269 &1,
11270 "only the affected occurrence was removed",
11271 )?;
11272 let replacement_relations = store.repository_graph_relations(
11273 RepositoryGraphRelationQuery::Outbound {
11274 source: replacement_file.key().clone(),
11275 },
11276 10,
11277 )?;
11278 require_eq(
11279 &replacement_relations.rows.len(),
11280 &1,
11281 "replacement relation count",
11282 )?;
11283 require_eq(
11284 &store
11285 .repository_graph_occurrences(&replacement_relations.rows[0], 10)?
11286 .rows
11287 .len(),
11288 &1,
11289 "replacement source occurrence",
11290 )?;
11291 let affected_coverage = store.repository_graph_coverage(
11292 project,
11293 &CoverageScope::Path {
11294 path: RepositoryNodePath::new(Path::new("src/a"))?,
11295 },
11296 10,
11297 )?;
11298 require(
11299 affected_coverage.rows.len() == 1
11300 && affected_coverage.rows[0].state() == CoverageState::Complete,
11301 "affected path coverage was not replaced",
11302 )?;
11303 let case_coverage = store.repository_graph_coverage(
11304 project,
11305 &CoverageScope::Path {
11306 path: RepositoryNodePath::new(Path::new("src/A"))?,
11307 },
11308 10,
11309 )?;
11310 require_eq(
11311 &case_coverage.rows.len(),
11312 &1,
11313 "case-distinct coverage preservation",
11314 )?;
11315 require_eq(
11316 &store
11317 .repository_graph_coverage(project, &CoverageScope::Project, 10)?
11318 .rows
11319 .len(),
11320 &1,
11321 "unaffected project coverage preservation",
11322 )?;
11323 require_eq(
11324 &store
11325 .repository_graph_relations(
11326 RepositoryGraphRelationQuery::Outbound {
11327 source: project_entity.key().clone(),
11328 },
11329 10,
11330 )?
11331 .rows
11332 .len(),
11333 &1,
11334 "project-to-external relation preservation",
11335 )?;
11336
11337 store.finish_index_read_snapshot()?;
11338 drop(store);
11339 let mut store = AtlasStore::open_for_project(&db_path, &project_root)?;
11340 let generation_three = IndexGeneration::new(3);
11341 let root_project = GraphEntity::new(project, EntitySelector::Project, generation_three)?;
11342 let readme = GraphEntity::new(
11343 project,
11344 EntitySelector::File {
11345 path: RepositoryFilePath::new(Path::new("README.md"))?,
11346 },
11347 generation_three,
11348 )?;
11349 let root_coverage = CoverageRecord::new(
11350 CoverageScope::Project,
11351 None,
11352 CoverageState::Complete,
11353 1,
11354 0,
11355 generation_three,
11356 None,
11357 None,
11358 )?;
11359 {
11360 let mut publication = store.begin_index_publication("affected-closure")?;
11361 publication.replace_repository_graph_for_paths(
11362 project,
11363 &[".".to_string()],
11364 &[root_project.clone(), readme],
11365 &[],
11366 &[],
11367 &[root_coverage],
11368 )?;
11369 publication.complete()?;
11370 }
11371 drop(store);
11372 let store = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
11373 require_eq(
11374 &store.repository_graph_entity(case_distinct_file.key())?,
11375 &None,
11376 "root replacement stale local removal",
11377 )?;
11378 require_eq(
11379 &store.repository_graph_entity(retained_external.key())?,
11380 &None,
11381 "root replacement external removal",
11382 )?;
11383 require_eq(
11384 &store
11385 .repository_graph_relations(
11386 RepositoryGraphRelationQuery::Outbound {
11387 source: root_project.key().clone(),
11388 },
11389 10,
11390 )?
11391 .rows
11392 .len(),
11393 &0,
11394 "root replacement project relation removal",
11395 )?;
11396 require_eq(
11397 &store
11398 .repository_graph_coverage(project, &CoverageScope::Project, 10)?
11399 .rows
11400 .len(),
11401 &1,
11402 "root replacement project coverage",
11403 )?;
11404 require_eq(
11405 &store
11406 .repository_graph_coverage(
11407 project,
11408 &CoverageScope::Path {
11409 path: RepositoryNodePath::new(Path::new("src/A"))?,
11410 },
11411 10,
11412 )?
11413 .rows
11414 .len(),
11415 &0,
11416 "root replacement path coverage removal",
11417 )?;
11418
11419 store.finish_index_read_snapshot()?;
11420 drop(store);
11421 let mut store = AtlasStore::open_for_project(&db_path, &project_root)?;
11422 {
11423 let mut projection = store.begin_index_projection_refresh("affected-closure")?;
11424 projection.replace_file_texts_for_paths(
11425 &["README.md".to_string()],
11426 &[IndexedFileText {
11427 path: "README.md".to_string(),
11428 content_hash: Some("readme-hash".to_string()),
11429 byte_count: 7,
11430 line_count: 1,
11431 content: "# Atlas".to_string(),
11432 }],
11433 )?;
11434 projection.complete()?;
11435 }
11436 drop(store);
11437 let store = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
11438 let error = require_db_error(
11439 store.repository_graph_entity(root_project.key()),
11440 "non-graph publication blessed stale graph rows",
11441 )?;
11442 require(
11443 matches!(
11444 error,
11445 DbError::GraphRowShape {
11446 table: "project_identity",
11447 ..
11448 }
11449 ),
11450 &format!("unexpected stale graph generation error: {error}"),
11451 )?;
11452 store.finish_index_read_snapshot()?;
11453 Ok(())
11454 }
11455
11456 fn require_graph_projection(
11458 store: &AtlasStore,
11459 fixture: &GraphFixture,
11460 generation: IndexGeneration,
11461 lexical_content: &str,
11462 ) -> Result<(), Box<dyn Error>> {
11463 let publication = store
11464 .index_publication()?
11465 .ok_or_else(|| io::Error::other("graph publication metadata missing"))?;
11466 require_eq(
11467 &publication.state,
11468 &IndexPublicationState::Complete,
11469 "graph publication state",
11470 )?;
11471 require_eq(
11472 &publication.generation,
11473 &generation,
11474 "graph publication generation",
11475 )?;
11476 let source = fixture
11477 .entities
11478 .iter()
11479 .find(|entity| matches!(entity.selector(), EntitySelector::File { .. }))
11480 .ok_or_else(|| io::Error::other("source file fixture missing"))?;
11481 let entity = store
11482 .repository_graph_entity(source.key())?
11483 .ok_or_else(|| io::Error::other("source graph entity missing"))?;
11484 require_eq(&entity.generation(), &generation, "graph entity generation")?;
11485 let relations = store.repository_graph_relations(
11486 RepositoryGraphRelationQuery::Outbound {
11487 source: source.key().clone(),
11488 },
11489 10,
11490 )?;
11491 require_eq(&relations.rows.len(), &4, "graph relation count")?;
11492 require(
11493 relations
11494 .rows
11495 .iter()
11496 .all(|relation| relation.generation() == generation),
11497 "graph relation generation mismatch",
11498 )?;
11499 let calls = relations
11500 .rows
11501 .iter()
11502 .find(|relation| relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls))
11503 .ok_or_else(|| io::Error::other("call relation missing"))?;
11504 let occurrences = store.repository_graph_occurrences(calls, 10)?;
11505 require_eq(&occurrences.rows.len(), &2, "graph occurrence count")?;
11506 require(
11507 occurrences
11508 .rows
11509 .iter()
11510 .all(|occurrence| occurrence.generation() == generation),
11511 "graph occurrence generation mismatch",
11512 )?;
11513 let occurrence_pages = store.repository_graph_occurrence_pages(&relations.rows, 1, None)?;
11514 require_eq(
11515 &occurrence_pages.len(),
11516 &relations.rows.len(),
11517 "batched occurrence page count",
11518 )?;
11519 for (relation, page) in relations.rows.iter().zip(&occurrence_pages) {
11520 let is_calls = relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls);
11521 require_eq(
11522 &page.rows.len(),
11523 &usize::from(is_calls),
11524 "batched occurrence rows",
11525 )?;
11526 require_eq(&page.truncated, &is_calls, "batched occurrence truncation")?;
11527 }
11528 let coverage =
11529 store.repository_graph_coverage(fixture.project, &CoverageScope::Project, 10)?;
11530 let expected_coverage = fixture
11531 .coverage
11532 .iter()
11533 .filter(|record| matches!(record.scope(), CoverageScope::Project))
11534 .count();
11535 require_eq(
11536 &coverage.rows.len(),
11537 &expected_coverage,
11538 "graph coverage count",
11539 )?;
11540 require(
11541 coverage
11542 .rows
11543 .iter()
11544 .all(|record| record.generation() == generation),
11545 "graph coverage generation mismatch",
11546 )?;
11547 let lexical = store
11548 .load_file_text("src/Äuth.rs")?
11549 .ok_or_else(|| io::Error::other("lexical source row missing"))?;
11550 require_eq(
11551 &lexical.content.as_str(),
11552 &lexical_content,
11553 "lexical source generation",
11554 )?;
11555 require_eq(
11556 &store.symbol_relation_count()?,
11557 &1,
11558 "legacy symbol relation compatibility",
11559 )?;
11560 Ok(())
11561 }
11562
11563 #[test]
11564 fn document_relations_require_closed_reasons_and_reuse_indexed_inbound_reads()
11565 -> Result<(), Box<dyn Error>> {
11566 let temp = tempfile::tempdir()?;
11567 let root = temp.path().join("document-graph");
11568 fs::create_dir(&root)?;
11569 let database = temp.path().join("projectatlas.db");
11570 let mut store = AtlasStore::open_for_project(&database, &root)?;
11571 let project = store
11572 .project_instance_id()?
11573 .ok_or_else(|| io::Error::other("document fixture identity is missing"))?;
11574 let generation = IndexGeneration::new(1);
11575 let document_path = RepositoryFilePath::new(Path::new("docs/guide.md"))?;
11576 let document = GraphEntity::new(
11577 project,
11578 EntitySelector::File {
11579 path: document_path.clone(),
11580 },
11581 generation,
11582 )?;
11583 let heading = GraphEntity::new(
11584 project,
11585 EntitySelector::Symbol {
11586 symbol: SymbolSelector {
11587 file: document_path,
11588 name: GraphIdentityText::new("Installation")?,
11589 kind: SymbolKind::Heading,
11590 parent: None,
11591 signature: GraphIdentityText::new("# Installation")?,
11592 },
11593 },
11594 generation,
11595 )?;
11596 let unresolved_document = LogicalRelation::new(
11597 &document,
11598 GraphRelationKind::Extended(ExtendedRelationKind::Documents),
11599 RelationResolution::Unresolved {
11600 reference: GraphIdentityText::new("missing.md#setup")?,
11601 },
11602 ConfidenceClass::Exact,
11603 Completeness::Complete,
11604 generation,
11605 )?;
11606 let resolved_document = LogicalRelation::new(
11607 &document,
11608 GraphRelationKind::Extended(ExtendedRelationKind::Documents),
11609 RelationResolution::resolved(&heading)?,
11610 ConfidenceClass::Exact,
11611 Completeness::Complete,
11612 generation,
11613 )?;
11614 let unrelated = LogicalRelation::new(
11615 &document,
11616 GraphRelationKind::Extended(ExtendedRelationKind::Configures),
11617 RelationResolution::Unresolved {
11618 reference: GraphIdentityText::new("setting")?,
11619 },
11620 ConfidenceClass::Low,
11621 Completeness::Partial,
11622 generation,
11623 )?;
11624
11625 let mut publication = store.begin_index_publication("document-reasons")?;
11626 publication.begin_scan_replacement()?;
11627 publication.upsert_scan_node_batch(&[
11628 graph_node(".", NodeKind::Folder, None),
11629 graph_node("docs", NodeKind::Folder, Some(".")),
11630 graph_node("docs/guide.md", NodeKind::File, Some("docs")),
11631 ])?;
11632 publication.finish_scan_replacement()?;
11633 publication.replace_repository_graph(
11634 project,
11635 &[document, heading.clone()],
11636 &[
11637 unresolved_document.clone(),
11638 resolved_document.clone(),
11639 unrelated.clone(),
11640 ],
11641 &[],
11642 &[],
11643 )?;
11644 let incomplete = require_db_error(
11645 validate_complete_document_unresolved_reasons(&publication.connection),
11646 "unresolved document relation omitted its reason",
11647 )?;
11648 require(
11649 matches!(incomplete, DbError::GraphRowShape { .. }),
11650 "missing document reason returned the wrong error",
11651 )?;
11652 let mixed = require_db_error(
11653 publication.set_document_unresolved_reasons(&[
11654 (
11655 unresolved_document.key().clone(),
11656 DocumentTargetUnresolvedReason::Missing,
11657 ),
11658 (
11659 unrelated.key().clone(),
11660 DocumentTargetUnresolvedReason::Unsupported,
11661 ),
11662 ]),
11663 "non-document relation accepted a document reason",
11664 )?;
11665 require(
11666 matches!(mixed, DbError::GraphRowShape { .. }),
11667 "mixed document-reason batch returned the wrong error",
11668 )?;
11669 let unresolved_key = unresolved_document.key().digest_bytes()?;
11670 let retained_reason = publication.connection.query_row(
11671 "SELECT document_unresolved_reason FROM graph_relations WHERE relation_key = ?1",
11672 [&unresolved_key[..]],
11673 |row| row.get::<_, Option<String>>(0),
11674 )?;
11675 require(
11676 retained_reason.is_none(),
11677 "failed document-reason batch exposed partial mutation",
11678 )?;
11679 publication.set_document_unresolved_reasons(&[(
11680 unresolved_document.key().clone(),
11681 DocumentTargetUnresolvedReason::Missing,
11682 )])?;
11683 publication.complete()?;
11684
11685 let family = store.repository_graph_relation_rows(
11686 RepositoryGraphRelationQuery::Family {
11687 relation: GraphRelationKind::Extended(ExtendedRelationKind::Documents),
11688 },
11689 10,
11690 None,
11691 )?;
11692 require_eq(&family.rows.len(), &2, "document relation family")?;
11693 require(
11694 family.rows.iter().any(|row| {
11695 matches!(
11696 row.relation.resolution(),
11697 RelationResolution::Unresolved { .. }
11698 ) && row.document_unresolved_reason == Some(DocumentTargetUnresolvedReason::Missing)
11699 }) && family.rows.iter().any(|row| {
11700 matches!(
11701 row.relation.resolution(),
11702 RelationResolution::Resolved { .. }
11703 ) && row.document_unresolved_reason.is_none()
11704 }),
11705 "document reason or resolved relation did not round-trip",
11706 )?;
11707 let inbound = store.repository_graph_relation_rows(
11708 RepositoryGraphRelationQuery::Inbound {
11709 target: heading.key().clone(),
11710 },
11711 10,
11712 None,
11713 )?;
11714 require(
11715 inbound.rows.len() == 1
11716 && inbound.rows[0].relation.kind()
11717 == GraphRelationKind::Extended(ExtendedRelationKind::Documents),
11718 "inbound heading read did not reuse document relation storage",
11719 )?;
11720 let frontier = [family.rows[0].source.key().clone()];
11721 let legacy = store.repository_graph_adjacency_page_filtered_bounded(
11722 &frontier,
11723 RepositoryGraphDirection::Outbound,
11724 None,
11725 None,
11726 10,
11727 maximum_repository_graph_read_budget()?,
11728 None,
11729 )?;
11730 require(
11731 legacy.page.rows.len() == 1
11732 && legacy.page.rows[0].detail.relation.kind()
11733 == GraphRelationKind::Extended(ExtendedRelationKind::Configures),
11734 "legacy unfiltered adjacency admitted documents before its limit",
11735 )?;
11736 let explicit = store.repository_graph_adjacency_page_filtered_bounded_with_documents(
11737 &frontier,
11738 RepositoryGraphDirection::Outbound,
11739 None,
11740 true,
11741 None,
11742 10,
11743 maximum_repository_graph_read_budget()?,
11744 None,
11745 )?;
11746 require_eq(&explicit.page.rows.len(), &3, "explicit document adjacency")?;
11747 let exact = store.repository_graph_adjacency_page_filtered_bounded(
11748 &frontier,
11749 RepositoryGraphDirection::Outbound,
11750 Some(GraphRelationKind::Extended(ExtendedRelationKind::Documents)),
11751 None,
11752 10,
11753 maximum_repository_graph_read_budget()?,
11754 None,
11755 )?;
11756 require_eq(&exact.page.rows.len(), &2, "exact document adjacency")?;
11757 let first = store.repository_graph_adjacency_page_filtered_bounded_with_documents(
11758 &frontier,
11759 RepositoryGraphDirection::Outbound,
11760 None,
11761 true,
11762 None,
11763 1,
11764 maximum_repository_graph_read_budget()?,
11765 None,
11766 )?;
11767 let cursor = first
11768 .page
11769 .continuation
11770 .as_ref()
11771 .ok_or_else(|| io::Error::other("document visibility cursor is missing"))?;
11772 let mismatch = require_db_error(
11773 store.repository_graph_adjacency_page_filtered_bounded_with_documents(
11774 &frontier,
11775 RepositoryGraphDirection::Outbound,
11776 None,
11777 false,
11778 Some(cursor),
11779 1,
11780 maximum_repository_graph_read_budget()?,
11781 None,
11782 ),
11783 "document visibility cursor was reused under legacy filtering",
11784 )?;
11785 require(
11786 matches!(mismatch, DbError::GraphContract(_)),
11787 "document visibility cursor mismatch returned the wrong error",
11788 )?;
11789 assert_query_indexes(&store)?;
11790
11791 let resolved_key = resolved_document.key().digest_bytes()?;
11792 let contradictory = store.connection.execute(
11793 "UPDATE graph_relations
11794 SET document_unresolved_reason = 'missing'
11795 WHERE relation_key = ?1",
11796 [&resolved_key[..]],
11797 );
11798 require(
11799 contradictory.is_err(),
11800 "resolved document relation accepted an unresolved reason",
11801 )?;
11802 let invalid = store.connection.execute(
11803 "UPDATE graph_relations
11804 SET document_unresolved_reason = 'network_error'
11805 WHERE relation_key = ?1",
11806 [&unresolved_key[..]],
11807 );
11808 require(invalid.is_err(), "open document reason value was accepted")?;
11809 drop(store);
11810
11811 let reopened = AtlasStore::open_read_only_for_project(&database, &root)?;
11812 let reopened_family = reopened.repository_graph_relation_rows(
11813 RepositoryGraphRelationQuery::Family {
11814 relation: GraphRelationKind::Extended(ExtendedRelationKind::Documents),
11815 },
11816 10,
11817 None,
11818 )?;
11819 require_eq(
11820 &reopened_family.rows.len(),
11821 &2,
11822 "reopened document relations",
11823 )?;
11824 Ok(())
11825 }
11826
11827 #[test]
11828 fn document_unresolved_reasons_publish_across_multiple_graph_row_chunks()
11829 -> Result<(), Box<dyn Error>> {
11830 let temp = tempfile::tempdir()?;
11831 let root = temp.path().join("document-reason-chunks");
11832 fs::create_dir(&root)?;
11833 let database = temp.path().join("projectatlas.db");
11834 let mut store = AtlasStore::open_for_project(&database, &root)?;
11835 let project = store
11836 .project_instance_id()?
11837 .ok_or_else(|| io::Error::other("document chunk fixture identity is missing"))?;
11838 let baseline_generation = IndexGeneration::new(1);
11839 let generation = IndexGeneration::new(2);
11840 let baseline_document = GraphEntity::new(
11841 project,
11842 EntitySelector::File {
11843 path: RepositoryFilePath::new(Path::new("docs/baseline.md"))?,
11844 },
11845 baseline_generation,
11846 )?;
11847 let baseline_relation = LogicalRelation::new(
11848 &baseline_document,
11849 GraphRelationKind::Extended(ExtendedRelationKind::Documents),
11850 RelationResolution::Unresolved {
11851 reference: GraphIdentityText::new("baseline-missing.md")?,
11852 },
11853 ConfidenceClass::Exact,
11854 Completeness::Complete,
11855 baseline_generation,
11856 )?;
11857 let mut baseline = store.begin_index_publication("document-reason-chunks")?;
11858 baseline.upsert_scan_node_batch(&[graph_node(
11859 "docs/baseline.md",
11860 NodeKind::File,
11861 Some("docs"),
11862 )])?;
11863 baseline.replace_repository_graph(
11864 project,
11865 std::slice::from_ref(&baseline_document),
11866 std::slice::from_ref(&baseline_relation),
11867 &[],
11868 &[],
11869 )?;
11870 baseline.set_document_unresolved_reasons(&[(
11871 baseline_relation.key().clone(),
11872 DocumentTargetUnresolvedReason::Missing,
11873 )])?;
11874 baseline.complete()?;
11875 require_eq(
11876 &store.repository_graph_generation()?,
11877 &Some(baseline_generation),
11878 "baseline generation was not complete",
11879 )?;
11880 let document_path = RepositoryFilePath::new(Path::new("docs/index.md"))?;
11881 let document = GraphEntity::new(
11882 project,
11883 EntitySelector::File {
11884 path: document_path,
11885 },
11886 generation,
11887 )?;
11888 let total = GraphLimits::MAX_ROWS as usize * 2 + 1;
11889 let mut relations = Vec::with_capacity(total);
11890 for index in 0..total {
11891 relations.push(LogicalRelation::new(
11892 &document,
11893 GraphRelationKind::Extended(ExtendedRelationKind::Documents),
11894 RelationResolution::Unresolved {
11895 reference: GraphIdentityText::new(format!("missing-{index:05}.md"))?,
11896 },
11897 ConfidenceClass::Exact,
11898 Completeness::Complete,
11899 generation,
11900 )?);
11901 }
11902 let reasons = relations
11903 .iter()
11904 .map(|relation| {
11905 (
11906 relation.key().clone(),
11907 DocumentTargetUnresolvedReason::Missing,
11908 )
11909 })
11910 .collect::<Vec<_>>();
11911 let mut duplicate_reasons = reasons.clone();
11912 duplicate_reasons.push(reasons[0].clone());
11913 let mut contradictory_reasons = reasons.clone();
11914 contradictory_reasons.push((
11915 reasons[0].0.clone(),
11916 DocumentTargetUnresolvedReason::Unsupported,
11917 ));
11918
11919 let populate = |publication: &mut IndexPublicationGuard<'_>| -> DbResult<()> {
11920 publication.upsert_scan_node_batch(&[graph_node(
11921 "docs/index.md",
11922 NodeKind::File,
11923 Some("docs"),
11924 )])?;
11925 publication.replace_repository_graph(
11926 project,
11927 std::slice::from_ref(&document),
11928 &relations,
11929 &[],
11930 &[],
11931 )
11932 };
11933
11934 let mut boundaries = store.begin_index_publication("document-reason-chunks")?;
11935 populate(&mut boundaries)?;
11936 let duplicate = boundaries.set_document_unresolved_reasons(&duplicate_reasons);
11937 require(
11938 matches!(duplicate, Err(DbError::GraphContract(_))),
11939 "duplicate document reason crossing chunks was accepted",
11940 )?;
11941 let untouched = boundaries.connection.query_row(
11942 "SELECT COUNT(*) FROM graph_relations
11943 WHERE document_unresolved_reason IS NOT NULL",
11944 [],
11945 |row| row.get::<_, i64>(0),
11946 )?;
11947 require_eq(&untouched, &0, "duplicate batch exposed a partial chunk")?;
11948 let contradictory = boundaries.set_document_unresolved_reasons(&contradictory_reasons);
11949 require(
11950 matches!(contradictory, Err(DbError::GraphContract(_))),
11951 "contradictory document reason crossing chunks was accepted",
11952 )?;
11953 let contradiction_untouched = boundaries.connection.query_row(
11954 "SELECT COUNT(*) FROM graph_relations
11955 WHERE document_unresolved_reason IS NOT NULL",
11956 [],
11957 |row| row.get::<_, i64>(0),
11958 )?;
11959 require_eq(
11960 &contradiction_untouched,
11961 &0,
11962 "contradictory batch exposed a partial chunk",
11963 )?;
11964 for count in [
11965 GraphLimits::MAX_ROWS as usize - 1,
11966 GraphLimits::MAX_ROWS as usize,
11967 GraphLimits::MAX_ROWS as usize + 1,
11968 ] {
11969 boundaries.set_document_unresolved_reasons(&reasons[..count])?;
11970 let changed = boundaries.connection.query_row(
11971 "SELECT COUNT(*) FROM graph_relations
11972 WHERE document_unresolved_reason IS NOT NULL",
11973 [],
11974 |row| row.get::<_, i64>(0),
11975 )?;
11976 require_eq(
11977 &changed,
11978 &i64::try_from(count)?,
11979 "document reason boundary count",
11980 )?;
11981 }
11982 drop(boundaries);
11983 require_eq(
11984 &store.repository_graph_generation()?,
11985 &Some(baseline_generation),
11986 "boundary publication changed the current generation",
11987 )?;
11988
11989 let mut preflight = store.begin_index_publication("document-reason-chunks")?;
11990 populate(&mut preflight)?;
11991 let preflight_control = IndexWorkControl::with_deadline(
11992 IndexCancellation::new(),
11993 Instant::now() + Duration::from_millis(10),
11994 );
11995 let preflight_error =
11996 preflight.set_document_unresolved_reasons_controlled(&reasons, &preflight_control);
11997 require(
11998 matches!(
11999 preflight_error,
12000 Err(DbError::IndexWork(
12001 projectatlas_core::IndexWorkFailure::DeadlineExceeded {
12002 stage: IndexWorkStage::Publication
12003 }
12004 ))
12005 ),
12006 "preflight deadline was not observed during document-reason validation",
12007 )?;
12008 let preflight_untouched = preflight.connection.query_row(
12009 "SELECT COUNT(*) FROM graph_relations
12010 WHERE document_unresolved_reason IS NOT NULL",
12011 [],
12012 |row| row.get::<_, i64>(0),
12013 )?;
12014 require_eq(
12015 &preflight_untouched,
12016 &0,
12017 "preflight deadline exposed a partial document-reason update",
12018 )?;
12019 drop(preflight);
12020 require_eq(
12021 &store.repository_graph_generation()?,
12022 &Some(baseline_generation),
12023 "preflight deadline changed the current generation",
12024 )?;
12025
12026 let mut preflight_retry = store.begin_index_publication("document-reason-chunks")?;
12027 populate(&mut preflight_retry)?;
12028 preflight_retry.set_document_unresolved_reasons(&reasons)?;
12029 let retried_count = preflight_retry.connection.query_row(
12030 "SELECT COUNT(*) FROM graph_relations
12031 WHERE document_unresolved_reason IS NOT NULL",
12032 [],
12033 |row| row.get::<_, i64>(0),
12034 )?;
12035 require_eq(
12036 &retried_count,
12037 &i64::try_from(total)?,
12038 "successful retry did not publish every document reason",
12039 )?;
12040 drop(preflight_retry);
12041 require_eq(
12042 &store.repository_graph_generation()?,
12043 &Some(baseline_generation),
12044 "successful preflight retry changed the current generation before completion",
12045 )?;
12046
12047 let mut canceled_publication = store.begin_index_publication("document-reason-chunks")?;
12048 populate(&mut canceled_publication)?;
12049 canceled_publication
12050 .set_document_unresolved_reasons(&reasons[..GraphLimits::MAX_ROWS as usize])?;
12051 let cancellation = projectatlas_core::IndexCancellation::new();
12052 cancellation.cancel();
12053 let canceled_control = IndexWorkControl::new(cancellation, None);
12054 let canceled = canceled_publication.set_document_unresolved_reasons_controlled(
12055 &reasons[GraphLimits::MAX_ROWS as usize..],
12056 &canceled_control,
12057 );
12058 require(
12059 matches!(
12060 canceled,
12061 Err(DbError::IndexWork(
12062 projectatlas_core::IndexWorkFailure::Cancelled {
12063 stage: IndexWorkStage::Publication
12064 }
12065 ))
12066 ),
12067 "post-first-chunk cancellation was accepted",
12068 )?;
12069 let canceled_first_chunk = canceled_publication.connection.query_row(
12070 "SELECT COUNT(*) FROM graph_relations
12071 WHERE document_unresolved_reason IS NOT NULL",
12072 [],
12073 |row| row.get::<_, i64>(0),
12074 )?;
12075 require_eq(
12076 &canceled_first_chunk,
12077 &i64::from(GraphLimits::MAX_ROWS),
12078 "cancellation did not leave the first chunk observable inside the parent transaction",
12079 )?;
12080 drop(canceled_publication);
12081 require_eq(
12082 &store.repository_graph_generation()?,
12083 &Some(baseline_generation),
12084 "canceled publication changed the current generation",
12085 )?;
12086
12087 let mut in_chunk_canceled = store.begin_index_publication("document-reason-chunks")?;
12088 populate(&mut in_chunk_canceled)?;
12089 let cancellation = IndexCancellation::new();
12090 let control = IndexWorkControl::new(cancellation.clone(), None);
12091 let attempted_updates = Arc::new(AtomicUsize::new(0));
12092 let attempted_updates_hook = Arc::clone(&attempted_updates);
12093 in_chunk_canceled.connection.update_hook(Some(
12094 move |action: rusqlite::hooks::Action, _database: &str, table: &str, _rowid: i64| {
12095 if action == rusqlite::hooks::Action::SQLITE_UPDATE
12096 && table == "graph_relations"
12097 && attempted_updates_hook.fetch_add(1, Ordering::Relaxed) == 0
12098 {
12099 cancellation.cancel();
12100 }
12101 },
12102 ))?;
12103 let in_chunk = in_chunk_canceled.set_document_unresolved_reasons_controlled(
12104 &reasons[..GraphLimits::MAX_ROWS as usize],
12105 &control,
12106 );
12107 in_chunk_canceled
12108 .connection
12109 .update_hook(None::<fn(rusqlite::hooks::Action, &str, &str, i64)>)?;
12110 require(
12111 matches!(
12112 in_chunk,
12113 Err(DbError::IndexWork(
12114 projectatlas_core::IndexWorkFailure::Cancelled {
12115 stage: IndexWorkStage::Publication
12116 }
12117 ))
12118 ),
12119 "in-chunk cancellation was not typed",
12120 )?;
12121 require(
12122 attempted_updates.load(Ordering::Relaxed) >= 1,
12123 "in-chunk cancellation did not follow an attempted update",
12124 )?;
12125 let in_chunk_untouched = in_chunk_canceled.connection.query_row(
12126 "SELECT COUNT(*) FROM graph_relations
12127 WHERE document_unresolved_reason IS NOT NULL",
12128 [],
12129 |row| row.get::<_, i64>(0),
12130 )?;
12131 require_eq(
12132 &in_chunk_untouched,
12133 &0,
12134 "in-chunk cancellation exposed a partial document-reason update",
12135 )?;
12136 drop(in_chunk_canceled);
12137 require_eq(
12138 &store.repository_graph_generation()?,
12139 &Some(baseline_generation),
12140 "in-chunk cancellation changed the current generation",
12141 )?;
12142
12143 let mut in_chunk_retry = store.begin_index_publication("document-reason-chunks")?;
12144 populate(&mut in_chunk_retry)?;
12145 in_chunk_retry
12146 .set_document_unresolved_reasons(&reasons[..GraphLimits::MAX_ROWS as usize])?;
12147 let in_chunk_retry_count = in_chunk_retry.connection.query_row(
12148 "SELECT COUNT(*) FROM graph_relations
12149 WHERE document_unresolved_reason IS NOT NULL",
12150 [],
12151 |row| row.get::<_, i64>(0),
12152 )?;
12153 require_eq(
12154 &in_chunk_retry_count,
12155 &i64::from(GraphLimits::MAX_ROWS),
12156 "writer reuse after in-chunk cancellation did not retry cleanly",
12157 )?;
12158 drop(in_chunk_retry);
12159 require_eq(
12160 &store.repository_graph_generation()?,
12161 &Some(baseline_generation),
12162 "in-chunk retry changed the current generation before completion",
12163 )?;
12164
12165 let mut publication = store.begin_index_publication("document-reason-chunks")?;
12166 populate(&mut publication)?;
12167 publication.connection.execute_batch(
12168 "CREATE TRIGGER fail_document_reason_chunk
12169 BEFORE UPDATE OF document_unresolved_reason ON graph_relations
12170 WHEN NEW.reference_text = 'missing-10000.md'
12171 BEGIN
12172 SELECT RAISE(ABORT, 'injected document reason failure');
12173 END;",
12174 )?;
12175 let fault = publication.set_document_unresolved_reasons(&reasons);
12176 require(
12177 matches!(fault, Err(DbError::Sqlite(_))),
12178 "fault between document-reason chunks was accepted",
12179 )?;
12180 let rolled_back = publication.connection.query_row(
12181 "SELECT COUNT(*) FROM graph_relations
12182 WHERE document_unresolved_reason IS NOT NULL",
12183 [],
12184 |row| row.get::<_, i64>(0),
12185 )?;
12186 require_eq(
12187 &rolled_back,
12188 &0,
12189 "fault between chunks exposed an earlier chunk",
12190 )?;
12191 drop(publication);
12192 require(
12193 store.repository_graph_generation()? == Some(baseline_generation),
12194 "fault between chunks replaced the current generation",
12195 )?;
12196
12197 let mut publication = store.begin_index_publication("document-reason-chunks")?;
12198 populate(&mut publication)?;
12199 let changes_before = publication.connection.total_changes();
12200 publication.set_document_unresolved_reasons(&reasons)?;
12201 let changes_after = publication.connection.total_changes();
12202 require_eq(
12203 &changes_after.saturating_sub(changes_before),
12204 &u64::try_from(total)?,
12205 "bounded reason update changed rows",
12206 )?;
12207 publication.complete()?;
12208 let (relation_count, reason_count): (i64, i64) = store.connection.query_row(
12209 "SELECT COUNT(*), COUNT(document_unresolved_reason)
12210 FROM graph_relations
12211 WHERE relation_scope = 'extended' AND relation_kind = 'documents'",
12212 [],
12213 |row| Ok((row.get(0)?, row.get(1)?)),
12214 )?;
12215 require_eq(
12216 &relation_count,
12217 &i64::try_from(total)?,
12218 "complete document relation count",
12219 )?;
12220 require_eq(
12221 &reason_count,
12222 &i64::try_from(total)?,
12223 "complete document reason count",
12224 )?;
12225 assert_query_indexes(&store)?;
12226 Ok(())
12227 }
12228
12229 #[test]
12230 fn high_fanout_document_refresh_has_bounded_sql_and_changed_rows() -> Result<(), Box<dyn Error>>
12231 {
12232 const FANOUT: usize = 256;
12233 const EXPECTED_STATEMENTS: usize = 2_083;
12234 const EXPECTED_CHANGED_ROWS: u64 = 1_031;
12235
12236 let temp = tempfile::tempdir()?;
12237 let root = temp.path().join("high-fanout-document");
12238 fs::create_dir(&root)?;
12239 let database = temp.path().join("projectatlas.db");
12240 let mut store = AtlasStore::open_for_project(&database, &root)?;
12241 let project = store
12242 .project_instance_id()?
12243 .ok_or_else(|| io::Error::other("high-fanout fixture identity is missing"))?;
12244 let document_path = RepositoryFilePath::new(Path::new("docs/high-fanout.md"))?;
12245
12246 let graph = |generation: IndexGeneration| -> DbResult<_> {
12247 let document = GraphEntity::new(
12248 project,
12249 EntitySelector::File {
12250 path: document_path.clone(),
12251 },
12252 generation,
12253 )?;
12254 let mut entities = vec![document.clone()];
12255 let mut relations = Vec::with_capacity(FANOUT);
12256 let mut occurrences = Vec::with_capacity(FANOUT);
12257 for (index, line) in (2_u32..).take(FANOUT).enumerate() {
12258 let target = GraphEntity::new(
12259 project,
12260 EntitySelector::File {
12261 path: RepositoryFilePath::new(Path::new(&format!(
12262 "src/target_{index:04}.rs"
12263 )))?,
12264 },
12265 generation,
12266 )?;
12267 let relation = LogicalRelation::new(
12268 &document,
12269 GraphRelationKind::Extended(ExtendedRelationKind::Documents),
12270 RelationResolution::resolved(&target)?,
12271 ConfidenceClass::Exact,
12272 Completeness::Complete,
12273 generation,
12274 )?;
12275 occurrences.push(RelationOccurrence::new(
12276 &relation,
12277 document_path.clone(),
12278 SourceSpan::new(line, 0, line, 20)?,
12279 generation,
12280 )?);
12281 entities.push(target);
12282 relations.push(relation);
12283 }
12284 Ok((entities, relations, occurrences))
12285 };
12286
12287 let mut nodes = vec![
12288 graph_node(".", NodeKind::Folder, None),
12289 graph_node("docs", NodeKind::Folder, Some(".")),
12290 graph_node("docs/high-fanout.md", NodeKind::File, Some("docs")),
12291 graph_node("src", NodeKind::Folder, Some(".")),
12292 ];
12293 nodes.extend((0..FANOUT).map(|index| {
12294 graph_node(
12295 &format!("src/target_{index:04}.rs"),
12296 NodeKind::File,
12297 Some("src"),
12298 )
12299 }));
12300 let (entities, relations, occurrences) = graph(IndexGeneration::new(1))?;
12301 {
12302 let mut publication = store.begin_index_publication("high-fanout-document")?;
12303 publication.begin_scan_replacement()?;
12304 for batch in nodes.chunks(128) {
12305 publication.upsert_scan_node_batch(batch)?;
12306 }
12307 publication.finish_scan_replacement()?;
12308 publication.replace_repository_graph(
12309 project,
12310 &entities,
12311 &relations,
12312 &occurrences,
12313 &[],
12314 )?;
12315 publication.complete()?;
12316 }
12317
12318 let (entities, relations, occurrences) = graph(IndexGeneration::new(2))?;
12319 TRACED_STATEMENTS.with(|statements| statements.borrow_mut().clear());
12320 store.connection.trace_v2(
12321 rusqlite::trace::TraceEventCodes::SQLITE_TRACE_STMT,
12322 Some(record_traced_event),
12323 );
12324 let changed_before = store.connection.total_changes();
12325 let refresh = (|| -> DbResult<()> {
12326 let mut publication = store.begin_index_publication("high-fanout-document")?;
12327 publication.replace_repository_graph_for_paths(
12328 project,
12329 &["docs/high-fanout.md".to_string()],
12330 &entities,
12331 &relations,
12332 &occurrences,
12333 &[],
12334 )?;
12335 publication.complete()
12336 })();
12337 store
12338 .connection
12339 .trace_v2(rusqlite::trace::TraceEventCodes::empty(), None);
12340 refresh?;
12341 let changed_rows = store.connection.total_changes() - changed_before;
12342 let statements =
12343 TRACED_STATEMENTS.with(|statements| std::mem::take(&mut *statements.borrow_mut()));
12344
12345 require_eq(
12346 &statements.len(),
12347 &EXPECTED_STATEMENTS,
12348 "high-fanout refresh statement count",
12349 )?;
12350 require_eq(
12351 &changed_rows,
12352 &EXPECTED_CHANGED_ROWS,
12353 "high-fanout refresh changed rows",
12354 )?;
12355 let relation_count = usize::try_from(store.connection.query_row(
12356 "SELECT COUNT(*) FROM graph_relations
12357 WHERE relation_scope = 'extended' AND relation_kind = 'documents'",
12358 [],
12359 |row| row.get::<_, i64>(0),
12360 )?)?;
12361 require_eq(&relation_count, &FANOUT, "retained document relation count")?;
12362 Ok(())
12363 }
12364
12365 #[test]
12366 fn every_graph_limit_kind_round_trips_and_unknown_spelling_rolls_back()
12367 -> Result<(), Box<dyn Error>> {
12368 let temp = tempfile::tempdir()?;
12369 let root = temp.path().join("graph-limit-kinds");
12370 fs::create_dir_all(&root)?;
12371 let database = root.join("projectatlas.db");
12372 let mut store = AtlasStore::open_for_project(&database, &root)?;
12373 let project = store
12374 .project_instance_id()?
12375 .ok_or_else(|| io::Error::other("graph-limit fixture identity is missing"))?;
12376 let generation = IndexGeneration::new(1);
12377 let mut coverage = Vec::with_capacity(GraphLimitKind::ALL.len());
12378 for kind in GraphLimitKind::ALL {
12379 coverage.push(CoverageRecord::new(
12380 CoverageScope::Path {
12381 path: RepositoryNodePath::new(Path::new(&format!(
12382 "limits/{}.rs",
12383 kind.as_str()
12384 )))?,
12385 },
12386 None,
12387 CoverageState::Partial,
12388 1,
12389 1,
12390 generation,
12391 Some(GraphIdentityText::new("graph limit reached")?),
12392 Some(kind),
12393 )?);
12394 }
12395 let mut publication = store.begin_index_publication("graph-limit-kinds")?;
12396 publication.replace_repository_graph(project, &[], &[], &[], &coverage)?;
12397 publication.complete()?;
12398
12399 let verify = |store: &AtlasStore| -> Result<(), Box<dyn Error>> {
12400 for kind in GraphLimitKind::ALL {
12401 let page = store.repository_graph_coverage(
12402 project,
12403 &CoverageScope::Path {
12404 path: RepositoryNodePath::new(Path::new(&format!(
12405 "limits/{}.rs",
12406 kind.as_str()
12407 )))?,
12408 },
12409 1,
12410 )?;
12411 require_eq(&page.rows.len(), &1, "graph-limit coverage row count")?;
12412 require_eq(
12413 &page.rows[0].reached_limit(),
12414 &Some(kind),
12415 "graph-limit coverage spelling",
12416 )?;
12417 }
12418 Ok(())
12419 };
12420 verify(&store)?;
12421
12422 store.connection.execute_batch("BEGIN IMMEDIATE")?;
12423 let invalid = (|| -> DbResult<()> {
12424 store.connection.execute("DELETE FROM graph_coverage", [])?;
12425 store.connection.execute(
12426 "INSERT INTO graph_coverage(
12427 project_instance_id, scope_kind, state, total, covered, omitted,
12428 reason, reached_limit
12429 ) VALUES(?1, 'project', 'partial', 2, 1, 1, 'invalid limit', 'rowz')",
12430 [&project.as_bytes()[..]],
12431 )?;
12432 Ok(())
12433 })();
12434 require(
12435 matches!(invalid, Err(DbError::Sqlite(_))),
12436 "unknown graph-limit spelling bypassed the SQLite constraint",
12437 )?;
12438 store.connection.execute_batch("ROLLBACK")?;
12439 verify(&store)?;
12440 drop(store);
12441
12442 let reopened = AtlasStore::open_read_only_for_project(&database, &root)?;
12443 verify(&reopened)?;
12444 Ok(())
12445 }
12446
12447 #[test]
12448 fn typed_graph_round_trips_through_bounded_indexed_queries() -> Result<(), Box<dyn Error>> {
12449 let temp = tempfile::tempdir()?;
12450 let project_root = temp.path().join("typed-graph");
12451 let atlas_dir = project_root.join(".projectatlas");
12452 fs::create_dir_all(&atlas_dir)?;
12453 let db_path = atlas_dir.join("projectatlas.db");
12454 let mut writer = AtlasStore::open_for_project(&db_path, &project_root)?;
12455 let fixture = publish_fixture(&mut writer, "typed-graph")?;
12456 drop(writer);
12457 let store = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
12458
12459 for expected in &fixture.entities {
12460 require_eq(
12461 &store.repository_graph_entity(expected.key())?,
12462 &Some(expected.clone()),
12463 "stable entity lookup",
12464 )?;
12465 }
12466
12467 let source_path = RepositoryNodePath::new(Path::new("src/Äuth.rs"))?;
12468 let truncated =
12469 store.repository_graph_entities_by_path(fixture.project, &source_path, 1)?;
12470 require(
12471 truncated.truncated && truncated.rows.len() == 1,
12472 "entity LIMIT + 1",
12473 )?;
12474 let path_rows =
12475 store.repository_graph_entities_by_path(fixture.project, &source_path, 10)?;
12476 require(
12477 !path_rows.truncated && path_rows.rows.len() == 2,
12478 "Unicode/case path lookup",
12479 )?;
12480 for (result, context) in [
12481 (
12482 store.repository_graph_entities_by_path(fixture.project, &source_path, 0),
12483 "zero entity page limit",
12484 ),
12485 (
12486 store.repository_graph_entities_by_path(
12487 fixture.project,
12488 &source_path,
12489 GraphLimits::MAX_ROWS + 1,
12490 ),
12491 "over-ceiling entity page limit",
12492 ),
12493 ] {
12494 let error = require_db_error(result, context)?;
12495 require(
12496 matches!(error, DbError::GraphContract(_)),
12497 &format!("unexpected {context} error: {error}"),
12498 )?;
12499 }
12500
12501 let source = fixture
12502 .entities
12503 .iter()
12504 .find(|entity| matches!(entity.selector(), EntitySelector::File { .. }))
12505 .ok_or_else(|| io::Error::other("source file fixture missing"))?;
12506 let outbound = store.repository_graph_relations(
12507 RepositoryGraphRelationQuery::Outbound {
12508 source: source.key().clone(),
12509 },
12510 10,
12511 )?;
12512 require_eq(&outbound.rows.len(), &4, "all resolution states")?;
12513 require(
12514 outbound.rows.iter().any(|relation| {
12515 matches!(relation.resolution(), RelationResolution::Resolved { .. })
12516 }) && outbound.rows.iter().any(|relation| {
12517 matches!(relation.resolution(), RelationResolution::Ambiguous { .. })
12518 }) && outbound.rows.iter().any(|relation| {
12519 matches!(relation.resolution(), RelationResolution::Unresolved { .. })
12520 }) && outbound.rows.iter().any(|relation| {
12521 matches!(relation.resolution(), RelationResolution::External { .. })
12522 }),
12523 "resolution variants did not round-trip",
12524 )?;
12525 let outbound_truncated = store.repository_graph_relations(
12526 RepositoryGraphRelationQuery::Outbound {
12527 source: source.key().clone(),
12528 },
12529 3,
12530 )?;
12531 require(
12532 outbound_truncated.truncated && outbound_truncated.rows.len() == 3,
12533 "relation LIMIT + 1",
12534 )?;
12535 for (limit, context) in [
12536 (0, "zero relation page limit"),
12537 (
12538 GraphLimits::MAX_ROWS + 1,
12539 "over-ceiling relation page limit",
12540 ),
12541 ] {
12542 let error = require_db_error(
12543 store.repository_graph_relations(
12544 RepositoryGraphRelationQuery::Outbound {
12545 source: source.key().clone(),
12546 },
12547 limit,
12548 ),
12549 context,
12550 )?;
12551 require(
12552 matches!(error, DbError::GraphContract(_)),
12553 &format!("unexpected {context} error: {error}"),
12554 )?;
12555 }
12556
12557 let symbol = fixture
12558 .entities
12559 .iter()
12560 .find(|entity| matches!(entity.selector(), EntitySelector::Symbol { .. }))
12561 .ok_or_else(|| io::Error::other("symbol fixture missing"))?;
12562 let inbound = store.repository_graph_relations(
12563 RepositoryGraphRelationQuery::Inbound {
12564 target: symbol.key().clone(),
12565 },
12566 10,
12567 )?;
12568 require_eq(&inbound.rows.len(), &1, "inbound relation lookup")?;
12569 let calls = store.repository_graph_relations(
12570 RepositoryGraphRelationQuery::Family {
12571 relation: GraphRelationKind::Legacy(RelationKind::Calls),
12572 },
12573 10,
12574 )?;
12575 require_eq(&calls.rows.len(), &1, "relation-family lookup")?;
12576 let occurrence_page = store.repository_graph_occurrences(&calls.rows[0], 1)?;
12577 require(
12578 occurrence_page.truncated && occurrence_page.rows.len() == 1,
12579 "occurrence LIMIT + 1",
12580 )?;
12581 let all_occurrences = store.repository_graph_occurrences(&calls.rows[0], 10)?;
12582 require_eq(
12583 &all_occurrences.rows.len(),
12584 &2,
12585 "logical relation occurrence retention",
12586 )?;
12587 let error = require_db_error(
12588 store.repository_graph_occurrences(&calls.rows[0], 0),
12589 "zero occurrence page limit was accepted",
12590 )?;
12591 require(
12592 matches!(error, DbError::GraphContract(_)),
12593 &format!("unexpected zero occurrence-limit error: {error}"),
12594 )?;
12595 let error = require_db_error(
12596 store.repository_graph_occurrences(&calls.rows[0], GraphLimits::MAX_OCCURRENCES + 1),
12597 "over-ceiling occurrence page was accepted",
12598 )?;
12599 require(
12600 matches!(error, DbError::GraphContract(_)),
12601 &format!("unexpected occurrence-limit error: {error}"),
12602 )?;
12603
12604 let project_coverage =
12605 store.repository_graph_coverage(fixture.project, &CoverageScope::Project, 10)?;
12606 let path_coverage = store.repository_graph_coverage(
12607 fixture.project,
12608 &CoverageScope::Path {
12609 path: RepositoryNodePath::new(Path::new("src"))?,
12610 },
12611 10,
12612 )?;
12613 require_eq(&project_coverage.rows.len(), &8, "project coverage states")?;
12614 require_eq(&path_coverage.rows.len(), &1, "path coverage state")?;
12615 require(
12616 project_coverage
12617 .rows
12618 .iter()
12619 .any(|row| row.state() == CoverageState::NoCandidates && row.total() == 0),
12620 "zero-candidate coverage did not round-trip",
12621 )?;
12622 require(
12623 project_coverage.rows.iter().any(|row| {
12624 row.state() == CoverageState::Complete
12625 && row.total() == 0
12626 && row.relation()
12627 == Some(GraphRelationKind::Extended(ExtendedRelationKind::Tests))
12628 }),
12629 "non-document complete-zero coverage changed public state",
12630 )?;
12631 require(
12632 path_coverage.rows[0].state() == CoverageState::Partial,
12633 "partial coverage did not round-trip",
12634 )?;
12635 for (limit, context) in [
12636 (0, "zero coverage page limit"),
12637 (
12638 GraphLimits::MAX_ROWS + 1,
12639 "over-ceiling coverage page limit",
12640 ),
12641 ] {
12642 let error = require_db_error(
12643 store.repository_graph_coverage(fixture.project, &CoverageScope::Project, limit),
12644 context,
12645 )?;
12646 require(
12647 matches!(error, DbError::GraphContract(_)),
12648 &format!("unexpected {context} error: {error}"),
12649 )?;
12650 }
12651
12652 let source_next = GraphEntity::new(
12653 fixture.project,
12654 source.selector().clone(),
12655 IndexGeneration::new(2),
12656 )?;
12657 let symbol_next = GraphEntity::new(
12658 fixture.project,
12659 symbol.selector().clone(),
12660 IndexGeneration::new(2),
12661 )?;
12662 let next_generation_call = LogicalRelation::new(
12663 &source_next,
12664 GraphRelationKind::Legacy(RelationKind::Calls),
12665 RelationResolution::resolved(&symbol_next)?,
12666 ConfidenceClass::Exact,
12667 Completeness::Complete,
12668 IndexGeneration::new(2),
12669 )?;
12670 let error = require_db_error(
12671 store.repository_graph_occurrences(&next_generation_call, 10),
12672 "generation-mismatched occurrence request was accepted",
12673 )?;
12674 require(
12675 matches!(error, DbError::GraphContract(_)),
12676 &format!("unexpected occurrence generation error: {error}"),
12677 )?;
12678
12679 let lexical = store
12680 .load_file_text("src/Äuth.rs")?
12681 .ok_or_else(|| io::Error::other("lexical source row missing"))?;
12682 require_eq(
12683 &lexical.content,
12684 &"fn verifyToken()".to_string(),
12685 "lexical owner",
12686 )?;
12687 require_eq(
12688 &store.symbol_relation_count()?,
12689 &1,
12690 "legacy relation projection changed",
12691 )?;
12692
12693 store.finish_index_read_snapshot()?;
12694 drop(store);
12695 let mut writer = AtlasStore::open_for_project(&db_path, &project_root)?;
12696 let mut publication = writer.begin_index_publication("typed-graph")?;
12697 publication.replace_repository_graph_for_paths(
12698 fixture.project,
12699 &["src/unrelated.rs".to_string()],
12700 &[],
12701 &[],
12702 &[],
12703 &[],
12704 )?;
12705 publication.complete()?;
12706 drop(writer);
12707 let store = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
12708 let reused = store
12709 .repository_graph_entity(source.key())?
12710 .ok_or_else(|| io::Error::other("unchanged entity disappeared"))?;
12711 require_eq(
12712 &reused.generation(),
12713 &IndexGeneration::new(2),
12714 "unchanged graph row generation injection",
12715 )?;
12716 for expected in &fixture.entities {
12717 let reused = store
12718 .repository_graph_entity(expected.key())?
12719 .ok_or_else(|| io::Error::other("unchanged graph entity disappeared"))?;
12720 require_eq(
12721 &reused.generation(),
12722 &IndexGeneration::new(2),
12723 "incremental graph row reuse",
12724 )?;
12725 }
12726 assert_query_indexes(&store)?;
12727 store.finish_index_read_snapshot()?;
12728 Ok(())
12729 }
12730
12731 #[test]
12732 fn batched_adjacency_uses_direction_owned_indexes_and_stable_keysets()
12733 -> Result<(), Box<dyn Error>> {
12734 let temp = tempfile::tempdir()?;
12735 let project_root = temp.path().join("batched-adjacency");
12736 let atlas_dir = project_root.join(".projectatlas");
12737 fs::create_dir_all(&atlas_dir)?;
12738 let db_path = atlas_dir.join("projectatlas.db");
12739 let mut writer = AtlasStore::open_for_project(&db_path, &project_root)?;
12740 let fixture = publish_fixture(&mut writer, "batched-adjacency")?;
12741 drop(writer);
12742 let store = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
12743
12744 let source = fixture
12745 .entities
12746 .iter()
12747 .find(|entity| matches!(entity.selector(), EntitySelector::File { .. }))
12748 .ok_or_else(|| io::Error::other("source file fixture missing"))?;
12749 let symbol = fixture
12750 .entities
12751 .iter()
12752 .find(|entity| matches!(entity.selector(), EntitySelector::Symbol { .. }))
12753 .ok_or_else(|| io::Error::other("symbol fixture missing"))?;
12754 let external = fixture
12755 .entities
12756 .iter()
12757 .find(|entity| matches!(entity.selector(), EntitySelector::External { .. }))
12758 .ok_or_else(|| io::Error::other("external fixture missing"))?;
12759 let outbound_frontier = vec![source.key().clone()];
12760
12761 let first = store.repository_graph_adjacency_page(
12762 &outbound_frontier,
12763 RepositoryGraphDirection::Outbound,
12764 None,
12765 2,
12766 None,
12767 )?;
12768 require(
12769 first.truncated && first.rows.len() == 2 && first.continuation.is_some(),
12770 "outbound adjacency did not retain its LIMIT + 1 keyset",
12771 )?;
12772 require(
12773 first.rows.iter().all(|row| {
12774 row.frontier_index == 0
12775 && row.frontier == source.key().clone()
12776 && row.direction == RepositoryGraphDirection::Outbound
12777 }),
12778 "outbound adjacency lost its selecting frontier",
12779 )?;
12780 let continuation = first
12781 .continuation
12782 .clone()
12783 .ok_or_else(|| io::Error::other("outbound continuation missing"))?;
12784 let second = store.repository_graph_adjacency_page(
12785 &outbound_frontier,
12786 RepositoryGraphDirection::Outbound,
12787 Some(&continuation),
12788 10,
12789 None,
12790 )?;
12791 require(
12792 !second.truncated && second.rows.len() == 2 && second.continuation.is_none(),
12793 "outbound continuation did not finish the stable relation order",
12794 )?;
12795 let ordinary = store.repository_graph_relations(
12796 RepositoryGraphRelationQuery::Outbound {
12797 source: source.key().clone(),
12798 },
12799 10,
12800 )?;
12801 let combined = first
12802 .rows
12803 .into_iter()
12804 .chain(second.rows)
12805 .map(|row| row.detail.relation)
12806 .collect::<Vec<_>>();
12807 require_eq(
12808 &combined,
12809 &ordinary.rows,
12810 "adjacency keyset order versus ordinary relation order",
12811 )?;
12812 let calls = GraphRelationKind::Legacy(RelationKind::Calls);
12813 let filtered = store.repository_graph_adjacency_page_filtered(
12814 &outbound_frontier,
12815 RepositoryGraphDirection::Outbound,
12816 Some(calls),
12817 None,
12818 10,
12819 None,
12820 )?;
12821 let expected_calls = ordinary
12822 .rows
12823 .iter()
12824 .filter(|relation| relation.kind() == calls)
12825 .cloned()
12826 .collect::<Vec<_>>();
12827 require_eq(
12828 &filtered
12829 .rows
12830 .iter()
12831 .map(|row| row.detail.relation.clone())
12832 .collect::<Vec<_>>(),
12833 &expected_calls,
12834 "filtered adjacency versus ordinary family selection",
12835 )?;
12836 require(
12837 !filtered.truncated && filtered.continuation.is_none(),
12838 "filtered adjacency unexpectedly truncated its complete family",
12839 )?;
12840 let resolved = store.repository_graph_resolved_adjacency_page(
12841 &outbound_frontier,
12842 RepositoryGraphDirection::Outbound,
12843 calls,
12844 None,
12845 10,
12846 None,
12847 )?;
12848 require_eq(
12849 &resolved
12850 .rows
12851 .iter()
12852 .map(|row| row.detail.relation.clone())
12853 .collect::<Vec<_>>(),
12854 &expected_calls,
12855 "resolved adjacency versus exact local family selection",
12856 )?;
12857 let unresolved = store.repository_graph_resolved_adjacency_page(
12858 &outbound_frontier,
12859 RepositoryGraphDirection::Outbound,
12860 GraphRelationKind::Extended(ExtendedRelationKind::Configures),
12861 None,
12862 10,
12863 None,
12864 )?;
12865 require_eq(
12866 &unresolved.rows.len(),
12867 &0,
12868 "resolved adjacency retained unresolved rows",
12869 )?;
12870 let external_only = store.repository_graph_resolved_adjacency_page(
12871 &outbound_frontier,
12872 RepositoryGraphDirection::Outbound,
12873 GraphRelationKind::Legacy(RelationKind::DependsOn),
12874 None,
12875 10,
12876 None,
12877 )?;
12878 require_eq(
12879 &external_only.rows.len(),
12880 &0,
12881 "resolved adjacency retained external rows",
12882 )?;
12883 let mut resolved_cursor = continuation.clone();
12884 resolved_cursor.relation = Some(calls);
12885 resolved_cursor.resolved_only = true;
12886 let encoded = serde_json::to_string(&resolved_cursor)?;
12887 let decoded: RepositoryGraphAdjacencyContinuation = serde_json::from_str(&encoded)?;
12888 require(
12889 encoded.contains("\"resolved_only\":true") && decoded == resolved_cursor,
12890 "resolved adjacency cursor did not preserve its mode through serde",
12891 )?;
12892 let resolved_mode_mismatch = require_db_error(
12893 store.repository_graph_adjacency_page_filtered(
12894 &outbound_frontier,
12895 RepositoryGraphDirection::Outbound,
12896 Some(calls),
12897 Some(&decoded),
12898 1,
12899 None,
12900 ),
12901 "resolved adjacency cursor was accepted by ordinary adjacency",
12902 )?;
12903 require(
12904 matches!(resolved_mode_mismatch, DbError::GraphContract(_)),
12905 &format!("resolved cursor mismatch returned {resolved_mode_mismatch}"),
12906 )?;
12907
12908 let inbound_frontier = vec![symbol.key().clone(), external.key().clone()];
12909 let inbound = store.repository_graph_adjacency_page(
12910 &inbound_frontier,
12911 RepositoryGraphDirection::Inbound,
12912 None,
12913 10,
12914 None,
12915 )?;
12916 require(
12917 !inbound.truncated
12918 && inbound.rows.len() == 2
12919 && inbound.rows[0].frontier_index == 0
12920 && inbound.rows[0].frontier == symbol.key().clone()
12921 && inbound.rows[1].frontier_index == 1
12922 && inbound.rows[1].frontier == external.key().clone()
12923 && inbound
12924 .rows
12925 .iter()
12926 .all(|row| row.direction == RepositoryGraphDirection::Inbound),
12927 "inbound adjacency did not preserve bounded frontier order",
12928 )?;
12929
12930 for (direction, expected_index) in [
12931 (
12932 RepositoryGraphDirection::Outbound,
12933 "idx_graph_relations_source_kind",
12934 ),
12935 (
12936 RepositoryGraphDirection::Inbound,
12937 "idx_graph_relations_target_kind",
12938 ),
12939 ] {
12940 for continuation_index in [None, Some(0)] {
12941 let sql = format!(
12942 "EXPLAIN QUERY PLAN {}",
12943 adjacency_relation_sql(2, direction, continuation_index, false, false, false,)
12944 );
12945 let mut statement = store.connection.prepare(&sql)?;
12946 let mut bindings = vec![Value::Blob(fixture.project.as_bytes().to_vec())];
12947 bindings.push(Value::Blob(source.key().digest_bytes()?.to_vec()));
12948 if continuation_index.is_some() {
12949 bindings.extend([
12950 Value::Text(String::new()),
12951 Value::Text(String::new()),
12952 Value::Blob(vec![0; 32]),
12953 ]);
12954 }
12955 bindings.extend([
12956 Value::Integer(11),
12957 Value::Blob(symbol.key().digest_bytes()?.to_vec()),
12958 Value::Integer(11),
12959 Value::Integer(11),
12960 ]);
12961 let details = statement
12962 .query_map(params_from_iter(bindings.iter()), |row| {
12963 row.get::<_, String>(3)
12964 })?
12965 .collect::<Result<Vec<_>, _>>()?;
12966 require(
12967 details.iter().any(|detail| detail.contains(expected_index))
12968 && details
12969 .iter()
12970 .all(|detail| !detail.contains("SCAN relation")),
12971 &format!(
12972 "{direction:?} adjacency plan (continuation_index={continuation_index:?}) did not own its index: {details:?}"
12973 ),
12974 )?;
12975 }
12976
12977 let (scope, kind) = relation_parts(calls);
12978 let filtered_sql = format!(
12979 "EXPLAIN QUERY PLAN {}",
12980 adjacency_relation_sql(2, direction, None, true, false, false)
12981 );
12982 let mut filtered_statement = store.connection.prepare(&filtered_sql)?;
12983 let filtered_bindings = [
12984 Value::Blob(fixture.project.as_bytes().to_vec()),
12985 Value::Blob(source.key().digest_bytes()?.to_vec()),
12986 Value::Text(scope.to_string()),
12987 Value::Text(kind.to_string()),
12988 Value::Integer(11),
12989 Value::Blob(symbol.key().digest_bytes()?.to_vec()),
12990 Value::Text(scope.to_string()),
12991 Value::Text(kind.to_string()),
12992 Value::Integer(11),
12993 Value::Integer(11),
12994 ];
12995 let filtered_details = filtered_statement
12996 .query_map(params_from_iter(filtered_bindings.iter()), |row| {
12997 row.get::<_, String>(3)
12998 })?
12999 .collect::<Result<Vec<_>, _>>()?;
13000 require(
13001 filtered_details
13002 .iter()
13003 .any(|detail| detail.contains(expected_index))
13004 && filtered_details
13005 .iter()
13006 .all(|detail| !detail.contains("SCAN relation")),
13007 &format!(
13008 "filtered {direction:?} adjacency plan did not own its index: {filtered_details:?}"
13009 ),
13010 )?;
13011
13012 let resolved_sql = format!(
13013 "EXPLAIN QUERY PLAN {}",
13014 adjacency_relation_sql(2, direction, None, true, true, false)
13015 );
13016 let mut resolved_statement = store.connection.prepare(&resolved_sql)?;
13017 let resolved_bindings = [
13018 Value::Blob(fixture.project.as_bytes().to_vec()),
13019 Value::Text(RESOLUTION_STATUS_RESOLVED.to_string()),
13020 Value::Blob(source.key().digest_bytes()?.to_vec()),
13021 Value::Text(scope.to_string()),
13022 Value::Text(kind.to_string()),
13023 Value::Integer(11),
13024 Value::Blob(symbol.key().digest_bytes()?.to_vec()),
13025 Value::Text(scope.to_string()),
13026 Value::Text(kind.to_string()),
13027 Value::Integer(11),
13028 Value::Integer(11),
13029 ];
13030 let resolved_details = resolved_statement
13031 .query_map(params_from_iter(resolved_bindings.iter()), |row| {
13032 row.get::<_, String>(3)
13033 })?
13034 .collect::<Result<Vec<_>, _>>()?;
13035 require(
13036 resolved_details
13037 .iter()
13038 .any(|detail| detail.contains(expected_index))
13039 && resolved_details
13040 .iter()
13041 .all(|detail| !detail.contains("SCAN relation")),
13042 &format!(
13043 "resolved {direction:?} adjacency plan did not own its index: {resolved_details:?}"
13044 ),
13045 )?;
13046 }
13047
13048 let duplicate_error = require_db_error(
13049 store.repository_graph_adjacency_page(
13050 &[source.key().clone(), source.key().clone()],
13051 RepositoryGraphDirection::Outbound,
13052 None,
13053 1,
13054 None,
13055 ),
13056 "duplicate adjacency frontier was accepted",
13057 )?;
13058 require(
13059 matches!(duplicate_error, DbError::GraphContract(_)),
13060 &format!("unexpected duplicate-frontier error: {duplicate_error}"),
13061 )?;
13062 let oversized = vec![source.key().clone(); MAX_REPOSITORY_GRAPH_FRONTIER + 1];
13063 let oversized_error = require_db_error(
13064 store.repository_graph_adjacency_page(
13065 &oversized,
13066 RepositoryGraphDirection::Outbound,
13067 None,
13068 1,
13069 None,
13070 ),
13071 "oversized adjacency frontier was accepted",
13072 )?;
13073 require(
13074 matches!(oversized_error, DbError::GraphContract(_)),
13075 &format!("unexpected oversized-frontier error: {oversized_error}"),
13076 )?;
13077 let foreign = GraphEntity::new(
13078 ProjectInstanceId::from_bytes([0x7f; 16])?,
13079 EntitySelector::Project,
13080 IndexGeneration::new(1),
13081 )?;
13082 let project_error = require_db_error(
13083 store.repository_graph_adjacency_page(
13084 &[source.key().clone(), foreign.key().clone()],
13085 RepositoryGraphDirection::Outbound,
13086 None,
13087 1,
13088 None,
13089 ),
13090 "mixed-project adjacency frontier was accepted",
13091 )?;
13092 require(
13093 matches!(project_error, DbError::GraphProjectIdentityMismatch { .. }),
13094 &format!("unexpected mixed-project error: {project_error}"),
13095 )?;
13096 let direction_error = require_db_error(
13097 store.repository_graph_adjacency_page(
13098 &outbound_frontier,
13099 RepositoryGraphDirection::Inbound,
13100 Some(&continuation),
13101 1,
13102 None,
13103 ),
13104 "cross-direction adjacency continuation was accepted",
13105 )?;
13106 require(
13107 matches!(direction_error, DbError::GraphContract(_)),
13108 &format!("unexpected continuation-direction error: {direction_error}"),
13109 )?;
13110 let mut filtered_continuation = continuation.clone();
13111 filtered_continuation.relation = Some(calls);
13112 let family_error = require_db_error(
13113 store.repository_graph_adjacency_page_filtered(
13114 &outbound_frontier,
13115 RepositoryGraphDirection::Outbound,
13116 Some(GraphRelationKind::Extended(
13117 ExtendedRelationKind::Configures,
13118 )),
13119 Some(&filtered_continuation),
13120 1,
13121 None,
13122 ),
13123 "cross-family adjacency continuation was accepted",
13124 )?;
13125 require(
13126 matches!(family_error, DbError::GraphContract(_)),
13127 &format!("unexpected continuation-family error: {family_error}"),
13128 )?;
13129 let frontier_error = require_db_error(
13130 store.repository_graph_adjacency_page(
13131 &[external.key().clone()],
13132 RepositoryGraphDirection::Outbound,
13133 Some(&continuation),
13134 1,
13135 None,
13136 ),
13137 "cross-frontier adjacency continuation was accepted",
13138 )?;
13139 require(
13140 matches!(frontier_error, DbError::GraphContract(_)),
13141 &format!("unexpected continuation-frontier error: {frontier_error}"),
13142 )?;
13143
13144 let inbound_first = store.repository_graph_adjacency_page(
13145 &inbound_frontier,
13146 RepositoryGraphDirection::Inbound,
13147 None,
13148 1,
13149 None,
13150 )?;
13151 let inbound_continuation = inbound_first
13152 .continuation
13153 .ok_or_else(|| io::Error::other("inbound continuation missing"))?;
13154 require(
13155 store.repository_graph_adjacency_continuation_has_filtered_rows(
13156 &inbound_continuation,
13157 ConfidenceClass::Low,
13158 ContentSelection::UnspecifiedLegacy,
13159 None,
13160 )?,
13161 "multi-frontier continuation lost its remaining admitted relation",
13162 )?;
13163 let mut exhausted_continuation = inbound_continuation.clone();
13164 exhausted_continuation.relation = Some(GraphRelationKind::Extended(
13165 ExtendedRelationKind::Configures,
13166 ));
13167 require(
13168 !store.repository_graph_adjacency_continuation_has_filtered_rows(
13169 &exhausted_continuation,
13170 ConfidenceClass::Low,
13171 ContentSelection::UnspecifiedLegacy,
13172 None,
13173 )?,
13174 "multi-frontier continuation retained an excluded relation",
13175 )?;
13176 let reordered_frontier = vec![external.key().clone(), symbol.key().clone()];
13177 let reordered_error = require_db_error(
13178 store.repository_graph_adjacency_page(
13179 &reordered_frontier,
13180 RepositoryGraphDirection::Inbound,
13181 Some(&inbound_continuation),
13182 1,
13183 None,
13184 ),
13185 "reordered adjacency continuation frontier was accepted",
13186 )?;
13187 require(
13188 matches!(reordered_error, DbError::GraphContract(_)),
13189 &format!("unexpected reordered-frontier error: {reordered_error}"),
13190 )?;
13191 let empty_frontier_error = require_db_error(
13192 store.repository_graph_adjacency_page(
13193 &[],
13194 RepositoryGraphDirection::Inbound,
13195 Some(&inbound_continuation),
13196 1,
13197 None,
13198 ),
13199 "adjacency continuation without a frontier was accepted",
13200 )?;
13201 require(
13202 matches!(empty_frontier_error, DbError::GraphContract(_)),
13203 &format!("unexpected empty-frontier error: {empty_frontier_error}"),
13204 )?;
13205
13206 let mut foreign_project_continuation = continuation.clone();
13207 foreign_project_continuation.project = foreign.key().project();
13208 let continuation_project_error = require_db_error(
13209 store.repository_graph_adjacency_page(
13210 &outbound_frontier,
13211 RepositoryGraphDirection::Outbound,
13212 Some(&foreign_project_continuation),
13213 1,
13214 None,
13215 ),
13216 "cross-project adjacency continuation was accepted",
13217 )?;
13218 require(
13219 matches!(continuation_project_error, DbError::GraphContract(_)),
13220 &format!("unexpected continuation-project error: {continuation_project_error}"),
13221 )?;
13222 let mut stale_generation_continuation = continuation.clone();
13223 stale_generation_continuation.generation = stale_generation_continuation
13224 .generation
13225 .checked_next()
13226 .ok_or_else(|| io::Error::other("fixture generation overflowed"))?;
13227 let continuation_generation_error = require_db_error(
13228 store.repository_graph_adjacency_page(
13229 &outbound_frontier,
13230 RepositoryGraphDirection::Outbound,
13231 Some(&stale_generation_continuation),
13232 1,
13233 None,
13234 ),
13235 "cross-generation adjacency continuation was accepted",
13236 )?;
13237 require(
13238 matches!(continuation_generation_error, DbError::GraphContract(_)),
13239 &format!("unexpected continuation-generation error: {continuation_generation_error}"),
13240 )?;
13241
13242 let mut maximum_frontier = Vec::with_capacity(MAX_REPOSITORY_GRAPH_FRONTIER);
13243 for index in 0..MAX_REPOSITORY_GRAPH_FRONTIER {
13244 let entity = GraphEntity::new(
13245 fixture.project,
13246 EntitySelector::External {
13247 external: ExternalSelector {
13248 system: GraphIdentityText::new("work-envelope")?,
13249 identity: GraphIdentityText::new(format!("candidate-{index}"))?,
13250 },
13251 },
13252 continuation.generation,
13253 )?;
13254 maximum_frontier.push(entity.key().clone());
13255 }
13256 let bounded_work = store.repository_graph_adjacency_page(
13257 &maximum_frontier,
13258 RepositoryGraphDirection::Outbound,
13259 None,
13260 38,
13261 None,
13262 )?;
13263 require(
13264 bounded_work.rows.is_empty() && !bounded_work.truncated,
13265 "maximum adjacency frontier rejected work below the intermediate ceiling",
13266 )?;
13267 let excessive_work_error = require_db_error(
13268 store.repository_graph_adjacency_page(
13269 &maximum_frontier,
13270 RepositoryGraphDirection::Outbound,
13271 None,
13272 39,
13273 None,
13274 ),
13275 "adjacency intermediate work above the ceiling was accepted",
13276 )?;
13277 require(
13278 matches!(excessive_work_error, DbError::GraphContract(_)),
13279 &format!("unexpected intermediate-work error: {excessive_work_error}"),
13280 )?;
13281
13282 let successful_cancellation = projectatlas_core::IndexCancellation::new();
13283 let successful_control = IndexWorkControl::new(successful_cancellation.clone(), None);
13284 store.repository_graph_adjacency_page(
13285 &outbound_frontier,
13286 RepositoryGraphDirection::Outbound,
13287 None,
13288 10,
13289 Some(&successful_control),
13290 )?;
13291 successful_cancellation.cancel();
13292 require_eq(
13293 &store
13294 .connection
13295 .query_row("SELECT 1", [], |row| row.get::<_, i64>(0))?,
13296 &1,
13297 "cleared adjacency progress handler",
13298 )?;
13299
13300 let cancellation = projectatlas_core::IndexCancellation::new();
13301 cancellation.cancel();
13302 let control = IndexWorkControl::new(cancellation, None);
13303 let cancelled = store.repository_graph_adjacency_page(
13304 &outbound_frontier,
13305 RepositoryGraphDirection::Outbound,
13306 None,
13307 10,
13308 Some(&control),
13309 );
13310 require(
13311 matches!(
13312 cancelled,
13313 Err(DbError::IndexWork(
13314 projectatlas_core::IndexWorkFailure::Cancelled {
13315 stage: IndexWorkStage::RepositoryTraversal
13316 }
13317 ))
13318 ),
13319 "adjacency cancellation was not typed",
13320 )?;
13321 store.finish_index_read_snapshot()?;
13322 Ok(())
13323 }
13324
13325 #[test]
13326 fn detailed_relation_storage_statements_are_indexed_and_batch_bounded()
13327 -> Result<(), Box<dyn Error>> {
13328 use DetailedRelationTraceStatement::{
13329 ActiveGraphGeneration, AdjacencyRelations, ProjectIdentity, PublicationMetadata,
13330 PurposeOwners, RelationEntities, RelationOccurrences, RelationsByDigest,
13331 };
13332
13333 let temp = tempfile::tempdir()?;
13334 let project_root = temp.path().join("detailed-relation-storage");
13335 let atlas_dir = project_root.join(".projectatlas");
13336 fs::create_dir_all(&atlas_dir)?;
13337 let db_path = atlas_dir.join("projectatlas.db");
13338 let mut writer = AtlasStore::open_for_project(&db_path, &project_root)?;
13339 let fixture =
13340 publish_detailed_relation_trace_fixture(&mut writer, "detailed-relation-storage")?;
13341 drop(writer);
13342 let mut store = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
13343 let generation = store
13344 .repository_graph_generation()?
13345 .ok_or_else(|| io::Error::other("graph generation missing"))?;
13346 let budget = maximum_repository_graph_read_budget()?;
13347
13348 assert_cursor_hydration_indexes(&store)?;
13349
13350 let one_frontier = vec![fixture.source.key().clone()];
13351 let many_frontier = std::iter::once(fixture.source.key().clone())
13352 .chain(
13353 fixture
13354 .relations
13355 .iter()
13356 .filter_map(|relation| relation.resolution().resolved_target().cloned())
13357 .take(3),
13358 )
13359 .collect::<Vec<_>>();
13360 require_eq(
13361 &many_frontier.len(),
13362 &4,
13363 "multi-frontier trace fixture cardinality",
13364 )?;
13365 for (context, frontier) in [
13366 ("one-frontier adjacency", one_frontier),
13367 ("many-frontier adjacency", many_frontier),
13368 ] {
13369 let (page, statements) = trace_statements(&mut store, |store| {
13370 store.repository_graph_adjacency_page_bounded(
13371 &frontier,
13372 RepositoryGraphDirection::Outbound,
13373 None,
13374 4,
13375 budget,
13376 None,
13377 )
13378 })?;
13379 require(
13380 page.page.rows.len() == 4 && page.page.truncated,
13381 &format!("{context} did not exercise its bounded sentinel"),
13382 )?;
13383 require_traced_statement_multiset(
13384 &statements,
13385 &[
13386 (ProjectIdentity, 1),
13387 (PublicationMetadata, 1),
13388 (ActiveGraphGeneration, 1),
13389 (AdjacencyRelations, 1),
13390 (RelationEntities, 1),
13391 ],
13392 context,
13393 )?;
13394 }
13395
13396 let relation_digests = fixture
13397 .relations
13398 .iter()
13399 .map(|relation| relation.key().digest_bytes())
13400 .collect::<Result<Vec<_>, _>>()?;
13401 for (context, digests, endpoint_chunks) in [
13402 ("one-relation hydration", &relation_digests[..1], 1),
13403 (
13404 "128 unique endpoint hydration",
13405 &relation_digests[..GRAPH_ENTITY_HYDRATION_CHUNK - 1],
13406 1,
13407 ),
13408 (
13409 "129 unique endpoint hydration",
13410 &relation_digests[..GRAPH_ENTITY_HYDRATION_CHUNK],
13411 2,
13412 ),
13413 ("maximum relation hydration", relation_digests.as_slice(), 3),
13414 ] {
13415 let (relations, statements) = trace_statements(&mut store, |store| {
13416 store.repository_graph_relation_rows_by_digest(
13417 fixture.project,
13418 generation,
13419 digests,
13420 budget,
13421 None,
13422 )
13423 })?;
13424 require_eq(
13425 &relations.rows.len(),
13426 &digests.len(),
13427 &format!("{context} returned relation count"),
13428 )?;
13429 require_traced_statement_multiset(
13430 &statements,
13431 &[
13432 (ProjectIdentity, 1),
13433 (PublicationMetadata, 1),
13434 (ActiveGraphGeneration, 1),
13435 (RelationsByDigest, 1),
13436 (RelationEntities, endpoint_chunks),
13437 ],
13438 context,
13439 )?;
13440 }
13441
13442 for (context, relations) in [
13443 ("one-relation occurrences", &fixture.relations[..1]),
13444 (
13445 "maximum relation occurrence batch",
13446 fixture.relations.as_slice(),
13447 ),
13448 ] {
13449 let (pages, statements) = trace_statements(&mut store, |store| {
13450 store.repository_graph_occurrence_pages_bounded(relations, 1, budget, None)
13451 })?;
13452 require(
13453 pages.pages.len() == relations.len()
13454 && pages
13455 .pages
13456 .iter()
13457 .all(|page| page.rows.len() == 1 && page.truncated),
13458 &format!("{context} did not retain one row plus its occurrence sentinel"),
13459 )?;
13460 require_traced_statement_multiset(
13461 &statements,
13462 &[
13463 (ProjectIdentity, 1),
13464 (PublicationMetadata, 1),
13465 (ActiveGraphGeneration, 1),
13466 (RelationOccurrences, 1),
13467 ],
13468 context,
13469 )?;
13470 }
13471 let mut oversized_occurrence_batch = fixture.relations.clone();
13472 oversized_occurrence_batch.push(fixture.relations[0].clone());
13473 let oversized_occurrences = require_db_error(
13474 store.repository_graph_occurrence_pages_bounded(
13475 &oversized_occurrence_batch,
13476 1,
13477 budget,
13478 None,
13479 ),
13480 "oversized relation occurrence batch was accepted",
13481 )?;
13482 require(
13483 matches!(oversized_occurrences, DbError::GraphContract(_)),
13484 &format!("unexpected oversized occurrence error: {oversized_occurrences}"),
13485 )?;
13486
13487 let one_purpose_path = vec![".".to_string()];
13488 let one_purpose_chunk = std::iter::once(".".to_string())
13489 .chain(
13490 (1..crate::MAX_PURPOSE_CURATION_BATCH_ROWS)
13491 .map(|index| format!("missing/purpose-{index}.rs")),
13492 )
13493 .collect::<Vec<_>>();
13494 let two_purpose_chunks = one_purpose_chunk
13495 .iter()
13496 .cloned()
13497 .chain(std::iter::once("missing/purpose-overflow.rs".to_string()))
13498 .collect::<Vec<_>>();
13499 for (context, paths, expected) in [
13500 ("one purpose owner", one_purpose_path.as_slice(), 1),
13501 (
13502 "one full purpose-owner chunk",
13503 one_purpose_chunk.as_slice(),
13504 1,
13505 ),
13506 ("two purpose-owner chunks", two_purpose_chunks.as_slice(), 2),
13507 ] {
13508 let (_purposes, statements) = trace_statements(&mut store, |store| {
13509 store.load_purpose_owner_nodes_by_paths_controlled(
13510 fixture.project,
13511 generation,
13512 paths,
13513 budget,
13514 None,
13515 )
13516 })?;
13517 require_traced_statement_multiset(
13518 &statements,
13519 &[
13520 (ProjectIdentity, 1),
13521 (PublicationMetadata, 1),
13522 (ActiveGraphGeneration, 1),
13523 (PurposeOwners, expected),
13524 ],
13525 context,
13526 )?;
13527 }
13528
13529 store.finish_index_read_snapshot()?;
13530 Ok(())
13531 }
13532
13533 #[test]
13534 fn cursor_hydration_is_ordered_bounded_and_fail_closed() -> Result<(), Box<dyn Error>> {
13535 let temp = tempfile::tempdir()?;
13536 let project_root = temp.path().join("cursor-hydration");
13537 let atlas_dir = project_root.join(".projectatlas");
13538 fs::create_dir_all(&atlas_dir)?;
13539 let db_path = atlas_dir.join("projectatlas.db");
13540 let mut writer = AtlasStore::open_for_project(&db_path, &project_root)?;
13541 let fixture = publish_fixture(&mut writer, "cursor-hydration")?;
13542 writer.connection.execute(
13543 "INSERT INTO graph_relation_occurrences(
13544 relation_key, file_path, start_line, start_column, end_line, end_column
13545 ) VALUES(?1, 'Cargo.toml', 1, 0, 1, 1)",
13546 params![&fixture.relations[0].key().digest_bytes()?[..]],
13547 )?;
13548 writer.connection.execute(
13549 "INSERT INTO graph_coverage(
13550 project_instance_id, scope_kind, scope_path, relation_scope,
13551 relation_kind, state, total, covered, omitted, reason, reached_limit
13552 ) VALUES(?1, 'path', 'Cargo.toml', NULL, NULL,
13553 'complete', 1, 1, 0, NULL, NULL)",
13554 params![&fixture.project.as_bytes()[..]],
13555 )?;
13556 drop(writer);
13557 let store = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
13558 let generation = store
13559 .repository_graph_generation()?
13560 .ok_or_else(|| io::Error::other("graph generation missing"))?;
13561 let full_budget = RepositoryGraphReadBudget::new(
13562 MAX_REPOSITORY_GRAPH_FRONTIER as u32,
13563 MAX_REPOSITORY_GRAPH_FRONTIER as u32,
13564 RepositoryGraphReadBudget::MAX_DECODED_BYTES,
13565 RepositoryGraphReadBudget::MAX_HYDRATED_ENTITIES,
13566 RepositoryGraphReadBudget::MAX_HYDRATED_PATHS,
13567 )?;
13568
13569 let expected_entities = fixture
13570 .entities
13571 .iter()
13572 .rev()
13573 .take(3)
13574 .cloned()
13575 .collect::<Vec<_>>();
13576 let entity_digests = expected_entities
13577 .iter()
13578 .map(|entity| entity.key().digest_bytes())
13579 .collect::<Result<Vec<_>, _>>()?;
13580 let entity_batch = store.repository_graph_entities_by_digest(
13581 fixture.project,
13582 generation,
13583 &entity_digests,
13584 full_budget,
13585 None,
13586 )?;
13587 require_eq(
13588 &entity_batch.rows,
13589 &expected_entities,
13590 "ordered entity cursor hydration",
13591 )?;
13592 require_eq(
13593 &entity_batch.work,
13594 &RepositoryGraphReadWork {
13595 requested_rows: 3,
13596 returned_rows: 3,
13597 decoded_bytes: entity_batch.work.decoded_bytes,
13598 hydrated_entities: 3,
13599 hydrated_paths: 2,
13600 },
13601 "exact entity cursor work",
13602 )?;
13603 require(
13604 entity_batch.work.decoded_bytes > 0,
13605 "entity cursor decoded no SQLite payload bytes",
13606 )?;
13607
13608 let file_entity = fixture
13609 .entities
13610 .iter()
13611 .find(|entity| matches!(entity.selector(), EntitySelector::File { .. }))
13612 .ok_or_else(|| io::Error::other("file anchor fixture missing"))?;
13613 let file_anchor = store.repository_graph_entity_bounded(
13614 file_entity.key(),
13615 generation,
13616 full_budget,
13617 None,
13618 )?;
13619 require_eq(
13620 &file_anchor.rows,
13621 &vec![file_entity.clone()],
13622 "exact file anchor hydration",
13623 )?;
13624 require_eq(
13625 &file_anchor.work,
13626 &RepositoryGraphReadWork {
13627 requested_rows: 1,
13628 returned_rows: 1,
13629 decoded_bytes: file_anchor.work.decoded_bytes,
13630 hydrated_entities: 1,
13631 hydrated_paths: 1,
13632 },
13633 "exact file anchor work",
13634 )?;
13635 let exact_file_budget =
13636 RepositoryGraphReadBudget::new(1, 1, file_anchor.work.decoded_bytes, 1, 1)?;
13637 require_eq(
13638 &store.repository_graph_entity_bounded(
13639 file_entity.key(),
13640 generation,
13641 exact_file_budget,
13642 None,
13643 )?,
13644 &file_anchor,
13645 "exact file anchor envelope",
13646 )?;
13647 require_eq(
13648 &store.repository_graph_entity(file_entity.key())?,
13649 &Some(file_entity.clone()),
13650 "legacy file anchor wrapper compatibility",
13651 )?;
13652 let file_decode_overrun = require_db_error(
13653 store.repository_graph_entity_bounded(
13654 file_entity.key(),
13655 generation,
13656 RepositoryGraphReadBudget::new(1, 1, file_anchor.work.decoded_bytes - 1, 1, 1)?,
13657 None,
13658 ),
13659 "file anchor decoded-byte overrun was accepted",
13660 )?;
13661 require(
13662 matches!(file_decode_overrun, DbError::GraphContract(_)),
13663 &format!("unexpected file anchor envelope error: {file_decode_overrun}"),
13664 )?;
13665 let missing_file_selector = EntitySelector::File {
13666 path: RepositoryFilePath::new(Path::new("missing-anchor.rs"))?,
13667 };
13668 let missing_file_key = GraphEntityKey::new(fixture.project, &missing_file_selector);
13669 let missing_file = store.repository_graph_entity_bounded(
13670 &missing_file_key,
13671 generation,
13672 full_budget,
13673 None,
13674 )?;
13675 require(
13676 missing_file.rows.is_empty()
13677 && missing_file.work
13678 == (RepositoryGraphReadWork {
13679 requested_rows: 1,
13680 returned_rows: 0,
13681 decoded_bytes: 0,
13682 hydrated_entities: 0,
13683 hydrated_paths: 0,
13684 }),
13685 "missing file anchor did not return exact empty work",
13686 )?;
13687
13688 let anchor_path = RepositoryNodePath::new(Path::new("src/Äuth.rs"))?;
13689 let path_anchors = store.repository_graph_entities_by_path_bounded(
13690 fixture.project,
13691 generation,
13692 &anchor_path,
13693 1,
13694 full_budget,
13695 None,
13696 )?;
13697 require(
13698 path_anchors.page.rows.len() == 1
13699 && path_anchors.page.truncated
13700 && matches!(
13701 path_anchors.page.rows[0].selector(),
13702 EntitySelector::File { .. }
13703 )
13704 && path_anchors.work.requested_rows == 1
13705 && path_anchors.work.returned_rows == 1
13706 && path_anchors.work.decoded_bytes > 0
13707 && path_anchors.work.hydrated_entities == 2
13708 && path_anchors.work.hydrated_paths == 1,
13709 "path anchor page lost stable order or sentinel work",
13710 )?;
13711 let exact_path_anchor_budget =
13712 RepositoryGraphReadBudget::new(1, 1, path_anchors.work.decoded_bytes, 2, 1)?;
13713 require_eq(
13714 &store.repository_graph_entities_by_path_bounded(
13715 fixture.project,
13716 generation,
13717 &anchor_path,
13718 1,
13719 exact_path_anchor_budget,
13720 None,
13721 )?,
13722 &path_anchors,
13723 "exact path anchor envelope",
13724 )?;
13725 require_eq(
13726 &store.repository_graph_entities_by_path(fixture.project, &anchor_path, 1)?,
13727 &path_anchors.page,
13728 "legacy path anchor wrapper compatibility",
13729 )?;
13730 for (budget, limit, context) in [
13731 (
13732 RepositoryGraphReadBudget::new(1, 1, path_anchors.work.decoded_bytes - 1, 2, 1)?,
13733 1,
13734 "path anchor decoded-byte overrun was accepted",
13735 ),
13736 (
13737 RepositoryGraphReadBudget::new(1, 1, path_anchors.work.decoded_bytes, 1, 1)?,
13738 1,
13739 "path anchor sentinel entity overrun was accepted",
13740 ),
13741 (
13742 exact_path_anchor_budget,
13743 2,
13744 "path anchor returned-row overrun was accepted",
13745 ),
13746 ] {
13747 let error = require_db_error(
13748 store.repository_graph_entities_by_path_bounded(
13749 fixture.project,
13750 generation,
13751 &anchor_path,
13752 limit,
13753 budget,
13754 None,
13755 ),
13756 context,
13757 )?;
13758 require(
13759 matches!(error, DbError::GraphContract(_)),
13760 &format!("unexpected path anchor envelope error: {error}"),
13761 )?;
13762 }
13763 let anchor_cancellation = projectatlas_core::IndexCancellation::new();
13764 anchor_cancellation.cancel();
13765 let anchor_control = IndexWorkControl::new(anchor_cancellation, None);
13766 let cancelled_anchor = store.repository_graph_entities_by_path_bounded(
13767 fixture.project,
13768 generation,
13769 &anchor_path,
13770 1,
13771 full_budget,
13772 Some(&anchor_control),
13773 );
13774 require(
13775 matches!(
13776 cancelled_anchor,
13777 Err(DbError::IndexWork(
13778 projectatlas_core::IndexWorkFailure::Cancelled {
13779 stage: IndexWorkStage::RepositoryTraversal
13780 }
13781 ))
13782 ),
13783 "path anchor cancellation was not typed",
13784 )?;
13785 let expired_anchor = IndexWorkControl::with_deadline(
13786 projectatlas_core::IndexCancellation::new(),
13787 Instant::now(),
13788 );
13789 let anchor_deadline = store.repository_graph_entity_bounded(
13790 file_entity.key(),
13791 generation,
13792 full_budget,
13793 Some(&expired_anchor),
13794 );
13795 require(
13796 matches!(
13797 anchor_deadline,
13798 Err(DbError::IndexWork(
13799 projectatlas_core::IndexWorkFailure::DeadlineExceeded {
13800 stage: IndexWorkStage::RepositoryTraversal
13801 }
13802 ))
13803 ),
13804 "file anchor deadline was not typed",
13805 )?;
13806
13807 let expected_relations = fixture.relations.iter().rev().cloned().collect::<Vec<_>>();
13808 let relation_digests = expected_relations
13809 .iter()
13810 .map(|relation| relation.key().digest_bytes())
13811 .collect::<Result<Vec<_>, _>>()?;
13812 let relation_rows = store.repository_graph_relation_rows_by_digest(
13813 fixture.project,
13814 generation,
13815 &relation_digests,
13816 full_budget,
13817 None,
13818 )?;
13819 require_eq(
13820 &relation_rows
13821 .rows
13822 .iter()
13823 .map(|row| row.relation.clone())
13824 .collect::<Vec<_>>(),
13825 &expected_relations,
13826 "ordered relation cursor hydration",
13827 )?;
13828 require(
13829 relation_rows.rows.iter().all(|row| {
13830 row.source.key().project() == fixture.project
13831 && row
13832 .target
13833 .as_ref()
13834 .is_none_or(|target| target.key().project() == fixture.project)
13835 }),
13836 "relation cursor hydration returned an unvalidated endpoint",
13837 )?;
13838 require_eq(
13839 &relation_rows.work,
13840 &RepositoryGraphReadWork {
13841 requested_rows: 4,
13842 returned_rows: 4,
13843 decoded_bytes: relation_rows.work.decoded_bytes,
13844 hydrated_entities: 3,
13845 hydrated_paths: 1,
13846 },
13847 "exact relation cursor work",
13848 )?;
13849 require(
13850 relation_rows.work.decoded_bytes > entity_batch.work.decoded_bytes,
13851 "relation cursor did not meter relation and endpoint payload bytes",
13852 )?;
13853
13854 let exact_entity_budget =
13855 RepositoryGraphReadBudget::new(3, 3, entity_batch.work.decoded_bytes, 3, 2)?;
13856 require_eq(
13857 &store
13858 .repository_graph_entities_by_digest(
13859 fixture.project,
13860 generation,
13861 &entity_digests,
13862 exact_entity_budget,
13863 None,
13864 )?
13865 .work,
13866 &entity_batch.work,
13867 "exact entity envelope",
13868 )?;
13869 let exact_relation_budget =
13870 RepositoryGraphReadBudget::new(4, 4, relation_rows.work.decoded_bytes, 3, 1)?;
13871 require_eq(
13872 &store
13873 .repository_graph_relation_rows_by_digest(
13874 fixture.project,
13875 generation,
13876 &relation_digests,
13877 exact_relation_budget,
13878 None,
13879 )?
13880 .work,
13881 &relation_rows.work,
13882 "exact relation envelope",
13883 )?;
13884
13885 for (budget, context) in [
13886 (
13887 RepositoryGraphReadBudget::new(3, 3, entity_batch.work.decoded_bytes - 1, 3, 2)?,
13888 "decoded-byte envelope overrun was accepted",
13889 ),
13890 (
13891 RepositoryGraphReadBudget::new(3, 3, entity_batch.work.decoded_bytes, 3, 1)?,
13892 "purpose-path envelope overrun was accepted",
13893 ),
13894 ] {
13895 let error = require_db_error(
13896 store.repository_graph_entities_by_digest(
13897 fixture.project,
13898 generation,
13899 &entity_digests,
13900 budget,
13901 None,
13902 ),
13903 context,
13904 )?;
13905 require(
13906 matches!(error, DbError::GraphContract(_)),
13907 &format!("unexpected entity envelope error: {error}"),
13908 )?;
13909 }
13910 let endpoint_budget =
13911 RepositoryGraphReadBudget::new(4, 4, relation_rows.work.decoded_bytes, 2, 1)?;
13912 let endpoint_error = require_db_error(
13913 store.repository_graph_relation_rows_by_digest(
13914 fixture.project,
13915 generation,
13916 &relation_digests,
13917 endpoint_budget,
13918 None,
13919 ),
13920 "endpoint entity envelope overrun was accepted",
13921 )?;
13922 require(
13923 matches!(endpoint_error, DbError::GraphContract(_)),
13924 &format!("unexpected endpoint envelope error: {endpoint_error}"),
13925 )?;
13926
13927 for (budget, context) in [
13928 (
13929 RepositoryGraphReadBudget::new(
13930 2,
13931 3,
13932 RepositoryGraphReadBudget::MAX_DECODED_BYTES,
13933 3,
13934 2,
13935 )?,
13936 "requested-row envelope overrun was accepted",
13937 ),
13938 (
13939 RepositoryGraphReadBudget::new(
13940 3,
13941 2,
13942 RepositoryGraphReadBudget::MAX_DECODED_BYTES,
13943 3,
13944 2,
13945 )?,
13946 "returned-row envelope overrun was accepted",
13947 ),
13948 ] {
13949 let error = require_db_error(
13950 store.repository_graph_entities_by_digest(
13951 fixture.project,
13952 generation,
13953 &entity_digests,
13954 budget,
13955 None,
13956 ),
13957 context,
13958 )?;
13959 require(
13960 matches!(error, DbError::GraphContract(_)),
13961 &format!("unexpected row-envelope error: {error}"),
13962 )?;
13963 }
13964
13965 for invalid in [
13966 RepositoryGraphReadBudget::new(0, 1, 1, 1, 1),
13967 RepositoryGraphReadBudget::new(
13968 RepositoryGraphReadBudget::MAX_REQUESTED_ROWS + 1,
13969 1,
13970 1,
13971 1,
13972 1,
13973 ),
13974 RepositoryGraphReadBudget::new(
13975 1,
13976 RepositoryGraphReadBudget::MAX_RETURNED_ROWS + 1,
13977 1,
13978 1,
13979 1,
13980 ),
13981 RepositoryGraphReadBudget::new(
13982 1,
13983 1,
13984 RepositoryGraphReadBudget::MAX_DECODED_BYTES + 1,
13985 1,
13986 1,
13987 ),
13988 RepositoryGraphReadBudget::new(
13989 1,
13990 1,
13991 1,
13992 RepositoryGraphReadBudget::MAX_HYDRATED_ENTITIES + 1,
13993 1,
13994 ),
13995 RepositoryGraphReadBudget::new(
13996 1,
13997 1,
13998 1,
13999 1,
14000 RepositoryGraphReadBudget::MAX_HYDRATED_PATHS + 1,
14001 ),
14002 ] {
14003 require(
14004 matches!(invalid, Err(GraphContractError::InvalidLimits { .. })),
14005 "invalid graph read budget was accepted",
14006 )?;
14007 }
14008
14009 let empty_entities = store.repository_graph_entities_by_digest(
14010 fixture.project,
14011 generation,
14012 &[],
14013 full_budget,
14014 None,
14015 )?;
14016 let empty_relations = store.repository_graph_relation_rows_by_digest(
14017 fixture.project,
14018 generation,
14019 &[],
14020 full_budget,
14021 None,
14022 )?;
14023 require(
14024 empty_entities.rows.is_empty()
14025 && empty_relations.rows.is_empty()
14026 && empty_entities.work
14027 == (RepositoryGraphReadWork {
14028 requested_rows: 0,
14029 returned_rows: 0,
14030 decoded_bytes: 0,
14031 hydrated_entities: 0,
14032 hydrated_paths: 0,
14033 })
14034 && empty_relations.work == empty_entities.work,
14035 "empty cursor hydration was not stable",
14036 )?;
14037
14038 let purpose_paths = vec![
14039 "Cargo.toml".to_string(),
14040 ".".to_string(),
14041 "src/Äuth.rs".to_string(),
14042 "src".to_string(),
14043 ];
14044 let purpose_batch = store.load_purpose_owner_nodes_by_paths_controlled(
14045 fixture.project,
14046 generation,
14047 &purpose_paths,
14048 full_budget,
14049 None,
14050 )?;
14051 require_eq(
14052 &purpose_batch
14053 .rows
14054 .iter()
14055 .map(|node| node.node.path.clone())
14056 .collect::<Vec<_>>(),
14057 &purpose_paths,
14058 "ordered purpose-owner hydration",
14059 )?;
14060 require_eq(
14061 &purpose_batch.work,
14062 &RepositoryGraphReadWork {
14063 requested_rows: 4,
14064 returned_rows: 4,
14065 decoded_bytes: purpose_batch.work.decoded_bytes,
14066 hydrated_entities: 0,
14067 hydrated_paths: 4,
14068 },
14069 "exact purpose-owner work",
14070 )?;
14071 require(
14072 purpose_batch.work.decoded_bytes > 0,
14073 "purpose-owner hydration decoded no SQLite payload bytes",
14074 )?;
14075 let exact_purpose_budget =
14076 RepositoryGraphReadBudget::new(4, 4, purpose_batch.work.decoded_bytes, 1, 4)?;
14077 require_eq(
14078 &store
14079 .load_purpose_owner_nodes_by_paths_controlled(
14080 fixture.project,
14081 generation,
14082 &purpose_paths,
14083 exact_purpose_budget,
14084 None,
14085 )?
14086 .work,
14087 &purpose_batch.work,
14088 "exact purpose-owner envelope",
14089 )?;
14090 for (budget, context) in [
14091 (
14092 RepositoryGraphReadBudget::new(4, 4, purpose_batch.work.decoded_bytes - 1, 1, 4)?,
14093 "purpose-owner decoded-byte overrun was accepted",
14094 ),
14095 (
14096 RepositoryGraphReadBudget::new(4, 4, purpose_batch.work.decoded_bytes, 1, 3)?,
14097 "purpose-owner path overrun was accepted",
14098 ),
14099 ] {
14100 let error = require_db_error(
14101 store.load_purpose_owner_nodes_by_paths_controlled(
14102 fixture.project,
14103 generation,
14104 &purpose_paths,
14105 budget,
14106 None,
14107 ),
14108 context,
14109 )?;
14110 require(
14111 matches!(error, DbError::GraphContract(_)),
14112 &format!("unexpected purpose-owner envelope error: {error}"),
14113 )?;
14114 }
14115 let mut missing_purpose_paths = purpose_paths.clone();
14116 missing_purpose_paths.push("missing/purpose-owner.rs".to_string());
14117 let missing_purpose = store.load_purpose_owner_nodes_by_paths_controlled(
14118 fixture.project,
14119 generation,
14120 &missing_purpose_paths,
14121 full_budget,
14122 None,
14123 )?;
14124 require_eq(
14125 &missing_purpose
14126 .rows
14127 .iter()
14128 .map(|node| node.node.path.clone())
14129 .collect::<Vec<_>>(),
14130 &purpose_paths,
14131 "absent purpose-owner candidate ordering",
14132 )?;
14133 require(
14134 missing_purpose.work.requested_rows == 5
14135 && missing_purpose.work.returned_rows == 4
14136 && missing_purpose.work.hydrated_paths == 4,
14137 "absent purpose-owner candidate work was not exact",
14138 )?;
14139 let duplicate_purpose = require_db_error(
14140 store.load_purpose_owner_nodes_by_paths_controlled(
14141 fixture.project,
14142 generation,
14143 &[purpose_paths[0].clone(), purpose_paths[0].clone()],
14144 full_budget,
14145 None,
14146 ),
14147 "duplicate purpose-owner paths were accepted",
14148 )?;
14149 require(
14150 matches!(duplicate_purpose, DbError::GraphContract(_)),
14151 &format!("unexpected duplicate purpose-owner error: {duplicate_purpose}"),
14152 )?;
14153 let purpose_cancellation = projectatlas_core::IndexCancellation::new();
14154 purpose_cancellation.cancel();
14155 let purpose_control = IndexWorkControl::new(purpose_cancellation, None);
14156 let cancelled_purpose = store.load_purpose_owner_nodes_by_paths_controlled(
14157 fixture.project,
14158 generation,
14159 &purpose_paths,
14160 full_budget,
14161 Some(&purpose_control),
14162 );
14163 require(
14164 matches!(
14165 cancelled_purpose,
14166 Err(DbError::IndexWork(
14167 projectatlas_core::IndexWorkFailure::Cancelled {
14168 stage: IndexWorkStage::RepositoryTraversal
14169 }
14170 ))
14171 ),
14172 "purpose-owner cancellation was not typed",
14173 )?;
14174
14175 let occurrence_batch = store.repository_graph_occurrence_pages_bounded(
14176 &fixture.relations,
14177 1,
14178 full_budget,
14179 None,
14180 )?;
14181 require(
14182 occurrence_batch.pages.len() == fixture.relations.len()
14183 && occurrence_batch.pages[0].rows.len() == 1
14184 && occurrence_batch.pages[0].truncated
14185 && occurrence_batch.pages[1..]
14186 .iter()
14187 .all(|page| page.rows.is_empty() && !page.truncated),
14188 "batched occurrence pages lost owner order or truncation state",
14189 )?;
14190 require_eq(
14191 &occurrence_batch.work,
14192 &RepositoryGraphReadWork {
14193 requested_rows: 4,
14194 returned_rows: 1,
14195 decoded_bytes: occurrence_batch.work.decoded_bytes,
14196 hydrated_entities: 0,
14197 hydrated_paths: 2,
14198 },
14199 "exact occurrence batch work including sentinel path",
14200 )?;
14201 let exact_occurrence_budget =
14202 RepositoryGraphReadBudget::new(4, 1, occurrence_batch.work.decoded_bytes, 1, 2)?;
14203 require_eq(
14204 &store.repository_graph_occurrence_pages_bounded(
14205 &fixture.relations,
14206 1,
14207 exact_occurrence_budget,
14208 None,
14209 )?,
14210 &occurrence_batch,
14211 "exact occurrence envelope",
14212 )?;
14213 require_eq(
14214 &store.repository_graph_occurrence_pages(&fixture.relations, 1, None)?,
14215 &occurrence_batch.pages,
14216 "legacy occurrence wrapper compatibility",
14217 )?;
14218 for (budget, limit, context) in [
14219 (
14220 RepositoryGraphReadBudget::new(
14221 4,
14222 1,
14223 occurrence_batch.work.decoded_bytes - 1,
14224 1,
14225 2,
14226 )?,
14227 1,
14228 "occurrence decoded-byte overrun was accepted",
14229 ),
14230 (
14231 RepositoryGraphReadBudget::new(4, 1, occurrence_batch.work.decoded_bytes, 1, 1)?,
14232 1,
14233 "occurrence sentinel path overrun was accepted",
14234 ),
14235 (
14236 RepositoryGraphReadBudget::new(
14237 4,
14238 1,
14239 RepositoryGraphReadBudget::MAX_DECODED_BYTES,
14240 1,
14241 RepositoryGraphReadBudget::MAX_HYDRATED_PATHS,
14242 )?,
14243 3,
14244 "occurrence returned-row overrun was accepted",
14245 ),
14246 ] {
14247 let error = require_db_error(
14248 store.repository_graph_occurrence_pages_bounded(
14249 &fixture.relations,
14250 limit,
14251 budget,
14252 None,
14253 ),
14254 context,
14255 )?;
14256 require(
14257 matches!(error, DbError::GraphContract(_)),
14258 &format!("unexpected occurrence envelope error: {error}"),
14259 )?;
14260 }
14261 let occurrence_cancellation = projectatlas_core::IndexCancellation::new();
14262 occurrence_cancellation.cancel();
14263 let occurrence_control = IndexWorkControl::new(occurrence_cancellation, None);
14264 let cancelled_occurrences = store.repository_graph_occurrence_pages_bounded(
14265 &fixture.relations,
14266 1,
14267 full_budget,
14268 Some(&occurrence_control),
14269 );
14270 require(
14271 matches!(
14272 cancelled_occurrences,
14273 Err(DbError::IndexWork(
14274 projectatlas_core::IndexWorkFailure::Cancelled {
14275 stage: IndexWorkStage::RepositoryTraversal
14276 }
14277 ))
14278 ),
14279 "occurrence batch cancellation was not typed",
14280 )?;
14281 let expired_occurrences = IndexWorkControl::with_deadline(
14282 projectatlas_core::IndexCancellation::new(),
14283 Instant::now(),
14284 );
14285 let occurrence_deadline = store.repository_graph_occurrence_pages_bounded(
14286 &fixture.relations,
14287 1,
14288 full_budget,
14289 Some(&expired_occurrences),
14290 );
14291 require(
14292 matches!(
14293 occurrence_deadline,
14294 Err(DbError::IndexWork(
14295 projectatlas_core::IndexWorkFailure::DeadlineExceeded {
14296 stage: IndexWorkStage::RepositoryTraversal
14297 }
14298 ))
14299 ),
14300 "occurrence batch deadline was not typed",
14301 )?;
14302
14303 let coverage_paths = vec![
14304 RepositoryNodePath::new(Path::new("src/Äuth.rs"))?,
14305 RepositoryNodePath::new(Path::new("Cargo.toml"))?,
14306 ];
14307 let coverage_batch = store.repository_graph_path_coverage_bounded(
14308 fixture.project,
14309 generation,
14310 &coverage_paths,
14311 full_budget,
14312 None,
14313 )?;
14314 require_eq(
14315 &coverage_batch
14316 .page
14317 .rows
14318 .iter()
14319 .map(|coverage| match coverage.scope() {
14320 CoverageScope::Path { path } => path.as_str().to_string(),
14321 CoverageScope::Project => "project".to_string(),
14322 })
14323 .collect::<Vec<_>>(),
14324 &vec!["Cargo.toml".to_string(), "src/Äuth.rs".to_string()],
14325 "stable path coverage order",
14326 )?;
14327 require(
14328 !coverage_batch.page.truncated
14329 && coverage_batch.work
14330 == (RepositoryGraphReadWork {
14331 requested_rows: 2,
14332 returned_rows: 2,
14333 decoded_bytes: coverage_batch.work.decoded_bytes,
14334 hydrated_entities: 0,
14335 hydrated_paths: 2,
14336 })
14337 && coverage_batch.work.decoded_bytes > 0,
14338 "exact coverage batch work was incomplete",
14339 )?;
14340 let exact_coverage_budget =
14341 RepositoryGraphReadBudget::new(2, 2, coverage_batch.work.decoded_bytes, 1, 2)?;
14342 require_eq(
14343 &store.repository_graph_path_coverage_bounded(
14344 fixture.project,
14345 generation,
14346 &coverage_paths,
14347 exact_coverage_budget,
14348 None,
14349 )?,
14350 &coverage_batch,
14351 "exact coverage envelope",
14352 )?;
14353 require_eq(
14354 &store.repository_graph_path_coverage(fixture.project, &coverage_paths, None)?,
14355 &coverage_batch.page,
14356 "legacy coverage wrapper compatibility",
14357 )?;
14358 for (budget, context) in [
14359 (
14360 RepositoryGraphReadBudget::new(2, 2, coverage_batch.work.decoded_bytes - 1, 1, 2)?,
14361 "coverage decoded-byte overrun was accepted",
14362 ),
14363 (
14364 RepositoryGraphReadBudget::new(2, 1, coverage_batch.work.decoded_bytes, 1, 2)?,
14365 "coverage returned-row overrun was accepted",
14366 ),
14367 (
14368 RepositoryGraphReadBudget::new(2, 2, coverage_batch.work.decoded_bytes, 1, 1)?,
14369 "coverage hydrated-path overrun was accepted",
14370 ),
14371 ] {
14372 let error = require_db_error(
14373 store.repository_graph_path_coverage_bounded(
14374 fixture.project,
14375 generation,
14376 &coverage_paths,
14377 budget,
14378 None,
14379 ),
14380 context,
14381 )?;
14382 require(
14383 matches!(error, DbError::GraphContract(_)),
14384 &format!("unexpected coverage envelope error: {error}"),
14385 )?;
14386 }
14387 let coverage_cancellation = projectatlas_core::IndexCancellation::new();
14388 coverage_cancellation.cancel();
14389 let coverage_control = IndexWorkControl::new(coverage_cancellation, None);
14390 let cancelled_coverage = store.repository_graph_path_coverage_bounded(
14391 fixture.project,
14392 generation,
14393 &coverage_paths,
14394 full_budget,
14395 Some(&coverage_control),
14396 );
14397 require(
14398 matches!(
14399 cancelled_coverage,
14400 Err(DbError::IndexWork(
14401 projectatlas_core::IndexWorkFailure::Cancelled {
14402 stage: IndexWorkStage::RepositoryTraversal
14403 }
14404 ))
14405 ),
14406 "coverage batch cancellation was not typed",
14407 )?;
14408 let expired_coverage = IndexWorkControl::with_deadline(
14409 projectatlas_core::IndexCancellation::new(),
14410 Instant::now(),
14411 );
14412 let coverage_deadline = store.repository_graph_path_coverage_bounded(
14413 fixture.project,
14414 generation,
14415 &coverage_paths,
14416 full_budget,
14417 Some(&expired_coverage),
14418 );
14419 require(
14420 matches!(
14421 coverage_deadline,
14422 Err(DbError::IndexWork(
14423 projectatlas_core::IndexWorkFailure::DeadlineExceeded {
14424 stage: IndexWorkStage::RepositoryTraversal
14425 }
14426 ))
14427 ),
14428 "coverage batch deadline was not typed",
14429 )?;
14430
14431 let missing = [0xff; 32];
14432 let missing_entity = require_db_error(
14433 store.repository_graph_entities_by_digest(
14434 fixture.project,
14435 generation,
14436 &[entity_digests[0], missing],
14437 full_budget,
14438 None,
14439 ),
14440 "missing entity cursor key returned a partial set",
14441 )?;
14442 require(
14443 matches!(
14444 missing_entity,
14445 DbError::GraphRowShape {
14446 table: "graph_entities",
14447 ..
14448 }
14449 ),
14450 &format!("unexpected missing-entity error: {missing_entity}"),
14451 )?;
14452 let missing_relation = require_db_error(
14453 store.repository_graph_relation_rows_by_digest(
14454 fixture.project,
14455 generation,
14456 &[relation_digests[0], missing],
14457 full_budget,
14458 None,
14459 ),
14460 "missing relation cursor key returned a partial set",
14461 )?;
14462 require(
14463 matches!(
14464 missing_relation,
14465 DbError::GraphRowShape {
14466 table: "graph_relations",
14467 ..
14468 }
14469 ),
14470 &format!("unexpected missing-relation error: {missing_relation}"),
14471 )?;
14472
14473 let duplicate = require_db_error(
14474 store.repository_graph_entities_by_digest(
14475 fixture.project,
14476 generation,
14477 &[entity_digests[0], entity_digests[0]],
14478 full_budget,
14479 None,
14480 ),
14481 "duplicate entity cursor keys were accepted",
14482 )?;
14483 require(
14484 matches!(duplicate, DbError::GraphContract(_)),
14485 &format!("unexpected duplicate hydration error: {duplicate}"),
14486 )?;
14487 let oversized = vec![[0; 32]; MAX_REPOSITORY_GRAPH_FRONTIER + 1];
14488 let oversized = require_db_error(
14489 store.repository_graph_relation_rows_by_digest(
14490 fixture.project,
14491 generation,
14492 &oversized,
14493 full_budget,
14494 None,
14495 ),
14496 "oversized relation cursor key set was accepted",
14497 )?;
14498 require(
14499 matches!(oversized, DbError::GraphContract(_)),
14500 &format!("unexpected oversized hydration error: {oversized}"),
14501 )?;
14502
14503 let foreign_project = ProjectInstanceId::from_bytes([0x7f; 16])?;
14504 let foreign = require_db_error(
14505 store.repository_graph_entities_by_digest(
14506 foreign_project,
14507 generation,
14508 &entity_digests,
14509 full_budget,
14510 None,
14511 ),
14512 "cross-project entity cursor hydration was accepted",
14513 )?;
14514 require(
14515 matches!(foreign, DbError::GraphProjectIdentityMismatch { .. }),
14516 &format!("unexpected cross-project hydration error: {foreign}"),
14517 )?;
14518 let stale_generation = generation
14519 .checked_next()
14520 .ok_or_else(|| io::Error::other("fixture generation overflowed"))?;
14521 let stale = require_db_error(
14522 store.repository_graph_relation_rows_by_digest(
14523 fixture.project,
14524 stale_generation,
14525 &relation_digests,
14526 full_budget,
14527 None,
14528 ),
14529 "stale-generation relation cursor hydration was accepted",
14530 )?;
14531 require(
14532 matches!(stale, DbError::GraphContract(_)),
14533 &format!("unexpected stale-generation hydration error: {stale}"),
14534 )?;
14535 for entities in [true, false] {
14536 let cancellation = projectatlas_core::IndexCancellation::new();
14537 cancellation.cancel();
14538 let control = IndexWorkControl::new(cancellation, None);
14539 let cancelled = if entities {
14540 store
14541 .repository_graph_entities_by_digest(
14542 fixture.project,
14543 generation,
14544 &entity_digests,
14545 full_budget,
14546 Some(&control),
14547 )
14548 .map(|_| ())
14549 } else {
14550 store
14551 .repository_graph_relation_rows_by_digest(
14552 fixture.project,
14553 generation,
14554 &relation_digests,
14555 full_budget,
14556 Some(&control),
14557 )
14558 .map(|_| ())
14559 };
14560 require(
14561 matches!(
14562 cancelled,
14563 Err(DbError::IndexWork(
14564 projectatlas_core::IndexWorkFailure::Cancelled {
14565 stage: IndexWorkStage::RepositoryTraversal
14566 }
14567 ))
14568 ),
14569 "cursor hydration cancellation was not typed",
14570 )?;
14571 }
14572 let expired = IndexWorkControl::with_deadline(
14573 projectatlas_core::IndexCancellation::new(),
14574 Instant::now(),
14575 );
14576 let deadline = store.repository_graph_entities_by_digest(
14577 fixture.project,
14578 generation,
14579 &entity_digests,
14580 full_budget,
14581 Some(&expired),
14582 );
14583 require(
14584 matches!(
14585 deadline,
14586 Err(DbError::IndexWork(
14587 projectatlas_core::IndexWorkFailure::DeadlineExceeded {
14588 stage: IndexWorkStage::RepositoryTraversal
14589 }
14590 ))
14591 ),
14592 "cursor hydration deadline was not typed",
14593 )?;
14594
14595 let source = fixture
14596 .entities
14597 .iter()
14598 .find(|entity| matches!(entity.selector(), EntitySelector::File { .. }))
14599 .ok_or_else(|| io::Error::other("source file fixture missing"))?;
14600 let adjacency = store.repository_graph_adjacency_page_bounded(
14601 &[source.key().clone()],
14602 RepositoryGraphDirection::Outbound,
14603 None,
14604 1,
14605 full_budget,
14606 None,
14607 )?;
14608 require(
14609 adjacency.page.rows.len() == 1
14610 && adjacency.page.truncated
14611 && adjacency.work.requested_rows == 1
14612 && adjacency.work.returned_rows == 1
14613 && adjacency.work.decoded_bytes > 0
14614 && adjacency.work.hydrated_entities > 0
14615 && adjacency.work.hydrated_paths > 0,
14616 "bounded adjacency page omitted exact raw or endpoint work",
14617 )?;
14618 let exact_adjacency_budget = RepositoryGraphReadBudget::new(
14619 1,
14620 1,
14621 adjacency.work.decoded_bytes,
14622 adjacency.work.hydrated_entities,
14623 adjacency.work.hydrated_paths,
14624 )?;
14625 require_eq(
14626 &store.repository_graph_adjacency_page_bounded(
14627 &[source.key().clone()],
14628 RepositoryGraphDirection::Outbound,
14629 None,
14630 1,
14631 exact_adjacency_budget,
14632 None,
14633 )?,
14634 &adjacency,
14635 "exact adjacency envelope",
14636 )?;
14637 let adjacency_overrun = require_db_error(
14638 store.repository_graph_adjacency_page_bounded(
14639 &[source.key().clone()],
14640 RepositoryGraphDirection::Outbound,
14641 None,
14642 1,
14643 RepositoryGraphReadBudget::new(
14644 1,
14645 1,
14646 adjacency.work.decoded_bytes - 1,
14647 adjacency.work.hydrated_entities,
14648 adjacency.work.hydrated_paths,
14649 )?,
14650 None,
14651 ),
14652 "adjacency decoded-byte overrun was accepted",
14653 )?;
14654 require(
14655 matches!(adjacency_overrun, DbError::GraphContract(_)),
14656 &format!("unexpected adjacency envelope error: {adjacency_overrun}"),
14657 )?;
14658 let adjacency_return_limit = require_db_error(
14659 store.repository_graph_adjacency_page_bounded(
14660 &[source.key().clone()],
14661 RepositoryGraphDirection::Outbound,
14662 None,
14663 2,
14664 exact_adjacency_budget,
14665 None,
14666 ),
14667 "adjacency page limit exceeded the return budget",
14668 )?;
14669 require(
14670 matches!(adjacency_return_limit, DbError::GraphContract(_)),
14671 &format!("unexpected adjacency return-budget error: {adjacency_return_limit}"),
14672 )?;
14673 let continuation = adjacency
14674 .page
14675 .continuation
14676 .ok_or_else(|| io::Error::other("adjacency continuation missing"))?;
14677 let encoded =
14678 serde_json::to_vec(&(RepositoryGraphDirection::Outbound, continuation.clone()))?;
14679 let decoded: (
14680 RepositoryGraphDirection,
14681 RepositoryGraphAdjacencyContinuation,
14682 ) = serde_json::from_slice(&encoded)?;
14683 require_eq(
14684 &decoded,
14685 &(RepositoryGraphDirection::Outbound, continuation),
14686 "opaque relation cursor serde round trip",
14687 )?;
14688
14689 assert_cursor_hydration_indexes(&store)?;
14690 store.finish_index_read_snapshot()?;
14691 Ok(())
14692 }
14693
14694 #[test]
14695 fn continued_adjacency_seeks_past_high_degree_prefixes() -> Result<(), Box<dyn Error>> {
14696 let temp = tempfile::tempdir()?;
14697 let project_root = temp.path().join("continued-adjacency-work");
14698 let atlas_dir = project_root.join(".projectatlas");
14699 fs::create_dir_all(&atlas_dir)?;
14700 let db_path = atlas_dir.join("projectatlas.db");
14701 let mut writer = AtlasStore::open_for_project(&db_path, &project_root)?;
14702 let fixture = publish_fixture(&mut writer, "continued-adjacency-work")?;
14703 let source = fixture
14704 .entities
14705 .iter()
14706 .find(|entity| matches!(entity.selector(), EntitySelector::File { .. }))
14707 .ok_or_else(|| io::Error::other("source file fixture missing"))?;
14708 let external = fixture
14709 .entities
14710 .iter()
14711 .find(|entity| matches!(entity.selector(), EntitySelector::External { .. }))
14712 .ok_or_else(|| io::Error::other("external fixture missing"))?;
14713 writer.connection.execute(
14714 "WITH RECURSIVE sequence(value) AS (
14715 VALUES(1)
14716 UNION ALL
14717 SELECT value + 1 FROM sequence WHERE value < 100000
14718 )
14719 INSERT INTO graph_relations(
14720 relation_key, project_instance_id, canonical_identity,
14721 source_entity_key, relation_scope, relation_kind,
14722 resolution_status, target_entity_key, reference_text,
14723 candidate_count, confidence, completeness
14724 )
14725 SELECT CAST(printf('%032d', 1000000 + value) AS BLOB),
14726 ?1, printf('perf-%06d', value), ?2,
14727 'legacy', 'calls', 'resolved', ?3, NULL, NULL,
14728 'exact', 'complete'
14729 FROM sequence",
14730 params![
14731 fixture.project.as_bytes().as_slice(),
14732 source.key().digest_bytes()?.as_slice(),
14733 external.key().digest_bytes()?.as_slice(),
14734 ],
14735 )?;
14736 drop(writer);
14737
14738 let store = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
14739 let generation = store
14740 .repository_graph_generation()?
14741 .ok_or_else(|| io::Error::other("graph generation missing"))?;
14742 let source_digest = source.key().digest_bytes()?;
14743 let external_digest = external.key().digest_bytes()?;
14744 let skipped_frontier = vec![source.key().clone(), external.key().clone()];
14745 let skipped_continuation = RepositoryGraphAdjacencyContinuation {
14746 project: fixture.project,
14747 generation,
14748 direction: RepositoryGraphDirection::Outbound,
14749 relation: None,
14750 resolved_only: false,
14751 include_documents: false,
14752 frontier: vec![source_digest, external_digest],
14753 frontier_index: 1,
14754 relation_scope: String::new(),
14755 relation_kind: String::new(),
14756 relation_key: [0; 32],
14757 };
14758 let skipped_steps = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
14759 let skipped_counter = std::sync::Arc::clone(&skipped_steps);
14760 store.connection.progress_handler(
14761 1_000,
14762 Some(move || {
14763 skipped_counter.fetch_add(1_000, std::sync::atomic::Ordering::Relaxed);
14764 false
14765 }),
14766 )?;
14767 let skipped_result = store.repository_graph_adjacency_page(
14768 &skipped_frontier,
14769 RepositoryGraphDirection::Outbound,
14770 Some(&skipped_continuation),
14771 2,
14772 None,
14773 );
14774 store.connection.progress_handler(0, None::<fn() -> bool>)?;
14775 skipped_result?;
14776 require(
14777 skipped_steps.load(std::sync::atomic::Ordering::Relaxed) < 100_000,
14778 "continued adjacency scanned a completed high-degree frontier branch",
14779 )?;
14780
14781 let last_key: [u8; 32] = format!("{:032}", 1_100_000)
14782 .into_bytes()
14783 .try_into()
14784 .map_err(|_source| io::Error::other("high-degree cursor key width changed"))?;
14785 let deep_continuation = RepositoryGraphAdjacencyContinuation {
14786 project: fixture.project,
14787 generation,
14788 direction: RepositoryGraphDirection::Outbound,
14789 relation: None,
14790 resolved_only: false,
14791 include_documents: false,
14792 frontier: vec![source_digest],
14793 frontier_index: 0,
14794 relation_scope: "legacy".to_string(),
14795 relation_kind: "calls".to_string(),
14796 relation_key: last_key,
14797 };
14798 let deep_steps = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
14799 let deep_counter = std::sync::Arc::clone(&deep_steps);
14800 store.connection.progress_handler(
14801 1_000,
14802 Some(move || {
14803 deep_counter.fetch_add(1_000, std::sync::atomic::Ordering::Relaxed);
14804 false
14805 }),
14806 )?;
14807 let deep_result = store.repository_graph_adjacency_page(
14808 &[source.key().clone()],
14809 RepositoryGraphDirection::Outbound,
14810 Some(&deep_continuation),
14811 2,
14812 None,
14813 );
14814 store.connection.progress_handler(0, None::<fn() -> bool>)?;
14815 deep_result?;
14816 require(
14817 deep_steps.load(std::sync::atomic::Ordering::Relaxed) < 100_000,
14818 "continued adjacency rescanned a high-degree keyset prefix",
14819 )?;
14820 store.finish_index_read_snapshot()?;
14821 Ok(())
14822 }
14823
14824 #[test]
14825 fn bounded_rust_frontier_matches_indexed_recursive_cte_on_cycles_and_high_degree()
14826 -> Result<(), Box<dyn Error>> {
14827 const HIGH_DEGREE: usize = 4_096;
14828 const RECURSIVE_CTE: &str = "WITH RECURSIVE walk(entity_key) AS (
14829 VALUES(?2)
14830 UNION
14831 SELECT relation.target_entity_key
14832 FROM graph_relations AS relation INDEXED BY idx_graph_relations_source_kind
14833 JOIN walk ON relation.source_entity_key = walk.entity_key
14834 WHERE relation.project_instance_id = ?1
14835 AND relation.relation_scope = 'legacy'
14836 AND relation.relation_kind = 'calls'
14837 AND relation.target_entity_key IS NOT NULL
14838 )
14839 SELECT entity_key FROM walk ORDER BY entity_key";
14840
14841 let temp = tempfile::tempdir()?;
14842 let project_root = temp.path().join("frontier-cte-comparison");
14843 let atlas_dir = project_root.join(".projectatlas");
14844 fs::create_dir_all(&atlas_dir)?;
14845 let db_path = atlas_dir.join("projectatlas.db");
14846 let mut writer = AtlasStore::open_for_project(&db_path, &project_root)?;
14847 let fixture = publish_fixture(&mut writer, "frontier-cte-comparison")?;
14848 let source = fixture
14849 .entities
14850 .iter()
14851 .find(|entity| matches!(entity.selector(), EntitySelector::File { .. }))
14852 .ok_or_else(|| io::Error::other("source file fixture missing"))?;
14853 let generation = source.generation();
14854 let mut high_degree_entities = Vec::with_capacity(HIGH_DEGREE);
14855 let mut cyclic_relations = Vec::with_capacity(HIGH_DEGREE * 2);
14856 for index in 0..HIGH_DEGREE {
14857 let target = GraphEntity::new(
14858 fixture.project,
14859 EntitySelector::External {
14860 external: ExternalSelector {
14861 system: GraphIdentityText::new("frontier-measurement")?,
14862 identity: GraphIdentityText::new(format!("node-{index:05}"))?,
14863 },
14864 },
14865 generation,
14866 )?;
14867 cyclic_relations.push(LogicalRelation::new(
14868 source,
14869 GraphRelationKind::Legacy(RelationKind::Calls),
14870 RelationResolution::external(&target)?,
14871 ConfidenceClass::Exact,
14872 Completeness::Complete,
14873 generation,
14874 )?);
14875 cyclic_relations.push(LogicalRelation::new(
14876 &target,
14877 GraphRelationKind::Legacy(RelationKind::Calls),
14878 RelationResolution::resolved(source)?,
14879 ConfidenceClass::Exact,
14880 Completeness::Complete,
14881 generation,
14882 )?);
14883 high_degree_entities.push(target);
14884 }
14885 let transaction = writer.connection.transaction()?;
14886 insert_entities(&transaction, fixture.project, &high_degree_entities)?;
14887 insert_relations(&transaction, fixture.project, &cyclic_relations)?;
14888 transaction.commit()?;
14889 drop(writer);
14890
14891 let store = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
14892 let project = fixture.project.as_bytes();
14893 let source_key = source.key().digest_bytes()?;
14894 let plan_sql = format!("EXPLAIN QUERY PLAN {RECURSIVE_CTE}");
14895 let cte_plan = store
14896 .connection
14897 .prepare(&plan_sql)?
14898 .query_map(params![&project[..], &source_key[..]], |row| {
14899 row.get::<_, String>(3)
14900 })?
14901 .collect::<Result<Vec<_>, _>>()?;
14902 require(
14903 cte_plan
14904 .iter()
14905 .any(|detail| detail.contains("idx_graph_relations_source_kind"))
14906 && cte_plan
14907 .iter()
14908 .all(|detail| !detail.contains("SCAN relation")),
14909 &format!("recursive CTE did not retain the source-owned index: {cte_plan:?}"),
14910 )?;
14911
14912 let cte_steps = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
14913 let cte_counter = std::sync::Arc::clone(&cte_steps);
14914 store.connection.progress_handler(
14915 1_000,
14916 Some(move || {
14917 cte_counter.fetch_add(1_000, std::sync::atomic::Ordering::Relaxed);
14918 false
14919 }),
14920 )?;
14921 let cte_started = Instant::now();
14922 let cte_result = store
14923 .connection
14924 .prepare(RECURSIVE_CTE)?
14925 .query_map(params![&project[..], &source_key[..]], |row| {
14926 row.get::<_, Vec<u8>>(0)
14927 })?
14928 .map(|row| fixed_bytes::<32>("recursive_cte.entity_key", row?))
14929 .collect::<DbResult<Vec<_>>>();
14930 let cte_elapsed = cte_started.elapsed();
14931 store.connection.progress_handler(0, None::<fn() -> bool>)?;
14932 let cte_nodes = cte_result?;
14933
14934 let rust_steps = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
14935 let rust_counter = std::sync::Arc::clone(&rust_steps);
14936 store.connection.progress_handler(
14937 1_000,
14938 Some(move || {
14939 rust_counter.fetch_add(1_000, std::sync::atomic::Ordering::Relaxed);
14940 false
14941 }),
14942 )?;
14943 let rust_started = Instant::now();
14944 let rust_result = collect_bounded_outbound_calls(&store, source.key());
14945 let rust_elapsed = rust_started.elapsed();
14946 store.connection.progress_handler(0, None::<fn() -> bool>)?;
14947 let (rust_nodes, inspected_edges, peak_frontier) = rust_result?;
14948 let (repeated_nodes, repeated_edges, repeated_peak) =
14949 collect_bounded_outbound_calls(&store, source.key())?;
14950
14951 require_eq(
14952 &rust_nodes,
14953 &cte_nodes,
14954 "Rust frontier versus recursive CTE topology",
14955 )?;
14956 require_eq(
14957 &repeated_nodes,
14958 &rust_nodes,
14959 "deterministic Rust frontier order",
14960 )?;
14961 require_eq(
14962 &repeated_edges,
14963 &inspected_edges,
14964 "deterministic inspected edges",
14965 )?;
14966 require_eq(
14967 &repeated_peak,
14968 &peak_frontier,
14969 "deterministic peak frontier",
14970 )?;
14971 require_eq(
14972 &rust_nodes.len(),
14973 &(HIGH_DEGREE + 2),
14974 "cycle-safe high-degree topology",
14975 )?;
14976 require_eq(
14977 &inspected_edges,
14978 &(HIGH_DEGREE * 2 + 1),
14979 "bounded frontier inspected every call edge once",
14980 )?;
14981 require(
14982 cte_steps.load(std::sync::atomic::Ordering::Relaxed) > 0
14983 && rust_steps.load(std::sync::atomic::Ordering::Relaxed) > 0
14984 && cte_elapsed > Duration::ZERO
14985 && rust_elapsed > Duration::ZERO,
14986 "frontier comparison did not record VM work and elapsed time",
14987 )?;
14988 let retained_key_bytes = rust_nodes
14989 .len()
14990 .checked_add(peak_frontier)
14991 .and_then(|keys| keys.checked_mul(std::mem::size_of::<[u8; 32]>()))
14992 .ok_or_else(|| io::Error::other("retained key measurement overflowed"))?;
14993 let output_key_bytes = rust_nodes
14994 .len()
14995 .checked_mul(std::mem::size_of::<[u8; 32]>())
14996 .ok_or_else(|| io::Error::other("output key measurement overflowed"))?;
14997 require(
14998 retained_key_bytes <= GraphLimits::MAX_OUTPUT_BYTES as usize
14999 && output_key_bytes <= GraphLimits::MAX_OUTPUT_BYTES as usize,
15000 "bounded Rust frontier exceeded the shared compact byte ceiling",
15001 )?;
15002
15003 store.connection.progress_handler(1, Some(|| true))?;
15004 let cancelled_cte = (|| -> Result<Vec<Vec<u8>>, rusqlite::Error> {
15005 let mut statement = store.connection.prepare(RECURSIVE_CTE)?;
15006 statement
15007 .query_map(params![&project[..], &source_key[..]], |row| {
15008 row.get::<_, Vec<u8>>(0)
15009 })?
15010 .collect()
15011 })();
15012 store.connection.progress_handler(0, None::<fn() -> bool>)?;
15013 require(
15014 matches!(
15015 cancelled_cte,
15016 Err(rusqlite::Error::SqliteFailure(ref failure, _))
15017 if failure.code == rusqlite::ErrorCode::OperationInterrupted
15018 ),
15019 "recursive CTE did not stop through the SQLite progress handler",
15020 )?;
15021 let cancellation = projectatlas_core::IndexCancellation::new();
15022 cancellation.cancel();
15023 let control = IndexWorkControl::new(cancellation, None);
15024 let cancelled_rust = store.repository_graph_adjacency_page_filtered(
15025 &[source.key().clone()],
15026 RepositoryGraphDirection::Outbound,
15027 Some(GraphRelationKind::Legacy(RelationKind::Calls)),
15028 None,
15029 1,
15030 Some(&control),
15031 );
15032 require(
15033 matches!(
15034 cancelled_rust,
15035 Err(DbError::IndexWork(
15036 projectatlas_core::IndexWorkFailure::Cancelled {
15037 stage: IndexWorkStage::RepositoryTraversal
15038 }
15039 ))
15040 ),
15041 "bounded Rust frontier did not retain typed cancellation",
15042 )?;
15043 store.finish_index_read_snapshot()?;
15044 Ok(())
15045 }
15046
15047 #[cfg(feature = "sqlite-progress-test-observer")]
15048 #[test]
15049 fn zero_candidate_coverage_discovery_is_cancellable_at_scale() -> Result<(), Box<dyn Error>> {
15050 use crate::sqlite_progress_test_observer::{
15051 SqliteReadProgressEvent, observe_sqlite_read_progress,
15052 };
15053 use std::cell::Cell;
15054 use std::rc::Rc;
15055
15056 const POSITIVE_DOCUMENT_ROWS: usize = 100_000;
15057
15058 let temp = tempfile::tempdir()?;
15059 let project_root = temp.path().join("coverage-zero-candidate-scale");
15060 let atlas_dir = project_root.join(".projectatlas");
15061 fs::create_dir_all(&atlas_dir)?;
15062 let db_path = atlas_dir.join("projectatlas.db");
15063 let mut writer = AtlasStore::open_for_project(&db_path, &project_root)?;
15064 let fixture = publish_fixture(&mut writer, "coverage-zero-candidate-scale")?;
15065 writer.connection.execute(
15066 "DELETE FROM graph_coverage
15067 WHERE relation_scope = 'extended'
15068 AND relation_kind = 'documents'
15069 AND state = 'complete' AND total = 0",
15070 [],
15071 )?;
15072 writer.connection.execute(
15073 "WITH RECURSIVE sequence(value) AS (
15074 VALUES(1)
15075 UNION ALL
15076 SELECT value + 1 FROM sequence WHERE value < ?2
15077 )
15078 INSERT INTO graph_coverage(
15079 project_instance_id, scope_kind, scope_path,
15080 relation_scope, relation_kind, state,
15081 total, covered, omitted, reason, reached_limit
15082 )
15083 SELECT ?1, 'path', printf('src/perf-%06d.rs', value),
15084 'extended', 'documents', 'complete',
15085 1, 1, 0, NULL, NULL
15086 FROM sequence",
15087 params![
15088 fixture.project.as_bytes().as_slice(),
15089 i64::try_from(POSITIVE_DOCUMENT_ROWS)?,
15090 ],
15091 )?;
15092 writer.connection.execute(
15093 "INSERT INTO graph_coverage(
15094 project_instance_id, scope_kind, scope_path,
15095 relation_scope, relation_kind, state,
15096 total, covered, omitted, reason, reached_limit
15097 ) VALUES (?1, 'path', 'docs/empty.md',
15098 'extended', 'documents', 'complete',
15099 0, 0, 0, NULL, NULL)",
15100 params![fixture.project.as_bytes().as_slice()],
15101 )?;
15102 drop(writer);
15103
15104 let store = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
15105 let cancellation = projectatlas_core::IndexCancellation::new();
15106 let control = IndexWorkControl::new(cancellation.clone(), None);
15107 let inspected_steps = Rc::new(Cell::new(0_usize));
15108 let page = observe_sqlite_read_progress(
15109 {
15110 let inspected_steps = Rc::clone(&inspected_steps);
15111 move |event| {
15112 if matches!(
15113 event,
15114 SqliteReadProgressEvent::CallbackEntered {
15115 stage: IndexWorkStage::RepositoryTraversal
15116 }
15117 ) {
15118 inspected_steps.set(inspected_steps.get().saturating_add(1_000));
15119 cancellation.cancel();
15120 }
15121 }
15122 },
15123 || {
15124 store.repository_coverage_page_controlled(
15125 fixture.project,
15126 &RepositoryCoverageQuery {
15127 start_index: 0,
15128 limit: 1,
15129 path_prefix: None,
15130 parser: None,
15131 provider: None,
15132 relation: None,
15133 state: Some(CoverageState::NoCandidates),
15134 reason: None,
15135 },
15136 Some(&control),
15137 )
15138 },
15139 );
15140 require(
15141 matches!(
15142 page,
15143 Err(DbError::IndexWork(
15144 projectatlas_core::IndexWorkFailure::Cancelled {
15145 stage: IndexWorkStage::RepositoryTraversal
15146 }
15147 ))
15148 ),
15149 "scaled zero-candidate discovery did not propagate cancellation",
15150 )?;
15151 require(
15152 inspected_steps.get() <= 1_000,
15153 "zero-candidate discovery exceeded one SQLite progress interval before cancellation",
15154 )?;
15155 store.finish_index_read_snapshot()?;
15156 Ok(())
15157 }
15158
15159 #[test]
15160 fn coverage_discovery_filters_provenance_and_fails_closed_after_reopen()
15161 -> Result<(), Box<dyn Error>> {
15162 let temp = tempfile::tempdir()?;
15163 let project_root = temp.path().join("coverage-discovery");
15164 fs::create_dir_all(&project_root)?;
15165 let db_path = project_root.join("projectatlas.db");
15166 let mut writer = AtlasStore::open_for_project(&db_path, &project_root)?;
15167 let fixture = publish_fixture(&mut writer, "coverage-discovery")?;
15168 let sibling_path = "src/Äuth.rs.backup";
15169 let sibling_coverage = CoverageRecord::new(
15170 CoverageScope::Path {
15171 path: RepositoryNodePath::new(Path::new(sibling_path))?,
15172 },
15173 None,
15174 CoverageState::Complete,
15175 1,
15176 0,
15177 IndexGeneration::new(2),
15178 None,
15179 None,
15180 )?;
15181 let mut publication = writer.begin_index_publication("coverage-discovery-sibling")?;
15182 publication.replace_repository_graph_for_paths(
15183 fixture.project,
15184 &[sibling_path.to_string()],
15185 &[],
15186 &[],
15187 &[],
15188 &[sibling_coverage],
15189 )?;
15190 publication.complete()?;
15191 drop(writer);
15192
15193 let store = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
15194 let project_value = Value::Blob(fixture.project.as_bytes().to_vec());
15195 assert_coverage_discovery_plan(
15196 &store.connection,
15197 "EXPLAIN QUERY PLAN
15198 SELECT coverage.id FROM graph_coverage AS coverage
15199 WHERE coverage.project_instance_id = ?
15200 AND coverage.scope_kind = 'path'
15201 AND coverage.scope_path >= ? AND coverage.scope_path < ?
15202 AND (coverage.scope_path = ? OR coverage.scope_path >= ?)
15203 ORDER BY coverage.scope_path, coverage.relation_scope, coverage.relation_kind,
15204 coverage.state, coverage.id LIMIT 11",
15205 &[
15206 project_value.clone(),
15207 Value::Text("src/Äuth.rs".to_string()),
15208 Value::Text("src/Äuth.rs0".to_string()),
15209 Value::Text("src/Äuth.rs".to_string()),
15210 Value::Text("src/Äuth.rs/".to_string()),
15211 ],
15212 &["idx_graph_coverage_scope_order"],
15213 false,
15214 "coverage path filter",
15215 )?;
15216 assert_coverage_discovery_plan(
15217 &store.connection,
15218 "EXPLAIN QUERY PLAN
15219 SELECT coverage.id FROM graph_coverage AS coverage
15220 WHERE coverage.project_instance_id = ? AND coverage.state = ?
15221 ORDER BY coverage.state, coverage.scope_path, coverage.id LIMIT 11",
15222 &[project_value.clone(), Value::Text("failed".to_string())],
15223 &["idx_graph_coverage_discovery_state"],
15224 false,
15225 "coverage state filter",
15226 )?;
15227 assert_coverage_discovery_plan(
15228 &store.connection,
15229 "EXPLAIN QUERY PLAN
15230 SELECT coverage.id FROM graph_coverage AS coverage
15231 WHERE coverage.project_instance_id = ?
15232 AND coverage.state = 'complete' AND coverage.total = 0
15233 AND coverage.relation_scope = 'extended'
15234 AND coverage.relation_kind = 'documents'
15235 ORDER BY coverage.relation_scope, coverage.relation_kind,
15236 coverage.state, coverage.id LIMIT 11",
15237 std::slice::from_ref(&project_value),
15238 &["idx_graph_coverage_relation_state"],
15239 false,
15240 "zero-candidate coverage filter",
15241 )?;
15242 assert_coverage_discovery_plan(
15243 &store.connection,
15244 "EXPLAIN QUERY PLAN
15245 SELECT coverage.id FROM graph_coverage AS coverage
15246 WHERE coverage.project_instance_id = ?
15247 AND coverage.state = 'complete'
15248 AND NOT (coverage.total = 0
15249 AND coverage.relation_scope IS 'extended'
15250 AND coverage.relation_kind IS 'documents')
15251 ORDER BY coverage.state, coverage.scope_path, coverage.id LIMIT 11",
15252 std::slice::from_ref(&project_value),
15253 &["idx_graph_coverage_discovery_state"],
15254 false,
15255 "positive-complete coverage filter",
15256 )?;
15257 assert_coverage_discovery_plan(
15258 &store.connection,
15259 "EXPLAIN QUERY PLAN
15260 SELECT coverage.id FROM graph_coverage AS coverage
15261 WHERE coverage.project_instance_id = ? AND coverage.reason = ?
15262 ORDER BY coverage.reason, coverage.scope_path, coverage.id LIMIT 11",
15263 &[
15264 project_value.clone(),
15265 Value::Text("parser failed".to_string()),
15266 ],
15267 &["idx_graph_coverage_discovery_reason"],
15268 false,
15269 "coverage reason filter",
15270 )?;
15271 for (column, index, context) in [
15272 (
15273 "source_parser",
15274 "idx_source_parse_metadata_source_parser_path",
15275 "coverage parser filter",
15276 ),
15277 (
15278 "fact_parser",
15279 "idx_source_parse_metadata_fact_parser_path",
15280 "coverage provider filter",
15281 ),
15282 ] {
15283 assert_coverage_discovery_plan(
15284 &store.connection,
15285 &format!(
15286 "EXPLAIN QUERY PLAN
15287 SELECT coverage.id
15288 FROM source_parse_metadata AS metadata
15289 CROSS JOIN graph_coverage AS coverage
15290 ON coverage.scope_kind = 'path'
15291 AND coverage.scope_path = metadata.path
15292 WHERE coverage.project_instance_id = ?
15293 AND metadata.{column} = ?
15294 ORDER BY metadata.path, coverage.id LIMIT 11"
15295 ),
15296 &[
15297 project_value.clone(),
15298 Value::Text("tree-sitter".to_string()),
15299 ],
15300 &[index, "idx_graph_coverage_identity"],
15301 true,
15302 context,
15303 )?;
15304 }
15305 let all = store.repository_coverage_page(
15306 fixture.project,
15307 &RepositoryCoverageQuery {
15308 start_index: 0,
15309 limit: 2,
15310 path_prefix: None,
15311 parser: None,
15312 provider: None,
15313 relation: None,
15314 state: None,
15315 reason: None,
15316 },
15317 )?;
15318 require(
15319 all.truncated && all.rows.len() == 2,
15320 "coverage discovery did not use LIMIT + 1",
15321 )?;
15322 let all_states = store.repository_coverage_page(
15323 fixture.project,
15324 &RepositoryCoverageQuery {
15325 start_index: 0,
15326 limit: 20,
15327 path_prefix: None,
15328 parser: None,
15329 provider: None,
15330 relation: None,
15331 state: None,
15332 reason: None,
15333 },
15334 )?;
15335 for state in [
15336 CoverageState::Complete,
15337 CoverageState::NoCandidates,
15338 CoverageState::Partial,
15339 CoverageState::Failed,
15340 CoverageState::Ignored,
15341 CoverageState::Oversized,
15342 CoverageState::Quarantined,
15343 CoverageState::Stale,
15344 ] {
15345 require(
15346 all_states
15347 .rows
15348 .iter()
15349 .any(|row| row.coverage.state() == state),
15350 &format!("coverage discovery omitted {state:?}"),
15351 )?;
15352 }
15353
15354 let complete = store.repository_coverage_page(
15355 fixture.project,
15356 &RepositoryCoverageQuery {
15357 start_index: 0,
15358 limit: 10,
15359 path_prefix: None,
15360 parser: None,
15361 provider: None,
15362 relation: None,
15363 state: Some(CoverageState::Complete),
15364 reason: None,
15365 },
15366 )?;
15367 let complete_contract = !complete.rows.is_empty()
15368 && complete
15369 .rows
15370 .iter()
15371 .all(|row| row.coverage.state() == CoverageState::Complete)
15372 && complete.rows.iter().any(|row| {
15373 row.coverage.total() == 0
15374 && row.coverage.relation()
15375 == Some(GraphRelationKind::Extended(ExtendedRelationKind::Tests))
15376 });
15377 require(
15378 complete_contract,
15379 &format!(
15380 "complete coverage filter changed a non-document zero row or admitted no-candidates: {:?}",
15381 complete.rows
15382 ),
15383 )?;
15384 let no_candidates = store.repository_coverage_page(
15385 fixture.project,
15386 &RepositoryCoverageQuery {
15387 start_index: 0,
15388 limit: 10,
15389 path_prefix: None,
15390 parser: None,
15391 provider: None,
15392 relation: None,
15393 state: Some(CoverageState::NoCandidates),
15394 reason: None,
15395 },
15396 )?;
15397 require(
15398 !no_candidates.rows.is_empty()
15399 && no_candidates.rows.iter().all(|row| {
15400 row.coverage.state() == CoverageState::NoCandidates && row.coverage.total() == 0
15401 }),
15402 "zero-candidate coverage filter admitted a positive-complete row",
15403 )?;
15404
15405 let exact_path = store.repository_coverage_page(
15406 fixture.project,
15407 &RepositoryCoverageQuery {
15408 start_index: 0,
15409 limit: 1,
15410 path_prefix: Some("src/Äuth.rs".to_string()),
15411 parser: None,
15412 provider: None,
15413 relation: None,
15414 state: None,
15415 reason: None,
15416 },
15417 )?;
15418 require(
15419 !exact_path.truncated && exact_path.rows.len() == 1,
15420 "exact coverage path admitted a lexical sibling",
15421 )?;
15422 require_eq(
15423 exact_path.rows[0].coverage.scope(),
15424 &CoverageScope::Path {
15425 path: RepositoryNodePath::new(Path::new("src/Äuth.rs"))?,
15426 },
15427 "exact coverage path scope",
15428 )?;
15429
15430 let parsed = store.repository_coverage_page(
15431 fixture.project,
15432 &RepositoryCoverageQuery {
15433 start_index: 0,
15434 limit: 10,
15435 path_prefix: Some("src/Äuth.rs".to_string()),
15436 parser: Some(ParserKind::TreeSitter),
15437 provider: Some(ParserKind::TreeSitter),
15438 relation: None,
15439 state: Some(CoverageState::Complete),
15440 reason: None,
15441 },
15442 )?;
15443 require_eq(&parsed.rows.len(), &1, "parser/provider filtered coverage")?;
15444 require_eq(
15445 &parsed.rows[0].parser,
15446 &Some(ParserKind::TreeSitter),
15447 "source parser provenance",
15448 )?;
15449 require_eq(
15450 &parsed.rows[0].provider,
15451 &Some(ParserKind::TreeSitter),
15452 "fact provider provenance",
15453 )?;
15454
15455 let failed_calls = store.repository_coverage_page(
15456 fixture.project,
15457 &RepositoryCoverageQuery {
15458 start_index: 0,
15459 limit: 10,
15460 path_prefix: None,
15461 parser: None,
15462 provider: None,
15463 relation: Some(GraphRelationKind::Legacy(RelationKind::Calls)),
15464 state: Some(CoverageState::Failed),
15465 reason: Some("parser failed".to_string()),
15466 },
15467 )?;
15468 require_eq(&failed_calls.rows.len(), &1, "combined coverage filters")?;
15469 require_eq(
15470 &failed_calls.rows[0].coverage.state(),
15471 &CoverageState::Failed,
15472 "failed coverage state",
15473 )?;
15474
15475 let absent = store.repository_coverage_page(
15476 fixture.project,
15477 &RepositoryCoverageQuery {
15478 start_index: 0,
15479 limit: 10,
15480 path_prefix: None,
15481 parser: None,
15482 provider: Some(ParserKind::Manifest),
15483 relation: None,
15484 state: None,
15485 reason: None,
15486 },
15487 )?;
15488 require(
15489 absent.rows.is_empty(),
15490 "provider filter returned a false match",
15491 )?;
15492 store.finish_index_read_snapshot()?;
15493 drop(store);
15494
15495 let writer = AtlasStore::open_for_project(&db_path, &project_root)?;
15496 writer.connection.execute(
15497 "UPDATE source_parse_metadata SET source_parser = 'corrupt-parser'
15498 WHERE path = 'src/Äuth.rs'",
15499 [],
15500 )?;
15501 drop(writer);
15502 let store = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
15503 let error = require_db_error(
15504 store.repository_coverage_page(
15505 fixture.project,
15506 &RepositoryCoverageQuery {
15507 start_index: 0,
15508 limit: 10,
15509 path_prefix: Some("src/Äuth.rs".to_string()),
15510 parser: None,
15511 provider: None,
15512 relation: None,
15513 state: None,
15514 reason: None,
15515 },
15516 ),
15517 "corrupt parser provenance was accepted",
15518 )?;
15519 require(
15520 matches!(error, DbError::InvalidEnum { .. }),
15521 &format!("unexpected parser corruption error: {error}"),
15522 )?;
15523 Ok(())
15524 }
15525
15526 #[test]
15527 fn navigation_connections_cover_families_prefixes_truncation_and_reopen()
15528 -> Result<(), Box<dyn Error>> {
15529 let temp = tempfile::tempdir()?;
15530 let root = temp.path().join("navigation-connections");
15531 fs::create_dir_all(&root)?;
15532 let db_path = root.join("projectatlas.db");
15533 let mut store = AtlasStore::open_for_project(&db_path, &root)?;
15534 let fixture = publish_navigation_fixture(&mut store, "navigation-connections")?;
15535 let owners = vec![
15536 RepositoryNavigationNode {
15537 path: fixture.api_path.clone(),
15538 kind: NodeKind::File,
15539 },
15540 RepositoryNavigationNode {
15541 path: "src/auth".to_string(),
15542 kind: NodeKind::Folder,
15543 },
15544 RepositoryNavigationNode {
15545 path: fixture.manifest_path.clone(),
15546 kind: NodeKind::File,
15547 },
15548 RepositoryNavigationNode {
15549 path: ".".to_string(),
15550 kind: NodeKind::Folder,
15551 },
15552 ];
15553 let pages = store.repository_navigation_connections(&owners, 2, 20)?;
15554 require_eq(&pages.len(), &owners.len(), "navigation owner count")?;
15555 let api = &pages[0];
15556 let families = api
15557 .counts
15558 .iter()
15559 .map(|count| count.kind)
15560 .collect::<Vec<_>>();
15561 require_eq(
15562 &families,
15563 &NAVIGATION_CONNECTION_FAMILIES
15564 .iter()
15565 .map(|&(kind, _, _)| kind)
15566 .collect::<Vec<_>>(),
15567 "all navigation families",
15568 )?;
15569 let calls = api
15570 .counts
15571 .iter()
15572 .find(|count| count.kind == RankedConnectionKind::Call)
15573 .ok_or_else(|| io::Error::other("call navigation count is missing"))?;
15574 require_eq(&calls.count, &2, "bounded high-degree call count")?;
15575 require_eq(&calls.truncated, &true, "high-degree call truncation")?;
15576 require_eq(&api.truncated, &true, "file aggregate truncation")?;
15577 require(
15578 api.connections.iter().any(|connection| {
15579 connection.kind == RankedConnectionKind::Test
15580 && connection.direction == RankedConnectionDirection::Inbound
15581 }),
15582 "inbound test connection was not projected",
15583 )?;
15584
15585 let folder = &pages[1];
15586 let folder_imports = folder
15587 .counts
15588 .iter()
15589 .find(|count| count.kind == RankedConnectionKind::Import)
15590 .ok_or_else(|| io::Error::other("folder import count is missing"))?;
15591 require_eq(
15592 &folder_imports.count,
15593 &1,
15594 "folder prefix import count excluding sibling authz.rs",
15595 )?;
15596 let manifest = &pages[2];
15597 require(
15598 manifest
15599 .counts
15600 .iter()
15601 .any(|count| count.kind == RankedConnectionKind::Package && count.count == 1),
15602 "manifest-owned package context is missing",
15603 )?;
15604 require(
15605 pages[3]
15606 .counts
15607 .iter()
15608 .any(|count| count.kind == RankedConnectionKind::Call),
15609 "root aggregate omitted bounded call context",
15610 )?;
15611
15612 let globally_sampled = store.repository_navigation_connections(&owners[..1], 10, 3)?;
15613 require_eq(
15614 &globally_sampled[0].connections.len(),
15615 &3,
15616 "global connection sample limit",
15617 )?;
15618 require_eq(
15619 &globally_sampled[0].counts.len(),
15620 &NAVIGATION_CONNECTION_FAMILIES.len(),
15621 "global sample retained all family counts",
15622 )?;
15623 require(
15624 globally_sampled[0]
15625 .counts
15626 .iter()
15627 .all(|count| !count.truncated),
15628 "global sample incorrectly reported family overflow",
15629 )?;
15630 require_eq(
15631 &globally_sampled[0].truncated,
15632 &true,
15633 "global sample truncation",
15634 )?;
15635
15636 drop(store);
15637 let reader = AtlasStore::open_read_only_for_project(&db_path, &root)?;
15638 require_eq(
15639 &reader.project_instance_id()?,
15640 &Some(fixture.project),
15641 "reopened navigation identity",
15642 )?;
15643 let reopened = reader.repository_navigation_connections(&owners[..1], 2, 20)?;
15644 require_eq(
15645 &reopened[0].counts,
15646 &api.counts,
15647 "reopened navigation counts",
15648 )?;
15649 Ok(())
15650 }
15651
15652 #[test]
15653 fn navigation_connections_use_owned_indexes_and_fail_all_or_error_on_corruption()
15654 -> Result<(), Box<dyn Error>> {
15655 let temp = tempfile::tempdir()?;
15656 let root = temp.path().join("navigation-plan-corruption");
15657 fs::create_dir_all(&root)?;
15658 let db_path = root.join("projectatlas.db");
15659 let mut store = AtlasStore::open_for_project(&db_path, &root)?;
15660 let fixture = publish_navigation_fixture(&mut store, "navigation-plan-corruption")?;
15661 let owner = RepositoryNavigationNode {
15662 path: fixture.api_path,
15663 kind: NodeKind::File,
15664 };
15665 for (direction, expected_index) in [
15666 (
15667 RankedConnectionDirection::Outbound,
15668 "idx_graph_relations_source_kind",
15669 ),
15670 (
15671 RankedConnectionDirection::Inbound,
15672 "idx_graph_relations_target_kind",
15673 ),
15674 ] {
15675 let mut values = Vec::new();
15676 let sql = navigation_connection_branch(
15677 0,
15678 &owner,
15679 RankedConnectionKind::Call,
15680 "legacy",
15681 "calls",
15682 direction,
15683 3,
15684 &mut values,
15685 );
15686 let mut statement = store
15687 .connection
15688 .prepare(&format!("EXPLAIN QUERY PLAN {sql}"))?;
15689 let details = statement
15690 .query_map(params_from_iter(values.iter()), |row| {
15691 row.get::<_, String>(3)
15692 })?
15693 .collect::<Result<Vec<_>, _>>()?;
15694 for expected in [
15695 expected_index,
15696 "idx_graph_entities_path",
15697 "idx_graph_entities_manifest_path",
15698 ] {
15699 require(
15700 details.iter().any(|detail| detail.contains(expected)),
15701 &format!("navigation plan missed {expected}: {details:?}"),
15702 )?;
15703 }
15704 require(
15705 details.iter().all(|detail| !detail.contains("SCAN graph_")),
15706 &format!("navigation plan scanned graph storage: {details:?}"),
15707 )?;
15708 }
15709 let folder_owner = RepositoryNavigationNode {
15710 path: "src/auth".to_string(),
15711 kind: NodeKind::Folder,
15712 };
15713 for (direction, expected_index) in [
15714 (
15715 RankedConnectionDirection::Outbound,
15716 "idx_graph_relations_source_kind",
15717 ),
15718 (
15719 RankedConnectionDirection::Inbound,
15720 "idx_graph_relations_target_kind",
15721 ),
15722 ] {
15723 let mut values = Vec::new();
15724 let sql = navigation_connection_branch(
15725 0,
15726 &folder_owner,
15727 RankedConnectionKind::Call,
15728 "legacy",
15729 "calls",
15730 direction,
15731 3,
15732 &mut values,
15733 );
15734 let details = store
15735 .connection
15736 .prepare(&format!("EXPLAIN QUERY PLAN {sql}"))?
15737 .query_map(params_from_iter(values.iter()), |row| {
15738 row.get::<_, String>(3)
15739 })?
15740 .collect::<Result<Vec<_>, _>>()?;
15741 for expected in [
15742 expected_index,
15743 "idx_graph_entities_path",
15744 "idx_graph_entities_manifest_path",
15745 ] {
15746 require(
15747 details.iter().any(|detail| detail.contains(expected)),
15748 &format!("folder navigation plan missed {expected}: {details:?}"),
15749 )?;
15750 }
15751 require(
15752 details.iter().all(|detail| !detail.contains("SCAN graph_")),
15753 &format!("folder navigation plan scanned graph storage: {details:?}"),
15754 )?;
15755 }
15756 let root_owner = RepositoryNavigationNode {
15757 path: ".".to_string(),
15758 kind: NodeKind::Folder,
15759 };
15760 let mut root_values = Vec::new();
15761 let root_sql = navigation_connection_branch(
15762 0,
15763 &root_owner,
15764 RankedConnectionKind::Call,
15765 "legacy",
15766 "calls",
15767 RankedConnectionDirection::Outbound,
15768 2,
15769 &mut root_values,
15770 );
15771 let root_details = store
15772 .connection
15773 .prepare(&format!("EXPLAIN QUERY PLAN {root_sql}"))?
15774 .query_map(params_from_iter(root_values.iter()), |row| {
15775 row.get::<_, String>(3)
15776 })?
15777 .collect::<Result<Vec<_>, _>>()?;
15778 require(
15779 root_details
15780 .iter()
15781 .any(|detail| detail.contains("idx_graph_relations_kind_order")),
15782 &format!("root navigation plan missed family index: {root_details:?}"),
15783 )?;
15784 require(
15785 root_details
15786 .iter()
15787 .all(|detail| !detail.contains("SCAN graph_")),
15788 &format!("root navigation plan scanned graph storage: {root_details:?}"),
15789 )?;
15790
15791 store
15792 .connection
15793 .execute_batch("PRAGMA ignore_check_constraints = ON")?;
15794 store.connection.execute(
15795 "UPDATE graph_relations
15796 SET resolution_status = 'unresolved', reference_text = 'broken-route'
15797 WHERE relation_scope = 'extended' AND relation_kind = 'routes-to'",
15798 [],
15799 )?;
15800 let error = require_db_error(
15801 store.repository_navigation_connections(&[owner], 4, 20),
15802 "corrupt navigation relation returned a partial page",
15803 )?;
15804 require(
15805 matches!(error, DbError::GraphRowShape { .. }),
15806 &format!("corrupt navigation relation returned {error}"),
15807 )?;
15808 Ok(())
15809 }
15810
15811 #[test]
15812 fn graph_queries_fail_closed_on_corrupt_normalized_rows() -> Result<(), Box<dyn Error>> {
15813 let temp = tempfile::tempdir()?;
15814 let project_root = temp.path().join("graph-corruption");
15815 let atlas_dir = project_root.join(".projectatlas");
15816 fs::create_dir_all(&atlas_dir)?;
15817 let db_path = atlas_dir.join("projectatlas.db");
15818 let mut store = AtlasStore::open_for_project(&db_path, &project_root)?;
15819 let fixture = publish_fixture(&mut store, "graph-corruption")?;
15820 drop(store);
15821 let store = AtlasStore::open_for_project(&db_path, &project_root)?;
15822 let source = fixture
15823 .entities
15824 .iter()
15825 .find(|entity| matches!(entity.selector(), EntitySelector::File { .. }))
15826 .ok_or_else(|| io::Error::other("source file fixture missing"))?;
15827 let folder = fixture
15828 .entities
15829 .iter()
15830 .find(|entity| matches!(entity.selector(), EntitySelector::Folder { .. }))
15831 .ok_or_else(|| io::Error::other("folder fixture missing"))?;
15832 let symbol = fixture
15833 .entities
15834 .iter()
15835 .find(|entity| matches!(entity.selector(), EntitySelector::Symbol { .. }))
15836 .ok_or_else(|| io::Error::other("symbol fixture missing"))?;
15837 let ambiguous = fixture
15838 .relations
15839 .iter()
15840 .find(|relation| matches!(relation.resolution(), RelationResolution::Ambiguous { .. }))
15841 .ok_or_else(|| io::Error::other("ambiguous relation fixture missing"))?;
15842 let source_digest = source.key().digest_bytes()?;
15843 let folder_digest = folder.key().digest_bytes()?;
15844 let symbol_digest = symbol.key().digest_bytes()?;
15845 let ambiguous_digest = ambiguous.key().digest_bytes()?;
15846 let source_canonical = store.connection.query_row(
15847 "SELECT canonical_identity FROM graph_entities WHERE entity_key = ?1",
15848 [&source_digest[..]],
15849 |row| row.get::<_, String>(0),
15850 )?;
15851 let symbol_canonical = store.connection.query_row(
15852 "SELECT canonical_identity FROM graph_entities WHERE entity_key = ?1",
15853 [&symbol_digest[..]],
15854 |row| row.get::<_, String>(0),
15855 )?;
15856 store
15857 .connection
15858 .execute_batch("PRAGMA ignore_check_constraints = ON")?;
15859
15860 store.connection.execute(
15861 "UPDATE graph_entities SET entity_kind = 'corrupt' WHERE entity_key = ?1",
15862 [&source_digest[..]],
15863 )?;
15864 {
15865 let reader = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
15866 let error = require_db_error(
15867 reader.repository_graph_entity(source.key()),
15868 "malformed graph enum was accepted",
15869 )?;
15870 require(
15871 matches!(error, DbError::InvalidEnum { .. }),
15872 &format!("unexpected malformed-enum error: {error}"),
15873 )?;
15874 reader.finish_index_read_snapshot()?;
15875 }
15876 store.connection.execute(
15877 "UPDATE graph_entities SET entity_kind = 'file' WHERE entity_key = ?1",
15878 [&source_digest[..]],
15879 )?;
15880
15881 store.connection.execute(
15882 "UPDATE graph_relations SET candidate_count = 0 WHERE relation_key = ?1",
15883 [&ambiguous_digest[..]],
15884 )?;
15885 {
15886 let reader = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
15887 let error = require_db_error(
15888 reader.repository_graph_relations(
15889 RepositoryGraphRelationQuery::Outbound {
15890 source: source.key().clone(),
15891 },
15892 10,
15893 ),
15894 "zero ambiguity count was accepted",
15895 )?;
15896 require(
15897 matches!(error, DbError::GraphRowShape { .. }),
15898 &format!("unexpected candidate-count error: {error}"),
15899 )?;
15900 let adjacency_error = require_db_error(
15901 reader.repository_graph_adjacency_page(
15902 &[source.key().clone()],
15903 RepositoryGraphDirection::Outbound,
15904 None,
15905 10,
15906 None,
15907 ),
15908 "corrupt adjacency relation returned a partial page",
15909 )?;
15910 require(
15911 matches!(adjacency_error, DbError::GraphRowShape { .. }),
15912 &format!("unexpected adjacency row-shape error: {adjacency_error}"),
15913 )?;
15914 reader.finish_index_read_snapshot()?;
15915 }
15916 store.connection.execute(
15917 "UPDATE graph_relations SET candidate_count = 2 WHERE relation_key = ?1",
15918 [&ambiguous_digest[..]],
15919 )?;
15920
15921 store.connection.execute(
15922 "UPDATE graph_relations SET resolution_status = 'resolved'
15923 WHERE relation_key = ?1",
15924 [&ambiguous_digest[..]],
15925 )?;
15926 {
15927 let reader = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
15928 let error = require_db_error(
15929 reader.repository_graph_relations(
15930 RepositoryGraphRelationQuery::Outbound {
15931 source: source.key().clone(),
15932 },
15933 10,
15934 ),
15935 "contradictory resolution columns were accepted",
15936 )?;
15937 require(
15938 matches!(error, DbError::GraphRowShape { .. }),
15939 &format!("unexpected resolution-shape error: {error}"),
15940 )?;
15941 reader.finish_index_read_snapshot()?;
15942 }
15943 store.connection.execute(
15944 "UPDATE graph_relations SET resolution_status = 'ambiguous'
15945 WHERE relation_key = ?1",
15946 [&ambiguous_digest[..]],
15947 )?;
15948
15949 store.connection.execute(
15950 "UPDATE graph_coverage SET total = 999
15951 WHERE scope_kind = 'project' AND relation_scope IS NULL",
15952 [],
15953 )?;
15954 {
15955 let reader = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
15956 let error = require_db_error(
15957 reader.repository_graph_coverage(fixture.project, &CoverageScope::Project, 10),
15958 "contradictory coverage total was accepted",
15959 )?;
15960 require(
15961 matches!(error, DbError::GraphRowShape { .. }),
15962 &format!("unexpected coverage-total error: {error}"),
15963 )?;
15964 reader.finish_index_read_snapshot()?;
15965 }
15966 store.connection.execute(
15967 "UPDATE graph_coverage SET total = covered + omitted
15968 WHERE scope_kind = 'project' AND relation_scope IS NULL",
15969 [],
15970 )?;
15971
15972 store.connection.execute(
15973 "UPDATE project_identity SET active_generation = 99 WHERE singleton = 1",
15974 [],
15975 )?;
15976 {
15977 let reader = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
15978 let error = require_db_error(
15979 reader.repository_graph_entity(source.key()),
15980 "mismatched typed graph generation was accepted",
15981 )?;
15982 require(
15983 matches!(error, DbError::GraphRowShape { .. }),
15984 &format!("unexpected typed-generation error: {error}"),
15985 )?;
15986 reader.finish_index_read_snapshot()?;
15987 }
15988 store.connection.execute(
15989 "UPDATE project_identity SET active_generation = 1 WHERE singleton = 1",
15990 [],
15991 )?;
15992
15993 store.connection.execute(
15994 "UPDATE graph_entities SET canonical_identity = 'different-collision-witness'
15995 WHERE entity_key = ?1",
15996 [&source_digest[..]],
15997 )?;
15998 {
15999 let reader = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
16000 let error = require_db_error(
16001 reader.repository_graph_entity(source.key()),
16002 "canonical collision witness was accepted",
16003 )?;
16004 require(
16005 matches!(error, DbError::GraphContract(_)),
16006 &format!("unexpected collision-witness error: {error}"),
16007 )?;
16008 reader.finish_index_read_snapshot()?;
16009 }
16010 store.connection.execute(
16011 "UPDATE graph_entities SET canonical_identity = ?1 WHERE entity_key = ?2",
16012 params![source_canonical, &source_digest[..]],
16013 )?;
16014
16015 store.connection.execute(
16016 "UPDATE graph_entities SET entity_key = zeroblob(32) WHERE entity_key = ?1",
16017 [&folder_digest[..]],
16018 )?;
16019 let folder_path = RepositoryNodePath::new(Path::new("src"))?;
16020 {
16021 let reader = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
16022 let error = require_db_error(
16023 reader.repository_graph_entities_by_path(fixture.project, &folder_path, 10),
16024 "invalid stable digest was accepted",
16025 )?;
16026 require(
16027 matches!(error, DbError::GraphContract(_)),
16028 &format!("unexpected stable-digest error: {error}"),
16029 )?;
16030 reader.finish_index_read_snapshot()?;
16031 }
16032 store.connection.execute(
16033 "UPDATE graph_entities SET entity_key = ?1 WHERE entity_key = zeroblob(32)",
16034 [&folder_digest[..]],
16035 )?;
16036
16037 store.connection.execute(
16038 "UPDATE graph_entities SET entity_key = X'01' WHERE entity_key = ?1",
16039 [&folder_digest[..]],
16040 )?;
16041 {
16042 let reader = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
16043 let error = require_db_error(
16044 reader.repository_graph_entities_by_path(fixture.project, &folder_path, 10),
16045 "short graph key blob was accepted",
16046 )?;
16047 require(
16048 matches!(
16049 error,
16050 DbError::InvalidBlobLength {
16051 field: "graph_entities.entity_key",
16052 expected: 32,
16053 found: 1
16054 }
16055 ),
16056 &format!("unexpected graph-key length error: {error}"),
16057 )?;
16058 reader.finish_index_read_snapshot()?;
16059 }
16060 store.connection.execute(
16061 "UPDATE graph_entities SET entity_key = ?1 WHERE entity_key = X'01'",
16062 [&folder_digest[..]],
16063 )?;
16064
16065 store.connection.execute(
16066 "UPDATE graph_entities SET canonical_identity = X'00' WHERE entity_key = ?1",
16067 [&symbol_digest[..]],
16068 )?;
16069 let source_path = RepositoryNodePath::new(Path::new("src/Äuth.rs"))?;
16070 {
16071 let reader = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
16072 let error = require_db_error(
16073 reader.repository_graph_entities_by_path(fixture.project, &source_path, 10),
16074 "later row conversion failure returned a successful partial page",
16075 )?;
16076 require(
16077 matches!(error, DbError::Sqlite(_)),
16078 &format!("unexpected later-row conversion error: {error}"),
16079 )?;
16080 reader.finish_index_read_snapshot()?;
16081 }
16082 store.connection.execute(
16083 "UPDATE graph_entities SET canonical_identity = ?1 WHERE entity_key = ?2",
16084 params![symbol_canonical, &symbol_digest[..]],
16085 )?;
16086 store
16087 .connection
16088 .execute_batch("PRAGMA ignore_check_constraints = OFF")?;
16089 Ok(())
16090 }
16091
16092 #[test]
16093 fn identity_rejections_are_generation_atomic_indexed_and_reopenable()
16094 -> Result<(), Box<dyn Error>> {
16095 let temp = tempfile::tempdir()?;
16096 let project_root = temp.path().join("identity-rejections");
16097 let atlas_dir = project_root.join(".projectatlas");
16098 fs::create_dir_all(&atlas_dir)?;
16099 let db_path = atlas_dir.join("projectatlas.db");
16100 let mut writer = AtlasStore::open_for_project(&db_path, &project_root)?;
16101 let fixture = publish_fixture(&mut writer, "identity-rejections")?;
16102 let graph_v2 = graph_fixture(fixture.project, IndexGeneration::new(2))?;
16103 let graph_v3 = graph_fixture(fixture.project, IndexGeneration::new(3))?;
16104 let graph_v4 = graph_fixture(fixture.project, IndexGeneration::new(4))?;
16105 let source_path = RepositoryNodePath::new(Path::new("src/Äuth.rs"))?;
16106 let first = GraphIdentityRejection {
16107 path: source_path.clone(),
16108 span: SourceSpan::new(2, 0, 2, 3)?,
16109 parser: ParserKind::TreeSitter,
16110 field: GraphIdentityField::Symbol,
16111 reason: GraphIdentityRejectionReason::Empty,
16112 fact_index: 0,
16113 };
16114 let first_distinct = GraphIdentityRejection {
16115 fact_index: 1,
16116 ..first.clone()
16117 };
16118 let mut first_rows = vec![first, first_distinct];
16119 first_rows.push(first_rows[0].clone());
16120 let expected_first = first_rows[..2].to_vec();
16121 {
16122 let mut publication = writer.begin_index_publication("identity-rejections")?;
16123 publication.replace_repository_graph(
16124 fixture.project,
16125 &graph_v2.entities,
16126 &graph_v2.relations,
16127 &graph_v2.occurrences,
16128 &graph_v2.coverage,
16129 )?;
16130 publication.replace_graph_identity_rejections(fixture.project, &first_rows)?;
16131 publication.complete()?;
16132 }
16133 let first_rows = writer.repository_graph_identity_rejections(
16134 fixture.project,
16135 std::slice::from_ref(&source_path),
16136 10,
16137 None,
16138 )?;
16139 require_eq(
16140 &first_rows,
16141 &expected_first,
16142 "first typed rejection publication",
16143 )?;
16144
16145 let query_plan = writer
16146 .connection
16147 .prepare(
16148 "EXPLAIN QUERY PLAN
16149 SELECT file_path
16150 FROM graph_identity_rejections
16151 WHERE project_instance_id = ?1
16152 AND generation = ?2
16153 AND file_path IN (?3)",
16154 )?
16155 .query_map(
16156 params![&fixture.project.as_bytes()[..], 2_i64, source_path.as_str()],
16157 |row| row.get::<_, String>(3),
16158 )?
16159 .collect::<Result<Vec<_>, _>>()?
16160 .join(" ");
16161 require(
16162 query_plan.contains("idx_graph_identity_rejections_generation_path"),
16163 "typed rejection lookup did not use its generation/path index",
16164 )?;
16165
16166 let reopened = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
16167 let reopened_rows = reopened.repository_graph_identity_rejections(
16168 fixture.project,
16169 std::slice::from_ref(&source_path),
16170 10,
16171 None,
16172 )?;
16173 require_eq(
16174 &reopened_rows,
16175 &expected_first,
16176 "typed rejection survives SQLite reopen",
16177 )?;
16178 reopened.finish_index_read_snapshot()?;
16179
16180 let second = GraphIdentityRejection {
16181 path: source_path.clone(),
16182 span: SourceSpan::new(4, 0, 4, 4)?,
16183 parser: ParserKind::TreeSitter,
16184 field: GraphIdentityField::Signature,
16185 reason: GraphIdentityRejectionReason::SurroundingWhitespace,
16186 fact_index: 0,
16187 };
16188 let expected_second = vec![second.clone()];
16189 {
16190 let mut publication = writer.begin_index_publication("identity-rejections")?;
16191 publication.replace_repository_graph_for_paths(
16192 fixture.project,
16193 &[source_path.as_str().to_string()],
16194 &graph_v3.entities,
16195 &graph_v3.relations,
16196 &graph_v3.occurrences,
16197 &graph_v3.coverage,
16198 )?;
16199 publication.replace_graph_identity_rejections(
16200 fixture.project,
16201 std::slice::from_ref(&second),
16202 )?;
16203 publication.complete()?;
16204 }
16205 let incremental_rows = writer.repository_graph_identity_rejections(
16206 fixture.project,
16207 std::slice::from_ref(&source_path),
16208 10,
16209 None,
16210 )?;
16211 require_eq(
16212 &incremental_rows,
16213 &expected_second,
16214 "incremental path replacement swaps typed rejection rows",
16215 )?;
16216 let third = GraphIdentityRejection {
16217 path: source_path.clone(),
16218 span: SourceSpan::new(6, 0, 6, 6)?,
16219 parser: ParserKind::TreeSitter,
16220 field: GraphIdentityField::Package,
16221 reason: GraphIdentityRejectionReason::Oversized,
16222 fact_index: 0,
16223 };
16224 let expected_third = vec![third.clone()];
16225 {
16226 let mut publication = writer.begin_index_publication("identity-rejections")?;
16227 publication.replace_repository_graph(
16228 fixture.project,
16229 &graph_v4.entities,
16230 &graph_v4.relations,
16231 &graph_v4.occurrences,
16232 &graph_v4.coverage,
16233 )?;
16234 publication
16235 .replace_graph_identity_rejections(fixture.project, std::slice::from_ref(&third))?;
16236 let missing = GraphEntity::new(
16237 fixture.project,
16238 EntitySelector::File {
16239 path: RepositoryFilePath::new(Path::new("src/missing.rs"))?,
16240 },
16241 IndexGeneration::new(4),
16242 )?;
16243 let error = publication.replace_repository_graph_for_paths(
16244 fixture.project,
16245 &["src/missing.rs".to_string()],
16246 &[missing],
16247 &[],
16248 &[],
16249 &[],
16250 );
16251 require(
16252 error.is_err(),
16253 "late graph fault did not abort staged rejection",
16254 )?;
16255 }
16256 let retained_after_fault = writer.repository_graph_identity_rejections(
16257 fixture.project,
16258 std::slice::from_ref(&source_path),
16259 10,
16260 None,
16261 )?;
16262 require_eq(
16263 &retained_after_fault,
16264 &expected_second,
16265 "late publication fault retained the prior complete generation",
16266 )?;
16267
16268 {
16269 let mut publication = writer.begin_index_publication("identity-rejections")?;
16270 publication.replace_repository_graph(
16271 fixture.project,
16272 &graph_v4.entities,
16273 &graph_v4.relations,
16274 &graph_v4.occurrences,
16275 &graph_v4.coverage,
16276 )?;
16277 publication
16278 .replace_graph_identity_rejections(fixture.project, std::slice::from_ref(&third))?;
16279 publication.complete()?;
16280 }
16281 let retried = writer.repository_graph_identity_rejections(
16282 fixture.project,
16283 std::slice::from_ref(&source_path),
16284 10,
16285 None,
16286 )?;
16287 require_eq(
16288 &retried,
16289 &expected_third,
16290 "typed rejection retry replaces prior generation",
16291 )?;
16292 let retained_raw_identity_count = writer.connection.query_row(
16293 "SELECT COUNT(*)
16294 FROM graph_identity_rejections
16295 WHERE file_path LIKE '%bad%'
16296 OR reason LIKE '%bad%'",
16297 [],
16298 |row| row.get::<_, i64>(0),
16299 )?;
16300 require_eq(
16301 &retained_raw_identity_count,
16302 &0,
16303 "typed rejection table retained no invalid raw identity material",
16304 )?;
16305 Ok(())
16306 }
16307
16308 #[test]
16309 fn incremental_rejection_ceiling_covers_preserved_and_incoming_rows()
16310 -> Result<(), Box<dyn Error>> {
16311 let temp = tempfile::tempdir()?;
16312 let root = temp.path().join("identity-rejection-ceiling");
16313 fs::create_dir_all(root.join(".projectatlas"))?;
16314 let database = root.join(".projectatlas/projectatlas.db");
16315 let mut store = AtlasStore::open_for_project(&database, &root)?;
16316 let fixture = publish_fixture(&mut store, "identity-rejection-ceiling")?;
16317 let source_path = RepositoryNodePath::new(Path::new("src/Äuth.rs"))?;
16318 let preserved_count = usize::try_from(GraphLimits::MAX_ROWS)?.saturating_sub(1);
16319 let preserved = (0..preserved_count)
16320 .map(|index| {
16321 let line = u32::try_from(index.saturating_add(1))?;
16322 Ok(GraphIdentityRejection {
16323 path: source_path.clone(),
16324 span: SourceSpan::new(line, 0, line, 0)?,
16325 parser: ParserKind::TreeSitter,
16326 field: GraphIdentityField::Symbol,
16327 reason: GraphIdentityRejectionReason::Empty,
16328 fact_index: u64::try_from(index)?,
16329 })
16330 })
16331 .collect::<Result<Vec<_>, Box<dyn Error>>>()?;
16332 let graph_v2 = graph_fixture(fixture.project, IndexGeneration::new(2))?;
16333 {
16334 let mut publication = store.begin_index_publication("identity-rejection-ceiling")?;
16335 publication.replace_repository_graph(
16336 fixture.project,
16337 &graph_v2.entities,
16338 &graph_v2.relations,
16339 &graph_v2.occurrences,
16340 &graph_v2.coverage,
16341 )?;
16342 publication.replace_graph_identity_rejections(fixture.project, &preserved)?;
16343 publication.complete()?;
16344 }
16345 let incoming = vec![
16346 GraphIdentityRejection {
16347 path: source_path.clone(),
16348 span: SourceSpan::new(20_000, 0, 20_000, 0)?,
16349 parser: ParserKind::TreeSitter,
16350 field: GraphIdentityField::RelationTarget,
16351 reason: GraphIdentityRejectionReason::ControlCharacters,
16352 fact_index: 20_000,
16353 },
16354 GraphIdentityRejection {
16355 path: source_path.clone(),
16356 span: SourceSpan::new(20_001, 0, 20_001, 0)?,
16357 parser: ParserKind::TreeSitter,
16358 field: GraphIdentityField::RelationTarget,
16359 reason: GraphIdentityRejectionReason::ControlCharacters,
16360 fact_index: 20_001,
16361 },
16362 ];
16363 {
16364 let mut publication = store.begin_index_publication("identity-rejection-overflow")?;
16365 publication.replace_repository_graph_for_paths(
16366 fixture.project,
16367 &["Cargo.toml".to_string()],
16368 &[],
16369 &[],
16370 &[],
16371 &[],
16372 )?;
16373 let error =
16374 match publication.replace_graph_identity_rejections(fixture.project, &incoming) {
16375 Ok(()) => {
16376 return Err(io::Error::other(
16377 "preserved plus incoming rows unexpectedly exceeded silently",
16378 )
16379 .into());
16380 }
16381 Err(error) => error,
16382 };
16383 require(
16384 matches!(
16385 error,
16386 DbError::GraphContract(GraphContractError::InvalidLimits { .. })
16387 ),
16388 "combined rejection ceiling returned an unrelated error",
16389 )?;
16390 }
16391 require_eq(
16392 &store.repository_graph_generation()?,
16393 &Some(IndexGeneration::new(2)),
16394 "combined rejection ceiling retained the prior generation",
16395 )?;
16396 require_eq(
16397 &store
16398 .repository_graph_identity_rejections(
16399 fixture.project,
16400 std::slice::from_ref(&source_path),
16401 GraphLimits::MAX_ROWS,
16402 None,
16403 )?
16404 .len(),
16405 &preserved_count,
16406 "combined rejection ceiling did not preserve complete prior rows",
16407 )?;
16408
16409 let graph_v3 = graph_fixture(fixture.project, IndexGeneration::new(3))?;
16410 {
16411 let mut publication = store.begin_index_publication("identity-rejection-retry")?;
16412 publication.replace_repository_graph_for_paths(
16413 fixture.project,
16414 std::slice::from_ref(&source_path.as_str().to_string()),
16415 &graph_v3.entities,
16416 &graph_v3.relations,
16417 &graph_v3.occurrences,
16418 &graph_v3.coverage,
16419 )?;
16420 publication.replace_graph_identity_rejections(fixture.project, &incoming)?;
16421 publication.complete()?;
16422 }
16423 let retried = store.repository_graph_identity_rejections(
16424 fixture.project,
16425 std::slice::from_ref(&source_path),
16426 GraphLimits::MAX_ROWS,
16427 None,
16428 )?;
16429 require_eq(
16430 &retried,
16431 &incoming,
16432 "rejection ceiling retry after affected replacement",
16433 )?;
16434 Ok(())
16435 }
16436
16437 #[test]
16438 fn graph_publication_failure_rolls_back_text_graph_and_generation_for_readers()
16439 -> Result<(), Box<dyn Error>> {
16440 let temp = tempfile::tempdir()?;
16441 let project_root = temp.path().join("graph-publication");
16442 let atlas_dir = project_root.join(".projectatlas");
16443 fs::create_dir_all(&atlas_dir)?;
16444 let db_path = atlas_dir.join("projectatlas.db");
16445 let mut writer = AtlasStore::open_for_project(&db_path, &project_root)?;
16446 let fixture_v1 = publish_fixture(&mut writer, "graph-publication")?;
16447 let old_reader = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
16448 require_graph_projection(
16449 &old_reader,
16450 &fixture_v1,
16451 IndexGeneration::new(1),
16452 "fn verifyToken()",
16453 )?;
16454
16455 let missing_entity = GraphEntity::new(
16456 fixture_v1.project,
16457 EntitySelector::File {
16458 path: RepositoryFilePath::new(Path::new("src/missing.rs"))?,
16459 },
16460 IndexGeneration::new(2),
16461 )?;
16462 {
16463 let mut publication = writer.begin_index_publication("graph-publication")?;
16464 publication.replace_file_texts_for_paths(
16465 &["src/Äuth.rs".to_string()],
16466 &[IndexedFileText {
16467 path: "src/Äuth.rs".to_string(),
16468 content_hash: Some("hash-new".to_string()),
16469 byte_count: "fn verifyTokenUpdated()".len(),
16470 line_count: 1,
16471 content: "fn verifyTokenUpdated()".to_string(),
16472 }],
16473 )?;
16474 let error = require_db_error(
16475 publication.replace_repository_graph_for_paths(
16476 fixture_v1.project,
16477 &["src/Äuth.rs".to_string(), "src/missing.rs".to_string()],
16478 &[missing_entity],
16479 &[],
16480 &[],
16481 &[],
16482 ),
16483 "missing-node graph publication unexpectedly succeeded",
16484 )?;
16485 require(
16486 matches!(error, DbError::Sqlite(_)),
16487 &format!("unexpected late graph publication error: {error}"),
16488 )?;
16489 }
16490
16491 require_graph_projection(
16492 &writer,
16493 &fixture_v1,
16494 IndexGeneration::new(1),
16495 "fn verifyToken()",
16496 )?;
16497 let rolled_back_reader = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
16498 require_graph_projection(
16499 &rolled_back_reader,
16500 &fixture_v1,
16501 IndexGeneration::new(1),
16502 "fn verifyToken()",
16503 )?;
16504 require_graph_projection(
16505 &old_reader,
16506 &fixture_v1,
16507 IndexGeneration::new(1),
16508 "fn verifyToken()",
16509 )?;
16510 rolled_back_reader.finish_index_read_snapshot()?;
16511
16512 let project = writer
16513 .project_instance_id()?
16514 .ok_or_else(|| io::Error::other("bound writer identity is missing"))?;
16515 let fixture_v2 = graph_fixture(project, IndexGeneration::new(2))?;
16516 {
16517 let mut publication = writer.begin_index_publication("graph-publication")?;
16518 publication.replace_file_texts_for_paths(
16519 &["src/Äuth.rs".to_string()],
16520 &[IndexedFileText {
16521 path: "src/Äuth.rs".to_string(),
16522 content_hash: Some("hash-new".to_string()),
16523 byte_count: "fn verifyTokenUpdated()".len(),
16524 line_count: 1,
16525 content: "fn verifyTokenUpdated()".to_string(),
16526 }],
16527 )?;
16528 publication.replace_repository_graph(
16529 fixture_v2.project,
16530 &fixture_v2.entities,
16531 &fixture_v2.relations,
16532 &fixture_v2.occurrences,
16533 &fixture_v2.coverage,
16534 )?;
16535 publication.complete()?;
16536 }
16537
16538 require_graph_projection(
16539 &old_reader,
16540 &fixture_v1,
16541 IndexGeneration::new(1),
16542 "fn verifyToken()",
16543 )?;
16544 let new_reader = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
16545 require_graph_projection(
16546 &new_reader,
16547 &fixture_v2,
16548 IndexGeneration::new(2),
16549 "fn verifyTokenUpdated()",
16550 )?;
16551 new_reader.finish_index_read_snapshot()?;
16552 old_reader.finish_index_read_snapshot()?;
16553 Ok(())
16554 }
16555}