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(Debug)]
67pub struct RepositoryResolutionReadPage<T> {
68 pub page: RepositoryGraphPage<T>,
70 pub retained_bytes: u64,
72}
73
74impl<T> RepositoryResolutionReadPage<T> {
75 fn empty() -> Self {
77 Self {
78 page: empty_page(),
79 retained_bytes: 0,
80 }
81 }
82}
83
84#[derive(Clone, Copy, Debug, Eq, PartialEq)]
86pub struct RepositoryGraphReadBudget {
87 requested_rows: NonZeroU32,
89 returned_rows: NonZeroU32,
91 decoded_bytes: NonZeroU64,
93 hydrated_entities: NonZeroU32,
95 hydrated_paths: NonZeroU32,
97}
98
99impl RepositoryGraphReadBudget {
100 pub const MAX_REQUESTED_ROWS: u32 = GraphLimits::MAX_ROWS;
102 pub const MAX_RETURNED_ROWS: u32 = GraphLimits::MAX_ROWS;
104 pub const MAX_DECODED_BYTES: u64 = 32 * 1_024 * 1_024;
106 pub const MAX_HYDRATED_ENTITIES: u32 = 2 * (GraphLimits::MAX_ROWS + 1);
108 pub const MAX_HYDRATED_PATHS: u32 = 2 * (GraphLimits::MAX_ROWS + 1);
110
111 pub fn new(
118 requested_rows: u32,
119 returned_rows: u32,
120 decoded_bytes: u64,
121 hydrated_entities: u32,
122 hydrated_paths: u32,
123 ) -> Result<Self, GraphContractError> {
124 if requested_rows == 0 || requested_rows > Self::MAX_REQUESTED_ROWS {
125 return Err(GraphContractError::InvalidLimits {
126 reason: "graph read requested-row budget is zero or above the batch ceiling",
127 });
128 }
129 if returned_rows == 0 || returned_rows > Self::MAX_RETURNED_ROWS {
130 return Err(GraphContractError::InvalidLimits {
131 reason: "graph read returned-row budget is zero or above the batch ceiling",
132 });
133 }
134 if decoded_bytes == 0 || decoded_bytes > Self::MAX_DECODED_BYTES {
135 return Err(GraphContractError::InvalidLimits {
136 reason: "graph read decoded-byte budget is zero or above the batch ceiling",
137 });
138 }
139 if hydrated_entities == 0 || hydrated_entities > Self::MAX_HYDRATED_ENTITIES {
140 return Err(GraphContractError::InvalidLimits {
141 reason: "graph read entity budget is zero or above the batch ceiling",
142 });
143 }
144 if hydrated_paths == 0 || hydrated_paths > Self::MAX_HYDRATED_PATHS {
145 return Err(GraphContractError::InvalidLimits {
146 reason: "graph read path budget is zero or above the batch ceiling",
147 });
148 }
149 Ok(Self {
150 requested_rows: NonZeroU32::new(requested_rows).ok_or(
151 GraphContractError::InvalidLimits {
152 reason: "graph read requested-row budget must be nonzero",
153 },
154 )?,
155 returned_rows: NonZeroU32::new(returned_rows).ok_or(
156 GraphContractError::InvalidLimits {
157 reason: "graph read returned-row budget must be nonzero",
158 },
159 )?,
160 decoded_bytes: NonZeroU64::new(decoded_bytes).ok_or(
161 GraphContractError::InvalidLimits {
162 reason: "graph read decoded-byte budget must be nonzero",
163 },
164 )?,
165 hydrated_entities: NonZeroU32::new(hydrated_entities).ok_or(
166 GraphContractError::InvalidLimits {
167 reason: "graph read entity budget must be nonzero",
168 },
169 )?,
170 hydrated_paths: NonZeroU32::new(hydrated_paths).ok_or(
171 GraphContractError::InvalidLimits {
172 reason: "graph read path budget must be nonzero",
173 },
174 )?,
175 })
176 }
177
178 #[must_use]
180 pub const fn requested_rows(self) -> u32 {
181 self.requested_rows.get()
182 }
183
184 #[must_use]
186 pub const fn returned_rows(self) -> u32 {
187 self.returned_rows.get()
188 }
189
190 #[must_use]
192 pub const fn decoded_bytes(self) -> u64 {
193 self.decoded_bytes.get()
194 }
195
196 #[must_use]
198 pub const fn hydrated_entities(self) -> u32 {
199 self.hydrated_entities.get()
200 }
201
202 #[must_use]
204 pub const fn hydrated_paths(self) -> u32 {
205 self.hydrated_paths.get()
206 }
207}
208
209#[derive(Clone, Copy, Debug, Eq, PartialEq)]
211pub struct RepositoryGraphReadWork {
212 pub requested_rows: u32,
214 pub returned_rows: u32,
216 pub decoded_bytes: u64,
218 pub hydrated_entities: u32,
220 pub hydrated_paths: u32,
222}
223
224#[derive(Clone, Debug, Eq, PartialEq)]
226pub struct RepositoryGraphReadBatch<T> {
227 pub rows: Vec<T>,
229 pub work: RepositoryGraphReadWork,
231}
232
233#[derive(Clone, Debug, Eq, PartialEq)]
235pub struct RepositoryGraphReadPage<T> {
236 pub page: RepositoryGraphPage<T>,
238 pub work: RepositoryGraphReadWork,
240}
241
242#[derive(Clone, Debug, Eq, PartialEq)]
244pub struct RepositoryGraphReadPages<T> {
245 pub pages: Vec<RepositoryGraphPage<T>>,
247 pub work: RepositoryGraphReadWork,
249}
250
251#[derive(Clone, Debug, Eq, PartialEq)]
253pub struct RepositoryCoverageQuery {
254 pub start_index: u32,
256 pub limit: u32,
258 pub path_prefix: Option<String>,
260 pub parser: Option<ParserKind>,
262 pub provider: Option<ParserKind>,
264 pub relation: Option<GraphRelationKind>,
266 pub state: Option<CoverageState>,
268 pub reason: Option<String>,
270}
271
272#[derive(Clone, Debug, Eq, PartialEq)]
274pub struct RepositoryCoverageRow {
275 pub coverage: CoverageRecord,
277 pub parser: Option<ParserKind>,
279 pub provider: Option<ParserKind>,
281}
282
283#[derive(Clone, Debug, Eq, PartialEq)]
285pub struct RepositoryNavigationNode {
286 pub path: String,
288 pub kind: NodeKind,
290}
291
292#[derive(Clone, Debug, Eq, PartialEq)]
294pub struct RepositoryNavigationConnections {
295 pub path: String,
297 pub counts: Vec<RankedConnectionCount>,
299 pub connections: Vec<RankedConnection>,
301 pub truncated: bool,
303}
304
305const NAVIGATION_CONNECTION_OWNER_CHUNK: usize = 8;
307
308const NAVIGATION_CONNECTION_FAMILIES: &[(RankedConnectionKind, &str, &str)] = &[
310 (RankedConnectionKind::Package, "legacy", "depends-on"),
311 (RankedConnectionKind::Import, "legacy", "imports"),
312 (RankedConnectionKind::Call, "legacy", "calls"),
313 (RankedConnectionKind::Reference, "extended", "references"),
314 (RankedConnectionKind::Test, "extended", "tests"),
315 (RankedConnectionKind::Route, "extended", "routes-to"),
316 (RankedConnectionKind::Config, "extended", "configures"),
317];
318
319#[derive(Clone, Copy, Debug, Eq, PartialEq)]
321pub struct RepositoryAffectedSourceFootprint {
322 pub rows: u64,
324 pub retained_bytes: u64,
326 pub truncated: bool,
328}
329
330#[derive(Clone, Debug, Eq, PartialEq)]
332pub struct RepositoryResolutionCandidate {
333 key: CanonicalResolutionKey,
335 entity: GraphEntity,
337}
338
339impl RepositoryResolutionCandidate {
340 #[must_use]
342 pub const fn key(&self) -> &CanonicalResolutionKey {
343 &self.key
344 }
345
346 #[must_use]
348 pub const fn entity(&self) -> &GraphEntity {
349 &self.entity
350 }
351}
352
353#[derive(Clone, Debug, Eq, PartialEq)]
355pub enum RepositoryGraphRelationQuery {
356 Outbound {
358 source: GraphEntityKey,
360 },
361 Inbound {
363 target: GraphEntityKey,
365 },
366 Family {
368 relation: GraphRelationKind,
370 },
371}
372
373#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
375pub enum RepositoryGraphDirection {
376 Outbound,
378 Inbound,
380}
381
382#[derive(Clone, Debug, Eq, PartialEq)]
384pub struct RepositoryGraphRelationRow {
385 pub relation: LogicalRelation,
387 pub source: GraphEntity,
389 pub target: Option<GraphEntity>,
391 pub document_unresolved_reason: Option<DocumentTargetUnresolvedReason>,
393}
394
395#[derive(Clone, Debug, Eq, PartialEq)]
397pub struct RepositoryGraphClassifiedRelationRow {
398 pub detail: RepositoryGraphRelationRow,
400 pub source_classification: Option<ContentClassification>,
402}
403
404#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
406pub struct RepositoryGraphAdjacencyContinuation {
407 project: ProjectInstanceId,
409 generation: IndexGeneration,
411 direction: RepositoryGraphDirection,
413 relation: Option<GraphRelationKind>,
415 #[serde(default, skip_serializing_if = "is_default")]
417 resolved_only: bool,
418 #[serde(default, skip_serializing_if = "is_default")]
420 include_documents: bool,
421 frontier: Vec<[u8; 32]>,
423 frontier_index: u32,
425 relation_scope: String,
427 relation_kind: String,
429 relation_key: [u8; 32],
431}
432
433#[derive(Clone, Debug, Eq, PartialEq)]
435pub struct RepositoryGraphAdjacencyRow {
436 pub frontier_index: u32,
438 pub frontier: GraphEntityKey,
440 pub direction: RepositoryGraphDirection,
442 pub detail: RepositoryGraphRelationRow,
444}
445
446#[derive(Clone, Debug, Eq, PartialEq)]
448pub struct RepositoryGraphAdjacencyPage {
449 pub rows: Vec<RepositoryGraphAdjacencyRow>,
451 pub truncated: bool,
453 pub continuation: Option<RepositoryGraphAdjacencyContinuation>,
455}
456
457#[derive(Clone, Debug, Eq, PartialEq)]
459pub struct RepositoryGraphAdjacencyReadPage {
460 pub page: RepositoryGraphAdjacencyPage,
462 pub work: RepositoryGraphReadWork,
464}
465
466pub const MAX_REPOSITORY_GRAPH_FRONTIER: usize = 256;
468
469const MAX_REPOSITORY_GRAPH_ADJACENCY_WORK_ROWS: usize = GraphLimits::MAX_ROWS as usize + 1;
471
472const GRAPH_ENTITY_HYDRATION_CHUNK: usize = 128;
474
475struct EntityRow {
477 key: Vec<u8>,
479 project: Vec<u8>,
481 canonical: String,
483 kind: String,
485 repository_path: Option<String>,
487 package_manager: Option<String>,
489 package_name: Option<String>,
491 manifest_path: Option<String>,
493 symbol_name: Option<String>,
495 symbol_kind: Option<String>,
497 symbol_parent: Option<String>,
499 symbol_signature: Option<String>,
501 external_system: Option<String>,
503 external_identity: Option<String>,
505}
506
507struct RelationRow {
509 key: Vec<u8>,
511 project: Vec<u8>,
513 canonical: String,
515 source: Vec<u8>,
517 relation_scope: String,
519 relation_kind: String,
521 resolution_status: String,
523 target: Option<Vec<u8>>,
525 reference: Option<String>,
527 candidate_count: Option<i64>,
529 document_unresolved_reason: Option<String>,
531 confidence: String,
533 completeness: String,
535}
536
537struct ClassifiedRelationRow {
539 relation: RelationRow,
541 source_classification: Option<String>,
543}
544
545struct AdjacencyRelationRow {
547 frontier_index: u32,
549 relation: RelationRow,
551}
552
553struct OccurrenceRow {
555 relation: Vec<u8>,
557 file_path: String,
559 start_line: i64,
561 start_column: i64,
563 end_line: i64,
565 end_column: i64,
567}
568
569struct CoverageRow {
571 project: Vec<u8>,
573 scope_kind: String,
575 scope_path: Option<String>,
577 relation_scope: Option<String>,
579 relation_kind: Option<String>,
581 state: String,
583 total: i64,
585 covered: i64,
587 omitted: i64,
589 reason: Option<String>,
591 reached_limit: Option<String>,
593 parser: Option<String>,
595 provider: Option<String>,
597}
598
599pub(crate) struct RepositoryGraphReadMeter {
601 budget: RepositoryGraphReadBudget,
603 requested_rows: u32,
605 decoded_bytes: u64,
607 hydrated_entities: u32,
609 hydrated_paths: HashSet<String>,
611}
612
613impl RepositoryGraphReadMeter {
614 pub(crate) fn new(budget: RepositoryGraphReadBudget, requested_rows: usize) -> DbResult<Self> {
616 let requested_rows =
617 u32::try_from(requested_rows).map_err(|_source| GraphContractError::InvalidLimits {
618 reason: "graph read requested-row count overflowed",
619 })?;
620 if requested_rows > budget.requested_rows() {
621 return Err(GraphContractError::InvalidLimits {
622 reason: "graph read requested rows exceed the batch budget",
623 }
624 .into());
625 }
626 Ok(Self {
627 budget,
628 requested_rows,
629 decoded_bytes: 0,
630 hydrated_entities: 0,
631 hydrated_paths: HashSet::new(),
632 })
633 }
634
635 pub(crate) fn record_decoded_bytes(&mut self, bytes: u64) -> DbResult<()> {
637 let decoded_bytes =
638 self.decoded_bytes
639 .checked_add(bytes)
640 .ok_or(GraphContractError::InvalidLimits {
641 reason: "graph read decoded-byte accounting overflowed",
642 })?;
643 if decoded_bytes > self.budget.decoded_bytes() {
644 return Err(GraphContractError::InvalidLimits {
645 reason: "graph read decoded bytes exceed the batch budget",
646 }
647 .into());
648 }
649 self.decoded_bytes = decoded_bytes;
650 Ok(())
651 }
652
653 fn record_entity(&mut self, entity: &GraphEntity) -> DbResult<()> {
655 let hydrated_entities =
656 self.hydrated_entities
657 .checked_add(1)
658 .ok_or(GraphContractError::InvalidLimits {
659 reason: "graph read entity accounting overflowed",
660 })?;
661 if hydrated_entities > self.budget.hydrated_entities() {
662 return Err(GraphContractError::InvalidLimits {
663 reason: "graph read hydrated entities exceed the batch budget",
664 }
665 .into());
666 }
667 if let Some(path) = graph_entity_purpose_owner(entity) {
668 self.record_hydrated_path(path)?;
669 }
670 self.hydrated_entities = hydrated_entities;
671 Ok(())
672 }
673
674 pub(crate) fn record_hydrated_path(&mut self, path: &str) -> DbResult<()> {
676 if self.hydrated_paths.contains(path) {
677 return Ok(());
678 }
679 let hydrated_paths = u32::try_from(self.hydrated_paths.len()).map_err(|_source| {
680 GraphContractError::InvalidLimits {
681 reason: "graph read path accounting overflowed",
682 }
683 })?;
684 if hydrated_paths >= self.budget.hydrated_paths() {
685 return Err(GraphContractError::InvalidLimits {
686 reason: "graph read hydrated paths exceed the batch budget",
687 }
688 .into());
689 }
690 self.hydrated_paths.insert(path.to_string());
691 Ok(())
692 }
693
694 pub(crate) fn finish(self, returned_rows: usize) -> DbResult<RepositoryGraphReadWork> {
696 let returned_rows =
697 u32::try_from(returned_rows).map_err(|_source| GraphContractError::InvalidLimits {
698 reason: "graph read returned-row count overflowed",
699 })?;
700 if returned_rows > self.budget.returned_rows() {
701 return Err(GraphContractError::InvalidLimits {
702 reason: "graph read returned rows exceed the batch budget",
703 }
704 .into());
705 }
706 let hydrated_paths = u32::try_from(self.hydrated_paths.len()).map_err(|_source| {
707 GraphContractError::InvalidLimits {
708 reason: "graph read path accounting overflowed",
709 }
710 })?;
711 Ok(RepositoryGraphReadWork {
712 requested_rows: self.requested_rows,
713 returned_rows,
714 decoded_bytes: self.decoded_bytes,
715 hydrated_entities: self.hydrated_entities,
716 hydrated_paths,
717 })
718 }
719}
720
721struct NavigationConnectionRow {
723 owner_index: usize,
725 kind: RankedConnectionKind,
727 direction: RankedConnectionDirection,
729 relation_key: Vec<u8>,
731 resolution_status: String,
733 reference: Option<String>,
735 entity_kind: Option<String>,
737 repository_path: Option<String>,
739 package_manager: Option<String>,
741 package_name: Option<String>,
743 manifest_path: Option<String>,
745 symbol_name: Option<String>,
747 external_system: Option<String>,
749 external_identity: Option<String>,
751}
752
753fn empty_navigation_connections(path: &str) -> RepositoryNavigationConnections {
755 RepositoryNavigationConnections {
756 path: path.to_string(),
757 counts: Vec::new(),
758 connections: Vec::new(),
759 truncated: false,
760 }
761}
762
763fn collect_navigation_connection_rows(
765 connection: &Connection,
766 owners: &[RepositoryNavigationNode],
767 family_limit_plus_one: i64,
768) -> DbResult<Vec<NavigationConnectionRow>> {
769 let mut branches = Vec::with_capacity(owners.len() * NAVIGATION_CONNECTION_FAMILIES.len() * 2);
770 let mut values = Vec::new();
771 for (owner_index, owner) in owners.iter().enumerate() {
772 for &(kind, scope, relation) in NAVIGATION_CONNECTION_FAMILIES {
773 branches.push(navigation_connection_branch(
774 owner_index,
775 owner,
776 kind,
777 scope,
778 relation,
779 RankedConnectionDirection::Outbound,
780 family_limit_plus_one,
781 &mut values,
782 ));
783 if owner.kind != NodeKind::Folder || owner.path != "." {
784 branches.push(navigation_connection_branch(
785 owner_index,
786 owner,
787 kind,
788 scope,
789 relation,
790 RankedConnectionDirection::Inbound,
791 family_limit_plus_one,
792 &mut values,
793 ));
794 }
795 }
796 }
797 let sql = branches.join(" UNION ALL ");
798 let mut statement = connection.prepare(&sql)?;
799 let mut rows = statement.query(params_from_iter(values))?;
800 let mut collected = Vec::new();
801 while let Some(row) = rows.next()? {
802 collected.push(navigation_connection_row(row)?);
803 }
804 Ok(collected)
805}
806
807fn navigation_connection_branch(
809 owner_index: usize,
810 owner: &RepositoryNavigationNode,
811 kind: RankedConnectionKind,
812 scope: &'static str,
813 relation: &'static str,
814 direction: RankedConnectionDirection,
815 family_limit_plus_one: i64,
816 values: &mut Vec<Value>,
817) -> String {
818 values.push(Value::Integer(owner_index as i64));
819 values.push(Value::Text(
820 navigation_connection_kind_name(kind).to_string(),
821 ));
822 if owner.kind == NodeKind::Folder
823 && owner.path == "."
824 && direction == RankedConnectionDirection::Outbound
825 {
826 values.push(Value::Text(scope.to_string()));
827 values.push(Value::Text(relation.to_string()));
828 values.push(Value::Integer(family_limit_plus_one));
829 return "SELECT * FROM (
830 SELECT ? AS owner_index, ? AS expected_kind, 'outbound' AS direction,
831 r.relation_key, r.relation_scope, r.relation_kind,
832 r.resolution_status, r.reference_text,
833 related.entity_kind, related.repository_path,
834 related.package_manager, related.package_name, related.manifest_path,
835 related.symbol_name, related.external_system, related.external_identity
836 FROM graph_relations r INDEXED BY idx_graph_relations_kind_order
837 LEFT JOIN graph_entities related
838 ON related.entity_key = r.target_entity_key
839 WHERE r.relation_scope = ? AND r.relation_kind = ?
840 ORDER BY r.relation_key
841 LIMIT ?
842 )"
843 .to_string();
844 }
845 let (relation_key, related_key, index, direction_name) = match direction {
846 RankedConnectionDirection::Outbound => (
847 "source_entity_key",
848 "target_entity_key",
849 "idx_graph_relations_source_kind",
850 "outbound",
851 ),
852 RankedConnectionDirection::Inbound => (
853 "target_entity_key",
854 "source_entity_key",
855 "idx_graph_relations_target_kind",
856 "inbound",
857 ),
858 };
859 let owned = navigation_owned_entity_sql(owner, values);
860 let exclude_internal = if direction == RankedConnectionDirection::Inbound {
861 let owned_sources = navigation_owned_entity_sql(owner, values);
862 format!(" AND r.source_entity_key NOT IN ({owned_sources})")
863 } else {
864 String::new()
865 };
866 values.push(Value::Text(scope.to_string()));
867 values.push(Value::Text(relation.to_string()));
868 values.push(Value::Integer(family_limit_plus_one));
869 format!(
870 "SELECT * FROM (
871 SELECT ? AS owner_index, ? AS expected_kind, '{direction_name}' AS direction,
872 r.relation_key, r.relation_scope, r.relation_kind,
873 r.resolution_status, r.reference_text,
874 related.entity_kind, related.repository_path,
875 related.package_manager, related.package_name, related.manifest_path,
876 related.symbol_name, related.external_system, related.external_identity
877 FROM graph_relations r INDEXED BY {index}
878 LEFT JOIN graph_entities related ON related.entity_key = r.{related_key}
879 WHERE r.{relation_key} IN ({owned})
880 {exclude_internal}
881 AND r.relation_scope = ? AND r.relation_kind = ?
882 ORDER BY r.relation_key
883 LIMIT ?
884 )"
885 )
886}
887
888fn navigation_owned_entity_sql(
890 owner: &RepositoryNavigationNode,
891 values: &mut Vec<Value>,
892) -> String {
893 match owner.kind {
894 NodeKind::File => {
895 values.push(Value::Text(owner.path.clone()));
896 values.push(Value::Text(owner.path.clone()));
897 "SELECT entity_key
898 FROM graph_entities INDEXED BY idx_graph_entities_path
899 WHERE repository_path = ?
900 UNION
901 SELECT entity_key
902 FROM graph_entities INDEXED BY idx_graph_entities_manifest_path
903 WHERE manifest_path = ?"
904 .to_string()
905 }
906 NodeKind::Folder if owner.path == "." => "SELECT entity_key
907 FROM graph_entities INDEXED BY idx_graph_entities_path
908 WHERE repository_path IS NOT NULL
909 UNION
910 SELECT entity_key
911 FROM graph_entities INDEXED BY idx_graph_entities_manifest_path
912 WHERE manifest_path IS NOT NULL"
913 .to_string(),
914 NodeKind::Folder => {
915 let lower = format!("{}/", owner.path);
916 let upper = format!("{}0", owner.path);
917 values.push(Value::Text(owner.path.clone()));
918 values.push(Value::Text(lower.clone()));
919 values.push(Value::Text(upper.clone()));
920 values.push(Value::Text(owner.path.clone()));
921 values.push(Value::Text(lower));
922 values.push(Value::Text(upper));
923 "SELECT entity_key
924 FROM graph_entities INDEXED BY idx_graph_entities_path
925 WHERE repository_path = ?
926 UNION
927 SELECT entity_key
928 FROM graph_entities INDEXED BY idx_graph_entities_path
929 WHERE repository_path >= ? AND repository_path < ?
930 UNION
931 SELECT entity_key
932 FROM graph_entities INDEXED BY idx_graph_entities_manifest_path
933 WHERE manifest_path = ?
934 UNION
935 SELECT entity_key
936 FROM graph_entities INDEXED BY idx_graph_entities_manifest_path
937 WHERE manifest_path >= ? AND manifest_path < ?"
938 .to_string()
939 }
940 }
941}
942
943fn navigation_connection_row(row: &Row<'_>) -> DbResult<NavigationConnectionRow> {
945 let owner_index = count_to_usize("navigation_connection.owner_index", row.get(0)?)?;
946 let expected_kind: String = row.get(1)?;
947 let direction = match row.get::<_, String>(2)?.as_str() {
948 "outbound" => RankedConnectionDirection::Outbound,
949 "inbound" => RankedConnectionDirection::Inbound,
950 _ => {
951 return Err(DbError::GraphRowShape {
952 table: "graph_relations",
953 reason: "navigation relation direction is invalid",
954 });
955 }
956 };
957 let relation_key: Vec<u8> = row.get(3)?;
958 if relation_key.len() != 32 {
959 return Err(DbError::InvalidBlobLength {
960 field: "graph_relations.relation_key",
961 expected: 32,
962 found: relation_key.len(),
963 });
964 }
965 let relation_scope: String = row.get(4)?;
966 let relation_kind: String = row.get(5)?;
967 let kind = navigation_connection_kind(&relation_scope, &relation_kind)?;
968 if navigation_connection_kind_name(kind) != expected_kind {
969 return Err(DbError::GraphRowShape {
970 table: "graph_relations",
971 reason: "navigation relation family does not match its query branch",
972 });
973 }
974 let resolution_status: String = row.get(6)?;
975 if !matches!(
976 resolution_status.as_str(),
977 "resolved" | "ambiguous" | "unresolved" | "external"
978 ) {
979 return Err(DbError::InvalidEnum {
980 field: "graph_relations.resolution_status",
981 value: resolution_status,
982 });
983 }
984 Ok(NavigationConnectionRow {
985 owner_index,
986 kind,
987 direction,
988 relation_key,
989 resolution_status,
990 reference: row.get(7)?,
991 entity_kind: row.get(8)?,
992 repository_path: row.get(9)?,
993 package_manager: row.get(10)?,
994 package_name: row.get(11)?,
995 manifest_path: row.get(12)?,
996 symbol_name: row.get(13)?,
997 external_system: row.get(14)?,
998 external_identity: row.get(15)?,
999 })
1000}
1001
1002fn navigation_connection_pages(
1004 owners: &[RepositoryNavigationNode],
1005 mut rows: Vec<NavigationConnectionRow>,
1006 family_limit: usize,
1007 sample_limit: usize,
1008) -> DbResult<Vec<RepositoryNavigationConnections>> {
1009 rows.sort_by(|left, right| {
1010 left.owner_index
1011 .cmp(&right.owner_index)
1012 .then_with(|| left.kind.cmp(&right.kind))
1013 .then_with(|| {
1014 navigation_direction_order(left.direction)
1015 .cmp(&navigation_direction_order(right.direction))
1016 })
1017 .then_with(|| left.relation_key.cmp(&right.relation_key))
1018 });
1019 let mut grouped =
1020 BTreeMap::<(usize, RankedConnectionKind), Vec<NavigationConnectionRow>>::new();
1021 for row in rows {
1022 if row.owner_index >= owners.len() {
1023 return Err(DbError::GraphRowShape {
1024 table: "graph_relations",
1025 reason: "navigation relation owner index is outside its request chunk",
1026 });
1027 }
1028 grouped
1029 .entry((row.owner_index, row.kind))
1030 .or_default()
1031 .push(row);
1032 }
1033
1034 owners
1035 .iter()
1036 .enumerate()
1037 .map(|(owner_index, owner)| {
1038 let mut page = empty_navigation_connections(&owner.path);
1039 for &(kind, _, _) in NAVIGATION_CONNECTION_FAMILIES {
1040 let Some(rows) = grouped.remove(&(owner_index, kind)) else {
1041 continue;
1042 };
1043 let mut seen = HashSet::new();
1044 let unique = rows
1045 .into_iter()
1046 .filter(|row| seen.insert(row.relation_key.clone()))
1047 .collect::<Vec<_>>();
1048 let truncated = unique.len() > family_limit;
1049 let count = unique.len().min(family_limit);
1050 page.counts.push(RankedConnectionCount {
1051 kind,
1052 count,
1053 truncated,
1054 });
1055 for (family_index, row) in unique.into_iter().enumerate() {
1056 let target = navigation_connection_target(&row)?;
1057 if family_index < family_limit && page.connections.len() < sample_limit {
1058 page.connections.push(RankedConnection {
1059 kind,
1060 direction: row.direction,
1061 target,
1062 });
1063 } else if family_index < family_limit {
1064 page.truncated = true;
1065 }
1066 }
1067 page.truncated |= truncated;
1068 }
1069 Ok(page)
1070 })
1071 .collect()
1072}
1073
1074fn navigation_connection_target(row: &NavigationConnectionRow) -> DbResult<RankedConnectionTarget> {
1076 if row.direction == RankedConnectionDirection::Outbound
1077 && matches!(row.resolution_status.as_str(), "ambiguous" | "unresolved")
1078 {
1079 let reference = row.reference.clone().ok_or(DbError::GraphRowShape {
1080 table: "graph_relations",
1081 reason: "unresolved navigation relation is missing its reference",
1082 })?;
1083 if row.entity_kind.is_some() {
1084 return Err(DbError::GraphRowShape {
1085 table: "graph_relations",
1086 reason: "unresolved navigation relation retained a target entity",
1087 });
1088 }
1089 return Ok(RankedConnectionTarget::Unresolved { reference });
1090 }
1091 if !matches!(row.resolution_status.as_str(), "resolved" | "external") {
1092 return Err(DbError::GraphRowShape {
1093 table: "graph_relations",
1094 reason: "inbound navigation relation has no resolved target",
1095 });
1096 }
1097 match row.entity_kind.as_deref() {
1098 Some("project") => Ok(RankedConnectionTarget::Local {
1099 path: ".".to_string(),
1100 symbol: None,
1101 }),
1102 Some("folder" | "file") => Ok(RankedConnectionTarget::Local {
1103 path: required_navigation_target(
1104 row.repository_path.as_deref(),
1105 "local navigation target is missing its repository path",
1106 )?,
1107 symbol: None,
1108 }),
1109 Some("symbol") => Ok(RankedConnectionTarget::Local {
1110 path: required_navigation_target(
1111 row.repository_path.as_deref(),
1112 "symbol navigation target is missing its repository path",
1113 )?,
1114 symbol: Some(required_navigation_target(
1115 row.symbol_name.as_deref(),
1116 "symbol navigation target is missing its declaration name",
1117 )?),
1118 }),
1119 Some("package") => Ok(RankedConnectionTarget::Package {
1120 manager: required_navigation_target(
1121 row.package_manager.as_deref(),
1122 "package navigation target is missing its manager",
1123 )?,
1124 name: required_navigation_target(
1125 row.package_name.as_deref(),
1126 "package navigation target is missing its name",
1127 )?,
1128 manifest: required_navigation_target(
1129 row.manifest_path.as_deref(),
1130 "package navigation target is missing its manifest",
1131 )?,
1132 }),
1133 Some("external") => Ok(RankedConnectionTarget::External {
1134 system: required_navigation_target(
1135 row.external_system.as_deref(),
1136 "external navigation target is missing its system",
1137 )?,
1138 identity: required_navigation_target(
1139 row.external_identity.as_deref(),
1140 "external navigation target is missing its identity",
1141 )?,
1142 }),
1143 Some(_) => Err(DbError::GraphRowShape {
1144 table: "graph_entities",
1145 reason: "navigation endpoint has an unsupported entity kind",
1146 }),
1147 None => Err(DbError::GraphRowShape {
1148 table: "graph_relations",
1149 reason: "resolved navigation relation target is missing",
1150 }),
1151 }
1152}
1153
1154fn required_navigation_target(value: Option<&str>, reason: &'static str) -> DbResult<String> {
1156 value
1157 .filter(|value| !value.is_empty())
1158 .map(str::to_string)
1159 .ok_or(DbError::GraphRowShape {
1160 table: "graph_entities",
1161 reason,
1162 })
1163}
1164
1165fn navigation_connection_kind(scope: &str, relation: &str) -> DbResult<RankedConnectionKind> {
1167 NAVIGATION_CONNECTION_FAMILIES
1168 .iter()
1169 .find_map(|&(kind, expected_scope, expected_relation)| {
1170 (scope == expected_scope && relation == expected_relation).then_some(kind)
1171 })
1172 .ok_or(DbError::GraphRowShape {
1173 table: "graph_relations",
1174 reason: "relation family is not available to navigation enrichment",
1175 })
1176}
1177
1178const fn navigation_connection_kind_name(kind: RankedConnectionKind) -> &'static str {
1180 match kind {
1181 RankedConnectionKind::Package => "package",
1182 RankedConnectionKind::Import => "import",
1183 RankedConnectionKind::Call => "call",
1184 RankedConnectionKind::Reference => "reference",
1185 RankedConnectionKind::Test => "test",
1186 RankedConnectionKind::Route => "route",
1187 RankedConnectionKind::Config => "config",
1188 }
1189}
1190
1191const fn navigation_direction_order(direction: RankedConnectionDirection) -> u8 {
1193 match direction {
1194 RankedConnectionDirection::Outbound => 0,
1195 RankedConnectionDirection::Inbound => 1,
1196 }
1197}
1198
1199impl AtlasStore {
1200 pub fn repository_navigation_connections(
1212 &self,
1213 owners: &[RepositoryNavigationNode],
1214 family_limit: u32,
1215 sample_limit: usize,
1216 ) -> DbResult<Vec<RepositoryNavigationConnections>> {
1217 let family_limit_plus_one = validated_limit_plus_one(
1218 family_limit,
1219 GraphLimits::MAX_ROWS,
1220 "navigation connection rows must be nonzero and within the product ceiling",
1221 )?;
1222 if sample_limit == 0 || sample_limit > GraphLimits::MAX_ROWS as usize {
1223 return Err(GraphContractError::InvalidLimits {
1224 reason: "navigation connection sample must be nonzero and within the product ceiling",
1225 }
1226 .into());
1227 }
1228 for owner in owners {
1229 match owner.kind {
1230 NodeKind::Folder => {
1231 RepositoryNodePath::new(Path::new(&owner.path))?;
1232 }
1233 NodeKind::File => {
1234 RepositoryFilePath::new(Path::new(&owner.path))?;
1235 }
1236 }
1237 }
1238 if owners.is_empty() {
1239 return Ok(Vec::new());
1240 }
1241 if self.repository_graph_generation()?.is_none() {
1242 return Ok(owners
1243 .iter()
1244 .map(|owner| empty_navigation_connections(&owner.path))
1245 .collect());
1246 }
1247 let project = load_project_identity(&self.connection)?
1248 .ok_or(DbError::ProjectInstanceIdentityMissing)?;
1249 require_bound_project_identity(&self.connection, project)?;
1250
1251 let mut result = Vec::with_capacity(owners.len());
1252 for chunk in owners.chunks(NAVIGATION_CONNECTION_OWNER_CHUNK) {
1253 let rows =
1254 collect_navigation_connection_rows(&self.connection, chunk, family_limit_plus_one)?;
1255 result.extend(navigation_connection_pages(
1256 chunk,
1257 rows,
1258 family_limit as usize,
1259 sample_limit,
1260 )?);
1261 }
1262 Ok(result)
1263 }
1264
1265 pub fn repository_graph_entity(&self, key: &GraphEntityKey) -> DbResult<Option<GraphEntity>> {
1272 let Some(generation) = self.repository_graph_generation()? else {
1273 return Ok(None);
1274 };
1275 if !verify_project_identity(&self.connection, key.project())? {
1276 return Ok(None);
1277 }
1278 Ok(self
1279 .repository_graph_entity_bounded(
1280 key,
1281 generation,
1282 maximum_repository_graph_read_budget()?,
1283 None,
1284 )?
1285 .rows
1286 .into_iter()
1287 .next())
1288 }
1289
1290 pub fn repository_graph_entity_bounded(
1299 &self,
1300 key: &GraphEntityKey,
1301 generation: IndexGeneration,
1302 budget: RepositoryGraphReadBudget,
1303 control: Option<&IndexWorkControl>,
1304 ) -> DbResult<RepositoryGraphReadBatch<GraphEntity>> {
1305 self.require_repository_graph_snapshot(key.project(), generation)?;
1306 let digest = key.digest_bytes()?;
1307 let mut meter = RepositoryGraphReadMeter::new(budget, 1)?;
1308 let mut entities = load_graph_entities_by_digest_metered(
1309 self,
1310 &[digest],
1311 key.project(),
1312 generation,
1313 control,
1314 Some(&mut meter),
1315 )?;
1316 let rows = entities.remove(&digest).into_iter().collect::<Vec<_>>();
1317 let work = meter.finish(rows.len())?;
1318 Ok(RepositoryGraphReadBatch { rows, work })
1319 }
1320
1321 pub fn repository_graph_entities_by_digest(
1329 &self,
1330 project: ProjectInstanceId,
1331 generation: IndexGeneration,
1332 digests: &[[u8; 32]],
1333 budget: RepositoryGraphReadBudget,
1334 control: Option<&IndexWorkControl>,
1335 ) -> DbResult<RepositoryGraphReadBatch<GraphEntity>> {
1336 validate_graph_hydration_request(digests)?;
1337 self.require_repository_graph_snapshot(project, generation)?;
1338 let mut meter = RepositoryGraphReadMeter::new(budget, digests.len())?;
1339 let mut entities = load_graph_entities_by_digest_metered(
1340 self,
1341 digests,
1342 project,
1343 generation,
1344 control,
1345 Some(&mut meter),
1346 )?;
1347 let mut ordered = Vec::with_capacity(digests.len());
1348 for digest in digests {
1349 ordered.push(entities.remove(digest).ok_or(DbError::GraphRowShape {
1350 table: "graph_entities",
1351 reason: "requested graph entity is missing",
1352 })?);
1353 }
1354 let work = meter.finish(ordered.len())?;
1355 Ok(RepositoryGraphReadBatch {
1356 rows: ordered,
1357 work,
1358 })
1359 }
1360
1361 pub fn repository_graph_entities_by_path(
1368 &self,
1369 project: ProjectInstanceId,
1370 path: &RepositoryNodePath,
1371 limit: u32,
1372 ) -> DbResult<RepositoryGraphPage<GraphEntity>> {
1373 validated_limit_plus_one(
1374 limit,
1375 GraphLimits::MAX_ROWS,
1376 "graph rows must be nonzero and within the product ceiling",
1377 )?;
1378 let Some(generation) = self.repository_graph_generation()? else {
1379 return Ok(empty_page());
1380 };
1381 if !verify_project_identity(&self.connection, project)? {
1382 return Ok(empty_page());
1383 }
1384 Ok(self
1385 .repository_graph_entities_by_path_bounded(
1386 project,
1387 generation,
1388 path,
1389 limit,
1390 maximum_repository_graph_read_budget()?,
1391 None,
1392 )?
1393 .page)
1394 }
1395
1396 pub fn repository_graph_entities_by_path_bounded(
1405 &self,
1406 project: ProjectInstanceId,
1407 generation: IndexGeneration,
1408 path: &RepositoryNodePath,
1409 limit: u32,
1410 budget: RepositoryGraphReadBudget,
1411 control: Option<&IndexWorkControl>,
1412 ) -> DbResult<RepositoryGraphReadPage<GraphEntity>> {
1413 let limit_plus_one = validated_limit_plus_one(
1414 limit,
1415 GraphLimits::MAX_ROWS,
1416 "graph rows must be nonzero and within the product ceiling",
1417 )?;
1418 self.require_repository_graph_snapshot(project, generation)?;
1419 let mut meter = RepositoryGraphReadMeter::new(budget, 1)?;
1420 let raw = with_sqlite_read_progress(
1421 &self.connection,
1422 control,
1423 IndexWorkStage::RepositoryTraversal,
1424 || {
1425 let mut statement = self.connection.prepare_cached(
1426 "SELECT entity_key, project_instance_id, canonical_identity, entity_kind,
1427 repository_path, package_manager, package_name, manifest_path,
1428 symbol_name, symbol_kind, symbol_parent, symbol_signature,
1429 external_system, external_identity
1430 FROM graph_entities
1431 WHERE project_instance_id = ?1 AND repository_path = ?2
1432 ORDER BY entity_kind, entity_key
1433 LIMIT ?3",
1434 )?;
1435 collect_entity_rows_metered(
1436 statement.query(params![
1437 &project.as_bytes()[..],
1438 path.as_str(),
1439 limit_plus_one
1440 ])?,
1441 &mut meter,
1442 )
1443 },
1444 )?;
1445 let page = page_from_raw(raw, limit, |row| {
1446 let entity = entity_from_row(row, project, generation)?;
1447 meter.record_entity(&entity)?;
1448 Ok(entity)
1449 })?;
1450 let work = meter.finish(page.rows.len())?;
1451 Ok(RepositoryGraphReadPage { page, work })
1452 }
1453
1454 pub fn repository_graph_entrypoint_candidates_page_bounded(
1466 &self,
1467 project: ProjectInstanceId,
1468 generation: IndexGeneration,
1469 limit: u32,
1470 selection: ContentSelection,
1471 budget: RepositoryGraphReadBudget,
1472 control: Option<&IndexWorkControl>,
1473 ) -> DbResult<RepositoryGraphReadPage<GraphEntity>> {
1474 let limit_plus_one = validated_limit_plus_one(
1475 limit,
1476 GraphLimits::MAX_ROWS,
1477 "graph entity rows must be nonzero and within the product ceiling",
1478 )?;
1479 self.require_repository_graph_snapshot(project, generation)?;
1480 let mut meter = RepositoryGraphReadMeter::new(budget, 1)?;
1481 let selection_filter = match selection {
1482 ContentSelection::UnspecifiedLegacy => "",
1483 ContentSelection::Source => {
1484 " AND EXISTS (SELECT 1 FROM file_content_classifications AS classification
1485 WHERE classification.path = graph_entities.repository_path
1486 AND classification.classification = 'source')"
1487 }
1488 ContentSelection::Documentation => {
1489 " AND EXISTS (SELECT 1 FROM file_content_classifications AS classification
1490 WHERE classification.path = graph_entities.repository_path
1491 AND classification.classification = 'documentation')"
1492 }
1493 ContentSelection::Both => {
1494 " AND EXISTS (SELECT 1 FROM file_content_classifications AS classification
1495 WHERE classification.path = graph_entities.repository_path
1496 AND classification.classification IN ('source', 'documentation'))"
1497 }
1498 };
1499 let sql = format!(
1500 "SELECT entity_key, project_instance_id, canonical_identity, entity_kind,
1501 repository_path, package_manager, package_name, manifest_path,
1502 symbol_name, symbol_kind, symbol_parent, symbol_signature,
1503 external_system, external_identity
1504 FROM graph_entities
1505 WHERE project_instance_id = ?1
1506 AND entity_kind IN ('file', 'symbol'){selection_filter}
1507 ORDER BY entity_key
1508 LIMIT ?2"
1509 );
1510 let raw = with_sqlite_read_progress(
1511 &self.connection,
1512 control,
1513 IndexWorkStage::RepositoryTraversal,
1514 || {
1515 let mut statement = self.connection.prepare_cached(&sql)?;
1516 collect_entity_rows_metered(
1517 statement.query(params![&project.as_bytes()[..], limit_plus_one])?,
1518 &mut meter,
1519 )
1520 },
1521 )?;
1522 let page = page_from_raw(raw, limit, |row| {
1523 let entity = entity_from_row(row, project, generation)?;
1524 meter.record_entity(&entity)?;
1525 Ok(entity)
1526 })?;
1527 let work = meter.finish(page.rows.len())?;
1528 Ok(RepositoryGraphReadPage { page, work })
1529 }
1530
1531 pub fn repository_resolution_candidates(
1538 &self,
1539 key: &CanonicalResolutionKey,
1540 limit: u32,
1541 ) -> DbResult<RepositoryGraphPage<GraphEntity>> {
1542 let limit_plus_one = validated_limit_plus_one(
1543 limit,
1544 GraphLimits::MAX_ROWS,
1545 "resolution candidates must be nonzero and within the product ceiling",
1546 )?;
1547 let Some(generation) = self.repository_graph_generation()? else {
1548 return Ok(empty_page());
1549 };
1550 if !verify_project_identity(&self.connection, key.project())? {
1551 return Ok(empty_page());
1552 }
1553 if !validate_persisted_resolution_key(&self.connection, key)? {
1554 return Ok(empty_page());
1555 }
1556 let raw = {
1557 let mut statement = self.connection.prepare_cached(
1558 "SELECT entity.entity_key, entity.project_instance_id,
1559 entity.canonical_identity, entity.entity_kind,
1560 entity.repository_path, entity.package_manager,
1561 entity.package_name, entity.manifest_path,
1562 entity.symbol_name, entity.symbol_kind,
1563 entity.symbol_parent, entity.symbol_signature,
1564 entity.external_system, entity.external_identity
1565 FROM graph_entity_exports AS export
1566 INDEXED BY idx_graph_entity_exports_key
1567 JOIN graph_entities AS entity
1568 ON entity.project_instance_id = export.project_instance_id
1569 AND entity.entity_key = export.entity_key
1570 WHERE export.project_instance_id = ?1
1571 AND export.resolution_domain = ?2
1572 AND export.key_digest = ?3
1573 ORDER BY export.entity_key
1574 LIMIT ?4",
1575 )?;
1576 collect_entity_rows(statement.query(params![
1577 &key.project().as_bytes()[..],
1578 key.domain().as_str(),
1579 &key.digest_bytes()[..],
1580 limit_plus_one,
1581 ])?)?
1582 };
1583 page_from_raw(raw, limit, |row| {
1584 entity_from_row(row, key.project(), generation)
1585 })
1586 }
1587
1588 pub fn repository_resolution_candidates_for_keys(
1601 &self,
1602 project: ProjectInstanceId,
1603 keys: &[CanonicalResolutionKey],
1604 limit: u32,
1605 ) -> DbResult<RepositoryGraphPage<RepositoryResolutionCandidate>> {
1606 self.repository_resolution_candidates_for_keys_with_byte_limit(
1607 project,
1608 keys,
1609 limit,
1610 u64::MAX,
1611 )
1612 .map(|read| read.page)
1613 }
1614
1615 pub fn repository_resolution_candidates_for_keys_with_byte_limit(
1621 &self,
1622 project: ProjectInstanceId,
1623 keys: &[CanonicalResolutionKey],
1624 limit: u32,
1625 byte_limit: u64,
1626 ) -> DbResult<RepositoryResolutionReadPage<RepositoryResolutionCandidate>> {
1627 validated_limit_plus_one(
1628 limit,
1629 GraphLimits::MAX_ROWS,
1630 "resolution candidates must be nonzero and within the product ceiling",
1631 )?;
1632 let Some(generation) = self.repository_graph_generation()? else {
1633 return Ok(RepositoryResolutionReadPage::empty());
1634 };
1635 if !verify_project_identity(&self.connection, project)? {
1636 return Ok(RepositoryResolutionReadPage::empty());
1637 }
1638 let mut budget = ResolutionReadBudget::new(byte_limit);
1639 let keys = normalized_resolution_keys(project, keys, &mut budget)?;
1640 validate_persisted_resolution_keys(&self.connection, &keys, &budget)?;
1641 let mut candidates = Vec::new();
1642 for chunk in keys.chunks(RESOLUTION_KEYS_PER_QUERY) {
1643 if chunk.is_empty() {
1644 continue;
1645 }
1646 let values_clause = anonymous_values_clause(chunk.len(), 4);
1647 let sql = format!(
1648 "WITH requested(project_instance_id, resolution_domain, key_digest, canonical_identity)
1649 AS (VALUES {values_clause})
1650 SELECT entity.entity_key, entity.project_instance_id,
1651 entity.canonical_identity, entity.entity_kind,
1652 entity.repository_path, entity.package_manager,
1653 entity.package_name, entity.manifest_path,
1654 entity.symbol_name, entity.symbol_kind,
1655 entity.symbol_parent, entity.symbol_signature,
1656 entity.external_system, entity.external_identity,
1657 stored.project_instance_id, stored.resolution_domain,
1658 stored.key_digest, stored.canonical_identity
1659 FROM requested
1660 JOIN graph_resolution_keys AS stored
1661 ON stored.project_instance_id = requested.project_instance_id
1662 AND stored.resolution_domain = requested.resolution_domain
1663 AND stored.key_digest = requested.key_digest
1664 AND stored.canonical_identity = requested.canonical_identity
1665 JOIN graph_entity_exports AS export
1666 INDEXED BY idx_graph_entity_exports_key
1667 ON export.project_instance_id = stored.project_instance_id
1668 AND export.resolution_domain = stored.resolution_domain
1669 AND export.key_digest = stored.key_digest
1670 JOIN graph_entities AS entity
1671 ON entity.project_instance_id = export.project_instance_id
1672 AND entity.entity_key = export.entity_key
1673 ORDER BY stored.resolution_domain, stored.key_digest, export.entity_key
1674 LIMIT ?"
1675 );
1676 let mut values = resolution_key_values(chunk, true);
1677 values.push(Value::Integer(i64::from(limit) + 1));
1678 let mut statement = self.connection.prepare(&sql)?;
1679 let mut rows = statement.query(params_from_iter(values.iter()))?;
1680 while let Some(row) = rows.next()? {
1681 budget.reserve_output(ResolutionReadBudget::row_bytes(
1682 row,
1683 std::mem::size_of::<RepositoryResolutionCandidate>(),
1684 )?)?;
1685 let entity = entity_from_row(entity_row(row)?, project, generation)?;
1686 let key_project = project_from_blob(
1687 "graph_resolution_keys.project_instance_id",
1688 row.get::<_, Vec<u8>>(14)?,
1689 )?;
1690 require_project(project, key_project)?;
1691 let domain_text = row.get::<_, String>(15)?;
1692 let domain = ResolutionKeyDomain::try_from(domain_text.as_str())?;
1693 let digest = fixed_bytes::<32>(
1694 "graph_resolution_keys.key_digest",
1695 row.get::<_, Vec<u8>>(16)?,
1696 )?;
1697 let key = CanonicalResolutionKey::from_persisted(
1698 key_project,
1699 domain,
1700 digest,
1701 row.get(17)?,
1702 )?;
1703 candidates.push(RepositoryResolutionCandidate { key, entity });
1704 }
1705 if candidates.len() > limit as usize {
1706 break;
1707 }
1708 }
1709 candidates.sort_by(|left, right| {
1710 left.key
1711 .cmp(&right.key)
1712 .then_with(|| left.entity.key().digest().cmp(right.entity.key().digest()))
1713 .then_with(|| {
1714 left.entity
1715 .key()
1716 .canonical_identity()
1717 .cmp(right.entity.key().canonical_identity())
1718 })
1719 });
1720 candidates.dedup_by(|left, right| {
1721 left.key == right.key && left.entity.key() == right.entity.key()
1722 });
1723 let truncated = candidates.len() > limit as usize;
1724 candidates.truncate(limit as usize);
1725 let extra_slots = candidates
1728 .capacity()
1729 .saturating_sub(candidates.len().saturating_mul(2));
1730 let retained_bytes = budget.output_retained.saturating_add(
1731 (extra_slots as u64)
1732 .saturating_mul(std::mem::size_of::<RepositoryResolutionCandidate>() as u64),
1733 );
1734 Ok(RepositoryResolutionReadPage {
1735 page: RepositoryGraphPage {
1736 rows: candidates,
1737 truncated,
1738 },
1739 retained_bytes,
1740 })
1741 }
1742
1743 pub fn repository_export_keys_for_paths(
1753 &self,
1754 project: ProjectInstanceId,
1755 paths: &[String],
1756 limit: u32,
1757 ) -> DbResult<RepositoryGraphPage<CanonicalResolutionKey>> {
1758 self.repository_export_keys_for_paths_with_byte_limit(project, paths, limit, u64::MAX)
1759 .map(|read| read.page)
1760 }
1761
1762 pub fn repository_export_keys_for_paths_with_byte_limit(
1768 &self,
1769 project: ProjectInstanceId,
1770 paths: &[String],
1771 limit: u32,
1772 byte_limit: u64,
1773 ) -> DbResult<RepositoryResolutionReadPage<CanonicalResolutionKey>> {
1774 validated_limit_plus_one(
1775 limit,
1776 GraphLimits::MAX_ROWS,
1777 "resolution export keys 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(RepositoryResolutionReadPage::empty());
1783 }
1784 let mut budget = ResolutionReadBudget::new(byte_limit);
1785 for path in paths {
1786 budget.reserve(4_u64.saturating_mul(128_u64.saturating_add(path.len() as u64)))?;
1787 }
1788 let paths = normalized_file_paths(paths)?;
1789 let keys = resolution_keys_for_owner_paths(
1790 &self.connection,
1791 project,
1792 &paths,
1793 ResolutionOwner::EntityExports,
1794 Some(limit),
1795 &mut budget,
1796 )?;
1797 Ok(RepositoryResolutionReadPage {
1798 page: page_from_ordered_set(keys, limit),
1799 retained_bytes: budget.output_retained,
1800 })
1801 }
1802
1803 pub fn repository_affected_source_paths(
1815 &self,
1816 project: ProjectInstanceId,
1817 keys: &[CanonicalResolutionKey],
1818 limit: u32,
1819 ) -> DbResult<RepositoryGraphPage<RepositoryFilePath>> {
1820 self.repository_affected_source_paths_with_byte_limit(project, keys, limit, u64::MAX)
1821 .map(|read| read.page)
1822 }
1823
1824 pub fn repository_affected_source_paths_with_byte_limit(
1830 &self,
1831 project: ProjectInstanceId,
1832 keys: &[CanonicalResolutionKey],
1833 limit: u32,
1834 byte_limit: u64,
1835 ) -> DbResult<RepositoryResolutionReadPage<RepositoryFilePath>> {
1836 validated_limit_plus_one(
1837 limit,
1838 GraphLimits::MAX_ROWS,
1839 "affected source paths must be nonzero and within the product ceiling",
1840 )?;
1841 if self.repository_graph_generation()?.is_none()
1842 || !verify_project_identity(&self.connection, project)?
1843 {
1844 return Ok(RepositoryResolutionReadPage::empty());
1845 }
1846 let mut budget = ResolutionReadBudget::new(byte_limit);
1847 let keys = normalized_resolution_keys(project, keys, &mut budget)?;
1848 validate_persisted_resolution_keys(&self.connection, &keys, &budget)?;
1849 let paths = affected_source_paths(&self.connection, &keys, limit, &mut budget)?;
1850 Ok(RepositoryResolutionReadPage {
1851 page: page_from_ordered_set(paths, limit),
1852 retained_bytes: budget.output_retained,
1853 })
1854 }
1855
1856 pub fn repository_affected_source_footprint(
1870 &self,
1871 project: ProjectInstanceId,
1872 paths: &[String],
1873 limit: u32,
1874 ) -> DbResult<RepositoryAffectedSourceFootprint> {
1875 let limit_plus_one = validated_limit_plus_one(
1876 limit,
1877 GraphLimits::MAX_ROWS,
1878 "affected source footprint rows must be nonzero and within the product ceiling",
1879 )?;
1880 if self.repository_graph_generation()?.is_none()
1881 || !verify_project_identity(&self.connection, project)?
1882 {
1883 return Ok(empty_affected_source_footprint());
1884 }
1885 let paths = normalized_file_paths(paths)?;
1886 affected_source_footprint(&self.connection, project, &paths, limit_plus_one)
1887 }
1888
1889 pub fn repository_graph_relations(
1896 &self,
1897 query: RepositoryGraphRelationQuery,
1898 limit: u32,
1899 ) -> DbResult<RepositoryGraphPage<LogicalRelation>> {
1900 let page = self.repository_graph_relation_rows(query, limit, None)?;
1901 Ok(RepositoryGraphPage {
1902 rows: page.rows.into_iter().map(|row| row.relation).collect(),
1903 truncated: page.truncated,
1904 })
1905 }
1906
1907 pub fn repository_graph_resolved_relation_hubs(
1918 &self,
1919 relation: GraphRelationKind,
1920 limit: u32,
1921 control: Option<&IndexWorkControl>,
1922 ) -> DbResult<RepositoryGraphPage<GraphEntity>> {
1923 if let Some(control) = control {
1924 control.check(IndexWorkStage::RepositoryTraversal)?;
1925 }
1926 let limit_plus_one = validated_limit_plus_one(
1927 limit,
1928 GraphLimits::MAX_ROWS,
1929 "resolved graph hub rows must be nonzero and within the product ceiling",
1930 )?;
1931 let Some(generation) = self.repository_graph_generation()? else {
1932 return Ok(empty_page());
1933 };
1934 let project =
1935 load_project_identity(&self.connection)?.ok_or(DbError::GraphPublicationUnavailable)?;
1936 let (scope, kind) = relation_parts(relation);
1937 let raw_keys = with_sqlite_read_progress(
1938 &self.connection,
1939 control,
1940 IndexWorkStage::RepositoryTraversal,
1941 || {
1942 let mut statement = self
1943 .connection
1944 .prepare_cached(resolved_relation_hub_keys_sql())?;
1945 let mut rows = statement.query(params![
1946 &project.as_bytes()[..],
1947 scope,
1948 kind,
1949 RESOLUTION_STATUS_RESOLVED,
1950 limit_plus_one,
1951 ])?;
1952 let mut keys = Vec::new();
1953 while let Some(row) = rows.next()? {
1954 keys.push(row.get::<_, Vec<u8>>(0)?);
1955 }
1956 Ok(keys)
1957 },
1958 )?;
1959 let keys = raw_keys
1960 .into_iter()
1961 .map(|key| fixed_bytes::<32>("graph_entities.entity_key", key))
1962 .collect::<DbResult<Vec<_>>>()?;
1963 let mut entities =
1964 load_graph_entities_by_digest_metered(self, &keys, project, generation, control, None)?;
1965 let ordered = keys
1966 .into_iter()
1967 .map(|key| {
1968 entities.remove(&key).ok_or(DbError::GraphRowShape {
1969 table: "graph_relations",
1970 reason: "ranked resolved endpoint entity is missing",
1971 })
1972 })
1973 .collect::<DbResult<Vec<_>>>()?;
1974 page_from_raw(ordered, limit, Ok)
1975 }
1976
1977 pub fn repository_graph_relation_rows(
1985 &self,
1986 query: RepositoryGraphRelationQuery,
1987 limit: u32,
1988 control: Option<&IndexWorkControl>,
1989 ) -> DbResult<RepositoryGraphPage<RepositoryGraphRelationRow>> {
1990 let limit_plus_one = validated_limit_plus_one(
1991 limit,
1992 GraphLimits::MAX_ROWS,
1993 "graph rows must be nonzero and within the product ceiling",
1994 )?;
1995 let Some(generation) = self.repository_graph_generation()? else {
1996 return Ok(empty_page());
1997 };
1998 let (project, raw) = match query {
1999 RepositoryGraphRelationQuery::Outbound { source } => {
2000 let project = source.project();
2001 if !verify_project_identity(&self.connection, project)? {
2002 return Ok(empty_page());
2003 }
2004 let raw = with_sqlite_read_progress(
2005 &self.connection,
2006 control,
2007 IndexWorkStage::RepositoryTraversal,
2008 || {
2009 self.collect_relation_rows_by_key(
2010 "source_entity_key",
2011 &source.digest_bytes()?,
2012 limit_plus_one,
2013 )
2014 },
2015 )?;
2016 (project, raw)
2017 }
2018 RepositoryGraphRelationQuery::Inbound { target } => {
2019 let project = target.project();
2020 if !verify_project_identity(&self.connection, project)? {
2021 return Ok(empty_page());
2022 }
2023 let raw = with_sqlite_read_progress(
2024 &self.connection,
2025 control,
2026 IndexWorkStage::RepositoryTraversal,
2027 || {
2028 self.collect_relation_rows_by_key(
2029 "target_entity_key",
2030 &target.digest_bytes()?,
2031 limit_plus_one,
2032 )
2033 },
2034 )?;
2035 (project, raw)
2036 }
2037 RepositoryGraphRelationQuery::Family { relation } => {
2038 let project = load_project_identity(&self.connection)?
2039 .ok_or(DbError::GraphPublicationUnavailable)?;
2040 let (scope, kind) = relation_parts(relation);
2041 let raw = with_sqlite_read_progress(
2042 &self.connection,
2043 control,
2044 IndexWorkStage::RepositoryTraversal,
2045 || {
2046 #[cfg(feature = "sqlite-progress-test-observer")]
2047 crate::sqlite_progress_test_observer::notify(
2048 crate::sqlite_progress_test_observer::SqliteReadProgressEvent::RepositoryRelationFamilyQueryEntered,
2049 );
2050 let result = (|| {
2051 let mut statement = self.connection.prepare_cached(
2052 "SELECT relation_key, project_instance_id, canonical_identity,
2053 source_entity_key, relation_scope, relation_kind,
2054 resolution_status, target_entity_key, reference_text,
2055 candidate_count, document_unresolved_reason,
2056 confidence, completeness
2057 FROM graph_relations
2058 WHERE project_instance_id = ?1
2059 AND relation_scope = ?2 AND relation_kind = ?3
2060 ORDER BY relation_key
2061 LIMIT ?4",
2062 )?;
2063 collect_relation_rows(statement.query(params![
2064 &project.as_bytes()[..],
2065 scope,
2066 kind,
2067 limit_plus_one
2068 ])?)
2069 })();
2070 #[cfg(feature = "sqlite-progress-test-observer")]
2071 crate::sqlite_progress_test_observer::notify(
2072 crate::sqlite_progress_test_observer::SqliteReadProgressEvent::RepositoryRelationFamilyQueryExited,
2073 );
2074 result
2075 },
2076 )?;
2077 (project, raw)
2078 }
2079 };
2080 let entities = load_relation_entities(self, &raw, project, generation, control)?;
2081 page_from_raw(raw, limit, |row| {
2082 relation_detail_from_row(&entities, row, project, generation)
2083 })
2084 }
2085
2086 pub fn repository_graph_classified_relation_family_rows(
2099 &self,
2100 relation: GraphRelationKind,
2101 selection: ContentSelection,
2102 limit: u32,
2103 control: Option<&IndexWorkControl>,
2104 ) -> DbResult<RepositoryGraphPage<RepositoryGraphClassifiedRelationRow>> {
2105 let limit_plus_one = validated_limit_plus_one(
2106 limit,
2107 GraphLimits::MAX_ROWS,
2108 "graph rows must be nonzero and within the product ceiling",
2109 )?;
2110 let Some(generation) = self.repository_graph_generation()? else {
2111 return Ok(empty_page());
2112 };
2113 let project =
2114 load_project_identity(&self.connection)?.ok_or(DbError::GraphPublicationUnavailable)?;
2115 let (scope, kind) = relation_parts(relation);
2116 let sql = classified_relation_family_sql(selection);
2117 let mut bindings = vec![
2118 Value::Blob(project.as_bytes().to_vec()),
2119 Value::Text(scope.to_string()),
2120 Value::Text(kind.to_string()),
2121 ];
2122 match selection {
2123 ContentSelection::UnspecifiedLegacy => {}
2124 ContentSelection::Source => bindings.push(Value::Text(
2125 ContentClassification::Source.as_str().to_string(),
2126 )),
2127 ContentSelection::Documentation => bindings.push(Value::Text(
2128 ContentClassification::Documentation.as_str().to_string(),
2129 )),
2130 ContentSelection::Both => bindings.extend([
2131 Value::Text(ContentClassification::Source.as_str().to_string()),
2132 Value::Text(ContentClassification::Documentation.as_str().to_string()),
2133 ]),
2134 }
2135 bindings.push(Value::Integer(limit_plus_one));
2136 let raw = with_sqlite_read_progress(
2137 &self.connection,
2138 control,
2139 IndexWorkStage::RepositoryTraversal,
2140 || {
2141 #[cfg(feature = "sqlite-progress-test-observer")]
2142 crate::sqlite_progress_test_observer::notify(
2143 crate::sqlite_progress_test_observer::SqliteReadProgressEvent::RepositoryRelationFamilyQueryEntered,
2144 );
2145 let result = (|| {
2146 let mut statement = self.connection.prepare_cached(&sql)?;
2147 collect_classified_relation_rows(
2148 statement.query(params_from_iter(bindings.iter()))?,
2149 )
2150 })();
2151 #[cfg(feature = "sqlite-progress-test-observer")]
2152 crate::sqlite_progress_test_observer::notify(
2153 crate::sqlite_progress_test_observer::SqliteReadProgressEvent::RepositoryRelationFamilyQueryExited,
2154 );
2155 result
2156 },
2157 )?;
2158 let relation_rows = raw.iter().map(|row| &row.relation).collect::<Vec<_>>();
2159 let entities =
2160 load_relation_entity_references(self, &relation_rows, project, generation, control)?;
2161 page_from_raw(raw, limit, |row| {
2162 classified_relation_detail_from_row(&entities, row, project, generation)
2163 })
2164 }
2165
2166 pub fn repository_graph_relation_rows_by_digest(
2175 &self,
2176 project: ProjectInstanceId,
2177 generation: IndexGeneration,
2178 digests: &[[u8; 32]],
2179 budget: RepositoryGraphReadBudget,
2180 control: Option<&IndexWorkControl>,
2181 ) -> DbResult<RepositoryGraphReadBatch<RepositoryGraphRelationRow>> {
2182 validate_graph_hydration_request(digests)?;
2183 self.require_repository_graph_snapshot(project, generation)?;
2184 let mut meter = RepositoryGraphReadMeter::new(budget, digests.len())?;
2185 if digests.is_empty() {
2186 return Ok(RepositoryGraphReadBatch {
2187 rows: Vec::new(),
2188 work: meter.finish(0)?,
2189 });
2190 }
2191 let sql = graph_relation_hydration_sql(digests.len());
2192 let mut bindings = digests
2193 .iter()
2194 .map(|digest| Value::Blob(digest.to_vec()))
2195 .collect::<Vec<_>>();
2196 bindings.push(Value::Blob(project.as_bytes().to_vec()));
2197 let raw = with_sqlite_read_progress(
2198 &self.connection,
2199 control,
2200 IndexWorkStage::RepositoryTraversal,
2201 || {
2202 let mut statement = self.connection.prepare(&sql)?;
2203 collect_relation_rows_metered(
2204 statement.query(params_from_iter(bindings.iter()))?,
2205 &mut meter,
2206 )
2207 },
2208 )?;
2209 let mut relations = HashMap::with_capacity(raw.len());
2210 for row in raw {
2211 let digest = fixed_bytes::<32>("graph_relations.relation_key", row.key.clone())?;
2212 if relations.insert(digest, row).is_some() {
2213 return Err(DbError::GraphRowShape {
2214 table: "graph_relations",
2215 reason: "batched relation hydration returned a duplicate key",
2216 });
2217 }
2218 }
2219 let mut ordered = Vec::with_capacity(digests.len());
2220 for digest in digests {
2221 ordered.push(relations.remove(digest).ok_or(DbError::GraphRowShape {
2222 table: "graph_relations",
2223 reason: "requested graph relation is missing",
2224 })?);
2225 }
2226 let references = ordered.iter().collect::<Vec<_>>();
2227 let entities = load_relation_entity_references_metered(
2228 self,
2229 &references,
2230 project,
2231 generation,
2232 control,
2233 Some(&mut meter),
2234 )?;
2235 let rows = ordered
2236 .into_iter()
2237 .map(|row| relation_detail_from_row(&entities, row, project, generation))
2238 .collect::<DbResult<Vec<_>>>()?;
2239 let work = meter.finish(rows.len())?;
2240 Ok(RepositoryGraphReadBatch { rows, work })
2241 }
2242
2243 pub fn repository_graph_adjacency_page(
2257 &self,
2258 frontier: &[GraphEntityKey],
2259 direction: RepositoryGraphDirection,
2260 continuation: Option<&RepositoryGraphAdjacencyContinuation>,
2261 limit: u32,
2262 control: Option<&IndexWorkControl>,
2263 ) -> DbResult<RepositoryGraphAdjacencyPage> {
2264 Ok(self
2265 .repository_graph_adjacency_page_bounded(
2266 frontier,
2267 direction,
2268 continuation,
2269 limit,
2270 maximum_repository_graph_read_budget()?,
2271 control,
2272 )?
2273 .page)
2274 }
2275
2276 pub fn repository_graph_adjacency_page_bounded(
2284 &self,
2285 frontier: &[GraphEntityKey],
2286 direction: RepositoryGraphDirection,
2287 continuation: Option<&RepositoryGraphAdjacencyContinuation>,
2288 limit: u32,
2289 budget: RepositoryGraphReadBudget,
2290 control: Option<&IndexWorkControl>,
2291 ) -> DbResult<RepositoryGraphAdjacencyReadPage> {
2292 self.repository_graph_adjacency_page_filtered_bounded(
2293 frontier,
2294 direction,
2295 None,
2296 continuation,
2297 limit,
2298 budget,
2299 control,
2300 )
2301 }
2302
2303 pub fn repository_graph_adjacency_page_filtered(
2314 &self,
2315 frontier: &[GraphEntityKey],
2316 direction: RepositoryGraphDirection,
2317 relation: Option<GraphRelationKind>,
2318 continuation: Option<&RepositoryGraphAdjacencyContinuation>,
2319 limit: u32,
2320 control: Option<&IndexWorkControl>,
2321 ) -> DbResult<RepositoryGraphAdjacencyPage> {
2322 Ok(self
2323 .repository_graph_adjacency_page_filtered_bounded(
2324 frontier,
2325 direction,
2326 relation,
2327 continuation,
2328 limit,
2329 maximum_repository_graph_read_budget()?,
2330 control,
2331 )?
2332 .page)
2333 }
2334
2335 pub fn repository_graph_resolved_adjacency_page(
2343 &self,
2344 frontier: &[GraphEntityKey],
2345 direction: RepositoryGraphDirection,
2346 relation: GraphRelationKind,
2347 continuation: Option<&RepositoryGraphAdjacencyContinuation>,
2348 limit: u32,
2349 control: Option<&IndexWorkControl>,
2350 ) -> DbResult<RepositoryGraphAdjacencyPage> {
2351 Ok(self
2352 .repository_graph_adjacency_page_filtered_by_resolution_bounded(
2353 frontier,
2354 direction,
2355 Some(relation),
2356 true,
2357 false,
2358 continuation,
2359 limit,
2360 maximum_repository_graph_read_budget()?,
2361 control,
2362 )?
2363 .page)
2364 }
2365
2366 pub fn repository_graph_adjacency_page_filtered_bounded(
2376 &self,
2377 frontier: &[GraphEntityKey],
2378 direction: RepositoryGraphDirection,
2379 relation: Option<GraphRelationKind>,
2380 continuation: Option<&RepositoryGraphAdjacencyContinuation>,
2381 limit: u32,
2382 budget: RepositoryGraphReadBudget,
2383 control: Option<&IndexWorkControl>,
2384 ) -> DbResult<RepositoryGraphAdjacencyReadPage> {
2385 self.repository_graph_adjacency_page_filtered_bounded_with_documents(
2386 frontier,
2387 direction,
2388 relation,
2389 false,
2390 continuation,
2391 limit,
2392 budget,
2393 control,
2394 )
2395 }
2396
2397 #[allow(clippy::too_many_arguments)]
2408 pub fn repository_graph_adjacency_page_filtered_bounded_with_documents(
2409 &self,
2410 frontier: &[GraphEntityKey],
2411 direction: RepositoryGraphDirection,
2412 relation: Option<GraphRelationKind>,
2413 include_documents: bool,
2414 continuation: Option<&RepositoryGraphAdjacencyContinuation>,
2415 limit: u32,
2416 budget: RepositoryGraphReadBudget,
2417 control: Option<&IndexWorkControl>,
2418 ) -> DbResult<RepositoryGraphAdjacencyReadPage> {
2419 self.repository_graph_adjacency_page_filtered_by_resolution_bounded(
2420 frontier,
2421 direction,
2422 relation,
2423 false,
2424 include_documents,
2425 continuation,
2426 limit,
2427 budget,
2428 control,
2429 )
2430 }
2431
2432 pub fn repository_graph_adjacency_is_empty(
2443 &self,
2444 key: &GraphEntityKey,
2445 direction: RepositoryGraphDirection,
2446 relation: GraphRelationKind,
2447 control: Option<&IndexWorkControl>,
2448 ) -> DbResult<bool> {
2449 let project = key.project();
2450 if !verify_project_identity(&self.connection, project)?
2451 || self.repository_graph_generation()?.is_none()
2452 {
2453 return Ok(true);
2454 }
2455 let digest = key.digest_bytes()?;
2456 let (key_column, index_name) = match direction {
2457 RepositoryGraphDirection::Outbound => {
2458 ("source_entity_key", "idx_graph_relations_source_kind")
2459 }
2460 RepositoryGraphDirection::Inbound => {
2461 ("target_entity_key", "idx_graph_relations_target_kind")
2462 }
2463 };
2464 let (scope, kind) = relation_parts(relation);
2465 let sql = format!(
2466 "SELECT EXISTS(
2467 SELECT 1
2468 FROM graph_relations AS relation INDEXED BY {index_name}
2469 WHERE relation.project_instance_id = ?1
2470 AND relation.{key_column} = ?2
2471 AND relation.relation_scope = ?3
2472 AND relation.relation_kind = ?4
2473 )"
2474 );
2475 let bindings = [
2476 Value::Blob(project.as_bytes().to_vec()),
2477 Value::Blob(digest.to_vec()),
2478 Value::Text(scope.to_string()),
2479 Value::Text(kind.to_string()),
2480 ];
2481 with_sqlite_read_progress(
2482 &self.connection,
2483 control,
2484 IndexWorkStage::RepositoryTraversal,
2485 || {
2486 self.connection
2487 .query_row(&sql, params_from_iter(bindings.iter()), |row| {
2488 row.get::<_, bool>(0)
2489 })
2490 .map(|has_row| !has_row)
2491 .map_err(Into::into)
2492 },
2493 )
2494 }
2495
2496 pub fn repository_graph_adjacency_is_empty_filtered(
2508 &self,
2509 key: &GraphEntityKey,
2510 direction: RepositoryGraphDirection,
2511 relation: GraphRelationKind,
2512 minimum_confidence: ConfidenceClass,
2513 selection: ContentSelection,
2514 control: Option<&IndexWorkControl>,
2515 ) -> DbResult<bool> {
2516 let project = key.project();
2517 if !verify_project_identity(&self.connection, project)?
2518 || self.repository_graph_generation()?.is_none()
2519 {
2520 return Ok(true);
2521 }
2522 let digest = key.digest_bytes()?;
2523 let (key_column, index_name, endpoint_column) = match direction {
2524 RepositoryGraphDirection::Outbound => (
2525 "source_entity_key",
2526 "idx_graph_relations_source_kind",
2527 "target_entity_key",
2528 ),
2529 RepositoryGraphDirection::Inbound => (
2530 "target_entity_key",
2531 "idx_graph_relations_target_kind",
2532 "source_entity_key",
2533 ),
2534 };
2535 let (scope, kind) = relation_parts(relation);
2536 let confidence_filter = match minimum_confidence {
2537 ConfidenceClass::Exact => "AND relation.confidence = 'exact'",
2538 ConfidenceClass::High => "AND relation.confidence IN ('exact', 'high')",
2539 ConfidenceClass::Medium => "AND relation.confidence IN ('exact', 'high', 'medium')",
2540 ConfidenceClass::Low => "",
2541 };
2542 let endpoint_joins = if selection == ContentSelection::UnspecifiedLegacy {
2543 String::new()
2544 } else {
2545 format!(
2546 "LEFT JOIN graph_entities AS endpoint
2547 ON endpoint.project_instance_id = relation.project_instance_id
2548 AND endpoint.entity_key = relation.{endpoint_column}
2549 LEFT JOIN file_content_classifications AS endpoint_classification
2550 ON endpoint_classification.path = CASE endpoint.entity_kind
2551 WHEN 'file' THEN endpoint.repository_path
2552 WHEN 'symbol' THEN endpoint.repository_path
2553 WHEN 'package' THEN endpoint.manifest_path
2554 END"
2555 )
2556 };
2557 let selection_filter = match selection {
2558 ContentSelection::UnspecifiedLegacy => "",
2559 ContentSelection::Source => {
2560 "AND (relation.resolution_status NOT IN ('resolved', 'external')
2561 OR relation.resolution_status = 'external'
2562 OR (relation.relation_scope = 'extended'
2563 AND relation.relation_kind = 'documents')
2564 OR (relation.resolution_status = 'resolved'
2565 AND endpoint_classification.classification = 'source'))"
2566 }
2567 ContentSelection::Documentation => {
2568 "AND (relation.resolution_status NOT IN ('resolved', 'external')
2569 OR relation.resolution_status = 'external'
2570 OR (relation.relation_scope = 'extended'
2571 AND relation.relation_kind = 'documents')
2572 OR (relation.resolution_status = 'resolved'
2573 AND endpoint_classification.classification = 'documentation'))"
2574 }
2575 ContentSelection::Both => {
2576 "AND (relation.resolution_status NOT IN ('resolved', 'external')
2577 OR relation.resolution_status = 'external'
2578 OR (relation.relation_scope = 'extended'
2579 AND relation.relation_kind = 'documents')
2580 OR (relation.resolution_status = 'resolved'
2581 AND endpoint_classification.classification IN ('source', 'documentation')))"
2582 }
2583 };
2584 let sql = format!(
2585 "SELECT EXISTS(
2586 SELECT 1
2587 FROM graph_relations AS relation INDEXED BY {index_name}
2588 {endpoint_joins}
2589 WHERE relation.project_instance_id = ?1
2590 AND relation.{key_column} = ?2
2591 AND relation.relation_scope = ?3
2592 AND relation.relation_kind = ?4
2593 {confidence_filter}
2594 {selection_filter}
2595 )"
2596 );
2597 let bindings = [
2598 Value::Blob(project.as_bytes().to_vec()),
2599 Value::Blob(digest.to_vec()),
2600 Value::Text(scope.to_string()),
2601 Value::Text(kind.to_string()),
2602 ];
2603 with_sqlite_read_progress(
2604 &self.connection,
2605 control,
2606 IndexWorkStage::RepositoryTraversal,
2607 || {
2608 self.connection
2609 .query_row(&sql, params_from_iter(bindings.iter()), |row| {
2610 row.get::<_, bool>(0)
2611 })
2612 .map(|has_row| !has_row)
2613 .map_err(Into::into)
2614 },
2615 )
2616 }
2617
2618 pub fn repository_graph_adjacency_continuation_has_filtered_rows(
2629 &self,
2630 continuation: &RepositoryGraphAdjacencyContinuation,
2631 minimum_confidence: ConfidenceClass,
2632 selection: ContentSelection,
2633 control: Option<&IndexWorkControl>,
2634 ) -> DbResult<bool> {
2635 let project = continuation.project;
2636 self.require_repository_graph_snapshot(project, continuation.generation)?;
2637 if continuation.frontier.is_empty()
2638 || continuation.frontier_index as usize >= continuation.frontier.len()
2639 {
2640 return Err(GraphContractError::InvalidLimits {
2641 reason: "graph adjacency continuation has an invalid frontier",
2642 }
2643 .into());
2644 }
2645 let (key_column, index_name, endpoint_column) = match continuation.direction {
2646 RepositoryGraphDirection::Outbound => (
2647 "source_entity_key",
2648 "idx_graph_relations_source_kind",
2649 "target_entity_key",
2650 ),
2651 RepositoryGraphDirection::Inbound => (
2652 "target_entity_key",
2653 "idx_graph_relations_target_kind",
2654 "source_entity_key",
2655 ),
2656 };
2657 let confidence_filter = match minimum_confidence {
2658 ConfidenceClass::Exact => "AND relation.confidence = 'exact'",
2659 ConfidenceClass::High => "AND relation.confidence IN ('exact', 'high')",
2660 ConfidenceClass::Medium => "AND relation.confidence IN ('exact', 'high', 'medium')",
2661 ConfidenceClass::Low => "",
2662 };
2663 let endpoint_joins = if selection == ContentSelection::UnspecifiedLegacy {
2664 String::new()
2665 } else {
2666 format!(
2667 "LEFT JOIN graph_entities AS endpoint
2668 ON endpoint.project_instance_id = relation.project_instance_id
2669 AND endpoint.entity_key = relation.{endpoint_column}
2670 LEFT JOIN file_content_classifications AS endpoint_classification
2671 ON endpoint_classification.path = CASE endpoint.entity_kind
2672 WHEN 'file' THEN endpoint.repository_path
2673 WHEN 'symbol' THEN endpoint.repository_path
2674 WHEN 'package' THEN endpoint.manifest_path
2675 END"
2676 )
2677 };
2678 let selection_filter = match selection {
2679 ContentSelection::UnspecifiedLegacy => "",
2680 ContentSelection::Source => {
2681 "AND (relation.resolution_status NOT IN ('resolved', 'external')
2682 OR relation.resolution_status = 'external'
2683 OR (relation.relation_scope = 'extended'
2684 AND relation.relation_kind = 'documents')
2685 OR (relation.resolution_status = 'resolved'
2686 AND endpoint_classification.classification = 'source'))"
2687 }
2688 ContentSelection::Documentation => {
2689 "AND (relation.resolution_status NOT IN ('resolved', 'external')
2690 OR relation.resolution_status = 'external'
2691 OR (relation.relation_scope = 'extended'
2692 AND relation.relation_kind = 'documents')
2693 OR (relation.resolution_status = 'resolved'
2694 AND endpoint_classification.classification = 'documentation'))"
2695 }
2696 ContentSelection::Both => {
2697 "AND (relation.resolution_status NOT IN ('resolved', 'external')
2698 OR relation.resolution_status = 'external'
2699 OR (relation.relation_scope = 'extended'
2700 AND relation.relation_kind = 'documents')
2701 OR (relation.resolution_status = 'resolved'
2702 AND endpoint_classification.classification IN ('source', 'documentation')))"
2703 }
2704 };
2705 let relation_filter = continuation.relation.map(|relation| {
2706 let (scope, kind) = relation_parts(relation);
2707 (scope, kind)
2708 });
2709 let relation_filter_sql = if relation_filter.is_some() {
2710 "AND relation.relation_scope = ? AND relation.relation_kind = ?"
2711 } else {
2712 ""
2713 };
2714 let resolution_filter = if continuation.resolved_only {
2715 "AND relation.resolution_status = 'resolved'
2716 AND relation.target_entity_key IS NOT NULL
2717 AND relation.source_entity_key <> relation.target_entity_key"
2718 } else {
2719 ""
2720 };
2721 let document_filter = if continuation.relation.is_some() || continuation.include_documents {
2722 ""
2723 } else {
2724 "AND NOT (
2725 relation.relation_scope = 'extended'
2726 AND relation.relation_kind = 'documents'
2727 )"
2728 };
2729 let mut bindings = Vec::new();
2730 let branches = (continuation.frontier_index as usize..continuation.frontier.len())
2731 .map(|frontier_index| {
2732 let is_continuation_frontier =
2733 frontier_index == continuation.frontier_index as usize;
2734 let keyset = if is_continuation_frontier {
2735 "AND (relation.relation_scope, relation.relation_kind,
2736 relation.relation_key) > (?, ?, ?)"
2737 } else {
2738 ""
2739 };
2740 bindings.push(Value::Blob(project.as_bytes().to_vec()));
2741 bindings.push(Value::Blob(continuation.frontier[frontier_index].to_vec()));
2742 if let Some((scope, kind)) = relation_filter {
2743 bindings.push(Value::Text(scope.to_string()));
2744 bindings.push(Value::Text(kind.to_string()));
2745 }
2746 if is_continuation_frontier {
2747 bindings.push(Value::Text(continuation.relation_scope.clone()));
2748 bindings.push(Value::Text(continuation.relation_kind.clone()));
2749 bindings.push(Value::Blob(continuation.relation_key.to_vec()));
2750 }
2751 format!(
2752 "SELECT 1
2753 FROM graph_relations AS relation INDEXED BY {index_name}
2754 {endpoint_joins}
2755 WHERE relation.project_instance_id = ?
2756 AND relation.{key_column} = ?
2757 {relation_filter_sql}
2758 {resolution_filter}
2759 {document_filter}
2760 {confidence_filter}
2761 {selection_filter}
2762 {keyset}"
2763 )
2764 })
2765 .collect::<Vec<_>>()
2766 .join(" UNION ALL ");
2767 let sql = format!("SELECT EXISTS(SELECT 1 FROM ({branches}) AS pending LIMIT 1)");
2768 with_sqlite_read_progress(
2769 &self.connection,
2770 control,
2771 IndexWorkStage::RepositoryTraversal,
2772 || {
2773 self.connection
2774 .query_row(&sql, params_from_iter(bindings.iter()), |row| {
2775 row.get::<_, bool>(0)
2776 })
2777 .map_err(Into::into)
2778 },
2779 )
2780 }
2781
2782 fn repository_graph_adjacency_page_filtered_by_resolution_bounded(
2784 &self,
2785 frontier: &[GraphEntityKey],
2786 direction: RepositoryGraphDirection,
2787 relation: Option<GraphRelationKind>,
2788 resolved_only: bool,
2789 include_documents: bool,
2790 continuation: Option<&RepositoryGraphAdjacencyContinuation>,
2791 limit: u32,
2792 budget: RepositoryGraphReadBudget,
2793 control: Option<&IndexWorkControl>,
2794 ) -> DbResult<RepositoryGraphAdjacencyReadPage> {
2795 let include_documents = include_documents
2796 || relation == Some(GraphRelationKind::Extended(ExtendedRelationKind::Documents));
2797 let limit_plus_one = validated_limit_plus_one(
2798 limit,
2799 GraphLimits::MAX_ROWS,
2800 "graph adjacency rows must be nonzero and within the product ceiling",
2801 )?;
2802 if limit > budget.returned_rows() {
2803 return Err(GraphContractError::InvalidLimits {
2804 reason: "graph adjacency page limit exceeds the return budget",
2805 }
2806 .into());
2807 }
2808 if frontier.len() > MAX_REPOSITORY_GRAPH_FRONTIER {
2809 return Err(GraphContractError::InvalidLimits {
2810 reason: "graph adjacency frontier exceeds the product ceiling",
2811 }
2812 .into());
2813 }
2814 if frontier.is_empty() {
2815 if continuation.is_some() {
2816 return Err(GraphContractError::InvalidLimits {
2817 reason: "graph adjacency continuation requires a nonempty frontier",
2818 }
2819 .into());
2820 }
2821 let meter = RepositoryGraphReadMeter::new(budget, 0)?;
2822 return Ok(RepositoryGraphAdjacencyReadPage {
2823 page: empty_adjacency_page(),
2824 work: meter.finish(0)?,
2825 });
2826 }
2827 let mut meter = RepositoryGraphReadMeter::new(budget, frontier.len())?;
2828 let limit_plus_one_usize = usize::try_from(limit_plus_one).map_err(|_source| {
2829 GraphContractError::InvalidLimits {
2830 reason: "graph adjacency page limit overflowed",
2831 }
2832 })?;
2833 let project = frontier[0].project();
2834 let mut unique = BTreeSet::new();
2835 let mut frontier_digests = Vec::with_capacity(frontier.len());
2836 for key in frontier {
2837 require_project(project, key.project())?;
2838 let digest = key.digest_bytes()?;
2839 if !unique.insert(digest) {
2840 return Err(GraphContractError::InvalidLimits {
2841 reason: "graph adjacency frontier must contain unique entities",
2842 }
2843 .into());
2844 }
2845 frontier_digests.push(digest);
2846 }
2847
2848 let Some(generation) = self.repository_graph_generation()? else {
2849 if continuation.is_some() {
2850 return Err(GraphContractError::InvalidLimits {
2851 reason: "graph adjacency continuation has no active generation",
2852 }
2853 .into());
2854 }
2855 return Ok(RepositoryGraphAdjacencyReadPage {
2856 page: empty_adjacency_page(),
2857 work: meter.finish(0)?,
2858 });
2859 };
2860 if !verify_project_identity(&self.connection, project)? {
2861 if continuation.is_some() {
2862 return Err(GraphContractError::InvalidLimits {
2863 reason: "graph adjacency continuation does not match the bound project",
2864 }
2865 .into());
2866 }
2867 return Ok(RepositoryGraphAdjacencyReadPage {
2868 page: empty_adjacency_page(),
2869 work: meter.finish(0)?,
2870 });
2871 }
2872
2873 if let Some(continuation) = continuation
2874 && (continuation.project != project
2875 || continuation.generation != generation
2876 || continuation.direction != direction
2877 || continuation.relation != relation
2878 || continuation.resolved_only != resolved_only
2879 || continuation.include_documents != include_documents
2880 || continuation.frontier != frontier_digests
2881 || continuation.frontier_index as usize >= frontier.len())
2882 {
2883 return Err(GraphContractError::InvalidLimits {
2884 reason: "graph adjacency continuation does not match the request",
2885 }
2886 .into());
2887 }
2888
2889 let continuation_index = continuation.map_or(0, |value| value.frontier_index as usize);
2890 let active_frontier = frontier.len() - continuation_index;
2891 let work_rows = active_frontier.checked_mul(limit_plus_one_usize).ok_or(
2892 GraphContractError::InvalidLimits {
2893 reason: "graph adjacency intermediate row ceiling overflowed",
2894 },
2895 )?;
2896 if work_rows > MAX_REPOSITORY_GRAPH_ADJACENCY_WORK_ROWS {
2897 return Err(GraphContractError::InvalidLimits {
2898 reason: "graph adjacency frontier and page exceed the intermediate row ceiling",
2899 }
2900 .into());
2901 }
2902 if resolved_only && relation.is_none() {
2903 return Err(GraphContractError::InvalidLimits {
2904 reason: "resolved graph adjacency requires an exact relation family",
2905 }
2906 .into());
2907 }
2908
2909 let bindings_per_frontier = if relation.is_some() { 4 } else { 2 };
2910 let mut bindings = Vec::with_capacity(
2911 active_frontier * bindings_per_frontier + continuation.map_or(2, |_| 5),
2912 );
2913 bindings.push(Value::Blob(project.as_bytes().to_vec()));
2914 if resolved_only {
2915 bindings.push(Value::Text(RESOLUTION_STATUS_RESOLVED.to_string()));
2916 }
2917 for (index, digest) in frontier_digests.iter().enumerate().skip(continuation_index) {
2918 bindings.push(Value::Blob(digest.to_vec()));
2919 if let Some(relation) = relation {
2920 let (scope, kind) = relation_parts(relation);
2921 bindings.push(Value::Text(scope.to_string()));
2922 bindings.push(Value::Text(kind.to_string()));
2923 }
2924 if index == continuation_index
2925 && let Some(continuation) = continuation
2926 {
2927 bindings.push(Value::Text(continuation.relation_scope.clone()));
2928 bindings.push(Value::Text(continuation.relation_kind.clone()));
2929 bindings.push(Value::Blob(continuation.relation_key.to_vec()));
2930 }
2931 bindings.push(Value::Integer(limit_plus_one));
2932 }
2933 bindings.push(Value::Integer(limit_plus_one));
2934
2935 let sql = adjacency_relation_sql(
2936 frontier.len(),
2937 direction,
2938 continuation.map(|value| value.frontier_index as usize),
2939 relation.is_some(),
2940 resolved_only,
2941 include_documents,
2942 );
2943 let raw = with_sqlite_read_progress(
2944 &self.connection,
2945 control,
2946 IndexWorkStage::RepositoryTraversal,
2947 || {
2948 let mut statement = self.connection.prepare(&sql)?;
2949 collect_adjacency_relation_rows_metered(
2950 statement.query(params_from_iter(bindings.iter()))?,
2951 &mut meter,
2952 )
2953 },
2954 )?;
2955 let truncated = raw.len() > limit as usize;
2956 let next = if truncated {
2957 let last = raw.get(limit as usize - 1).ok_or(DbError::GraphRowShape {
2958 table: "graph_relations",
2959 reason: "adjacency truncation row is missing",
2960 })?;
2961 Some(RepositoryGraphAdjacencyContinuation {
2962 project,
2963 generation,
2964 direction,
2965 relation,
2966 resolved_only,
2967 include_documents,
2968 frontier: frontier_digests,
2969 frontier_index: last.frontier_index,
2970 relation_scope: last.relation.relation_scope.clone(),
2971 relation_kind: last.relation.relation_kind.clone(),
2972 relation_key: fixed_bytes::<32>(
2973 "graph_relations.relation_key",
2974 last.relation.key.clone(),
2975 )?,
2976 })
2977 } else {
2978 None
2979 };
2980 let relation_rows = raw.iter().map(|row| &row.relation).collect::<Vec<_>>();
2981 let entities = load_relation_entity_references_metered(
2982 self,
2983 &relation_rows,
2984 project,
2985 generation,
2986 control,
2987 Some(&mut meter),
2988 )?;
2989 let mut rows = Vec::with_capacity(raw.len());
2990 for row in raw {
2991 if let Some(control) = control {
2992 control.check(IndexWorkStage::RepositoryTraversal)?;
2993 }
2994 let frontier_key = frontier
2995 .get(row.frontier_index as usize)
2996 .ok_or(DbError::GraphRowShape {
2997 table: "graph_relations",
2998 reason: "adjacency row has an invalid frontier position",
2999 })?
3000 .clone();
3001 rows.push(RepositoryGraphAdjacencyRow {
3002 frontier_index: row.frontier_index,
3003 frontier: frontier_key,
3004 direction,
3005 detail: relation_detail_from_row(&entities, row.relation, project, generation)?,
3006 });
3007 }
3008 if truncated {
3009 rows.pop();
3010 }
3011 let work = meter.finish(rows.len())?;
3012 Ok(RepositoryGraphAdjacencyReadPage {
3013 page: RepositoryGraphAdjacencyPage {
3014 rows,
3015 truncated,
3016 continuation: next,
3017 },
3018 work,
3019 })
3020 }
3021
3022 pub fn repository_graph_occurrences(
3029 &self,
3030 relation: &LogicalRelation,
3031 limit: u32,
3032 ) -> DbResult<RepositoryGraphPage<RelationOccurrence>> {
3033 let limit_plus_one = validated_limit_plus_one(
3034 limit,
3035 GraphLimits::MAX_OCCURRENCES,
3036 "graph occurrences must be nonzero and within the product ceiling",
3037 )?;
3038 let Some(generation) = self.repository_graph_generation()? else {
3039 return Ok(empty_page());
3040 };
3041 if relation.generation() != generation {
3042 return Err(
3043 projectatlas_core::graph::GraphContractError::GenerationMismatch {
3044 context: "relation occurrence query",
3045 }
3046 .into(),
3047 );
3048 }
3049 if !verify_project_identity(&self.connection, relation.key().project())? {
3050 return Ok(empty_page());
3051 }
3052 let raw = {
3053 let mut statement = self.connection.prepare_cached(
3054 "SELECT relation_key, file_path, start_line, start_column,
3055 end_line, end_column
3056 FROM graph_relation_occurrences
3057 WHERE relation_key = ?1
3058 ORDER BY file_path, start_line, start_column, end_line, end_column
3059 LIMIT ?2",
3060 )?;
3061 let mut rows =
3062 statement.query(params![&relation.key().digest_bytes()?[..], limit_plus_one])?;
3063 let mut collected = Vec::new();
3064 while let Some(row) = rows.next()? {
3065 collected.push(occurrence_row(row)?);
3066 }
3067 collected
3068 };
3069 page_from_raw(raw, limit, |row| {
3070 occurrence_from_row(row, relation, generation)
3071 })
3072 }
3073
3074 pub fn repository_graph_occurrence_pages(
3086 &self,
3087 relations: &[LogicalRelation],
3088 limit: u32,
3089 control: Option<&IndexWorkControl>,
3090 ) -> DbResult<Vec<RepositoryGraphPage<RelationOccurrence>>> {
3091 Ok(self
3092 .repository_graph_occurrence_pages_bounded(
3093 relations,
3094 limit,
3095 maximum_repository_graph_read_budget()?,
3096 control,
3097 )?
3098 .pages)
3099 }
3100
3101 pub fn repository_graph_occurrence_pages_bounded(
3110 &self,
3111 relations: &[LogicalRelation],
3112 limit: u32,
3113 budget: RepositoryGraphReadBudget,
3114 control: Option<&IndexWorkControl>,
3115 ) -> DbResult<RepositoryGraphReadPages<RelationOccurrence>> {
3116 let limit_plus_one = validated_limit_plus_one(
3117 limit,
3118 GraphLimits::MAX_OCCURRENCES,
3119 "graph occurrences must be nonzero and within the product ceiling",
3120 )?;
3121 if relations.len() > MAX_REPOSITORY_GRAPH_FRONTIER {
3122 return Err(GraphContractError::InvalidLimits {
3123 reason: "graph occurrence batch exceeds the product ceiling",
3124 }
3125 .into());
3126 }
3127 let mut meter = RepositoryGraphReadMeter::new(budget, relations.len())?;
3128 if relations.is_empty() {
3129 return Ok(RepositoryGraphReadPages {
3130 pages: Vec::new(),
3131 work: meter.finish(0)?,
3132 });
3133 }
3134 let work_rows = relations.len().checked_mul(limit_plus_one as usize).ok_or(
3135 GraphContractError::InvalidLimits {
3136 reason: "graph occurrence batch work overflowed",
3137 },
3138 )?;
3139 if work_rows > MAX_REPOSITORY_GRAPH_ADJACENCY_WORK_ROWS {
3140 return Err(GraphContractError::InvalidLimits {
3141 reason: "graph occurrence batch exceeds the intermediate row ceiling",
3142 }
3143 .into());
3144 }
3145 let Some(generation) = self.repository_graph_generation()? else {
3146 return Ok(RepositoryGraphReadPages {
3147 pages: (0..relations.len()).map(|_| empty_page()).collect(),
3148 work: meter.finish(0)?,
3149 });
3150 };
3151 let project = relations[0].key().project();
3152 if !verify_project_identity(&self.connection, project)? {
3153 return Ok(RepositoryGraphReadPages {
3154 pages: (0..relations.len()).map(|_| empty_page()).collect(),
3155 work: meter.finish(0)?,
3156 });
3157 }
3158 let mut bindings = Vec::with_capacity(relations.len() * 2);
3159 for relation in relations {
3160 require_project(project, relation.key().project())?;
3161 if relation.generation() != generation {
3162 return Err(GraphContractError::GenerationMismatch {
3163 context: "relation occurrence batch query",
3164 }
3165 .into());
3166 }
3167 bindings.push(Value::Blob(relation.key().digest_bytes()?.to_vec()));
3168 bindings.push(Value::Integer(limit_plus_one));
3169 }
3170 let sql = occurrence_pages_sql(relations.len());
3171 let raw = with_sqlite_read_progress(
3172 &self.connection,
3173 control,
3174 IndexWorkStage::RepositoryTraversal,
3175 || {
3176 let mut statement = self.connection.prepare(&sql)?;
3177 let mut queried = statement.query(params_from_iter(bindings.iter()))?;
3178 let mut grouped = (0..relations.len())
3179 .map(|_| Vec::new())
3180 .collect::<Vec<Vec<OccurrenceRow>>>();
3181 while let Some(row) = queried.next()? {
3182 let index_value = row.get::<_, i64>(0)?;
3183 let index =
3184 usize::try_from(index_value).map_err(|source| DbError::InvalidCount {
3185 field: "graph_relation_occurrences.relation_index",
3186 value: index_value,
3187 source,
3188 })?;
3189 let group = grouped.get_mut(index).ok_or(DbError::GraphRowShape {
3190 table: "graph_relation_occurrences",
3191 reason: "occurrence batch returned an invalid relation position",
3192 })?;
3193 let occurrence = occurrence_row_at(row, 1)?;
3194 meter.record_decoded_bytes(
3195 occurrence_row_decoded_bytes(&occurrence)?
3196 .checked_add(8)
3197 .ok_or(GraphContractError::InvalidLimits {
3198 reason: "graph occurrence batch decoded row size overflowed",
3199 })?,
3200 )?;
3201 group.push(occurrence);
3202 }
3203 Ok(grouped)
3204 },
3205 )?;
3206 let pages = raw
3207 .into_iter()
3208 .zip(relations)
3209 .map(|(rows, relation)| {
3210 page_from_raw(rows, limit, |row| {
3211 let occurrence = occurrence_from_row(row, relation, generation)?;
3212 meter.record_hydrated_path(occurrence.file().as_str())?;
3213 Ok(occurrence)
3214 })
3215 })
3216 .collect::<DbResult<Vec<_>>>()?;
3217 let returned_rows = pages.iter().try_fold(0_usize, |count, page| {
3218 count
3219 .checked_add(page.rows.len())
3220 .ok_or(GraphContractError::InvalidLimits {
3221 reason: "graph occurrence returned-row accounting overflowed",
3222 })
3223 })?;
3224 let work = meter.finish(returned_rows)?;
3225 Ok(RepositoryGraphReadPages { pages, work })
3226 }
3227
3228 pub fn repository_graph_coverage(
3235 &self,
3236 project: ProjectInstanceId,
3237 scope: &CoverageScope,
3238 limit: u32,
3239 ) -> DbResult<RepositoryGraphPage<CoverageRecord>> {
3240 let limit_plus_one = validated_limit_plus_one(
3241 limit,
3242 GraphLimits::MAX_ROWS,
3243 "graph rows must be nonzero and within the product ceiling",
3244 )?;
3245 let Some(generation) = self.repository_graph_generation()? else {
3246 return Ok(empty_page());
3247 };
3248 if !verify_project_identity(&self.connection, project)? {
3249 return Ok(empty_page());
3250 }
3251 let (scope_kind, scope_path) = coverage_scope_parts(scope);
3252 let raw = {
3253 let mut statement = self.connection.prepare_cached(
3254 "SELECT project_instance_id, scope_kind, scope_path, relation_scope,
3255 relation_kind, state, total, covered, omitted, reason, reached_limit,
3256 NULL, NULL
3257 FROM graph_coverage
3258 WHERE project_instance_id = ?1
3259 AND scope_kind = ?2 AND scope_path IS ?3
3260 ORDER BY relation_scope, relation_kind, state, id
3261 LIMIT ?4",
3262 )?;
3263 let mut rows = statement.query(params![
3264 &project.as_bytes()[..],
3265 scope_kind,
3266 scope_path,
3267 limit_plus_one
3268 ])?;
3269 let mut collected = Vec::new();
3270 while let Some(row) = rows.next()? {
3271 collected.push(coverage_row(row)?);
3272 }
3273 collected
3274 };
3275 page_from_raw(raw, limit, |row| {
3276 coverage_from_row(row, project, generation)
3277 })
3278 }
3279
3280 pub fn repository_graph_path_coverage(
3291 &self,
3292 project: ProjectInstanceId,
3293 paths: &[RepositoryNodePath],
3294 control: Option<&IndexWorkControl>,
3295 ) -> DbResult<RepositoryGraphPage<CoverageRecord>> {
3296 validate_path_coverage_request(paths)?;
3297 if paths.is_empty() {
3298 return Ok(empty_page());
3299 }
3300 let Some(generation) = self.repository_graph_generation()? else {
3301 return Ok(empty_page());
3302 };
3303 if !verify_project_identity(&self.connection, project)? {
3304 return Ok(empty_page());
3305 }
3306 Ok(self
3307 .repository_graph_path_coverage_bounded(
3308 project,
3309 generation,
3310 paths,
3311 maximum_repository_graph_read_budget()?,
3312 control,
3313 )?
3314 .page)
3315 }
3316
3317 pub fn repository_graph_path_coverage_bounded(
3327 &self,
3328 project: ProjectInstanceId,
3329 generation: IndexGeneration,
3330 paths: &[RepositoryNodePath],
3331 budget: RepositoryGraphReadBudget,
3332 control: Option<&IndexWorkControl>,
3333 ) -> DbResult<RepositoryGraphReadPage<CoverageRecord>> {
3334 validate_path_coverage_request(paths)?;
3335 self.require_repository_graph_snapshot(project, generation)?;
3336 let mut meter = RepositoryGraphReadMeter::new(budget, paths.len())?;
3337 if paths.is_empty() {
3338 return Ok(RepositoryGraphReadPage {
3339 page: empty_page(),
3340 work: meter.finish(0)?,
3341 });
3342 }
3343
3344 let sql = path_coverage_sql(paths.len());
3345 let mut bindings = Vec::with_capacity(paths.len() + 3);
3346 bindings.push(Value::Blob(project.as_bytes().to_vec()));
3347 bindings.push(Value::Text("path".to_string()));
3348 bindings.extend(
3349 paths
3350 .iter()
3351 .map(|path| Value::Text(path.as_str().to_string())),
3352 );
3353 bindings.push(Value::Integer(i64::from(GraphLimits::MAX_ROWS) + 1));
3354 let raw = with_sqlite_read_progress(
3355 &self.connection,
3356 control,
3357 IndexWorkStage::RepositoryTraversal,
3358 || {
3359 let mut statement = self.connection.prepare(&sql)?;
3360 let mut rows = statement.query(params_from_iter(bindings.iter()))?;
3361 let mut collected = Vec::new();
3362 while let Some(row) = rows.next()? {
3363 let coverage = coverage_row(row)?;
3364 meter.record_decoded_bytes(coverage_row_decoded_bytes(&coverage)?)?;
3365 collected.push(coverage);
3366 }
3367 Ok(collected)
3368 },
3369 )?;
3370 let page = page_from_raw(raw, GraphLimits::MAX_ROWS, |row| {
3371 let coverage = coverage_from_row(row, project, generation)?;
3372 if let CoverageScope::Path { path } = coverage.scope() {
3373 meter.record_hydrated_path(path.as_str())?;
3374 }
3375 Ok(coverage)
3376 })?;
3377 let work = meter.finish(page.rows.len())?;
3378 Ok(RepositoryGraphReadPage { page, work })
3379 }
3380
3381 pub fn repository_coverage_page(
3389 &self,
3390 project: ProjectInstanceId,
3391 query: &RepositoryCoverageQuery,
3392 ) -> DbResult<RepositoryGraphPage<RepositoryCoverageRow>> {
3393 self.repository_coverage_page_controlled(project, query, None)
3394 }
3395
3396 pub fn repository_coverage_page_controlled(
3403 &self,
3404 project: ProjectInstanceId,
3405 query: &RepositoryCoverageQuery,
3406 control: Option<&IndexWorkControl>,
3407 ) -> DbResult<RepositoryGraphPage<RepositoryCoverageRow>> {
3408 let limit_plus_one = validated_limit_plus_one(
3409 query.limit,
3410 GraphLimits::MAX_ROWS,
3411 "coverage rows must be nonzero and within the product ceiling",
3412 )?;
3413 if query.start_index >= GraphLimits::MAX_ROWS {
3414 return Err(GraphContractError::InvalidLimits {
3415 reason: "coverage start index is at or above the product ceiling",
3416 }
3417 .into());
3418 }
3419 let Some(generation) = self.repository_graph_generation()? else {
3420 return Ok(empty_page());
3421 };
3422 if !verify_project_identity(&self.connection, project)? {
3423 return Ok(empty_page());
3424 }
3425
3426 let provenance_driven = query.parser.is_some() || query.provider.is_some();
3427 let path_prefix = query.path_prefix.as_deref().filter(|prefix| *prefix != ".");
3428 let mut sql = String::from(
3429 "SELECT coverage.project_instance_id, coverage.scope_kind,
3430 coverage.scope_path, coverage.relation_scope,
3431 coverage.relation_kind, coverage.state, coverage.total,
3432 coverage.covered, coverage.omitted, coverage.reason,
3433 coverage.reached_limit, metadata.source_parser,
3434 metadata.fact_parser
3435 FROM ",
3436 );
3437 if provenance_driven {
3438 sql.push_str(
3439 "source_parse_metadata AS metadata
3440 CROSS JOIN graph_coverage AS coverage
3441 ON coverage.scope_kind = 'path'
3442 AND coverage.scope_path = metadata.path",
3443 );
3444 } else {
3445 sql.push_str(
3446 "graph_coverage AS coverage
3447 LEFT JOIN source_parse_metadata AS metadata
3448 ON metadata.path = coverage.scope_path",
3449 );
3450 }
3451 sql.push_str(" WHERE coverage.project_instance_id = ?");
3452 let mut values = vec![Value::Blob(project.as_bytes().to_vec())];
3453
3454 if let Some(prefix) = path_prefix {
3455 sql.push_str(
3456 " AND coverage.scope_kind = 'path'
3457 AND coverage.scope_path >= ? AND coverage.scope_path < ?
3458 AND (coverage.scope_path = ? OR coverage.scope_path >= ?)",
3459 );
3460 values.push(Value::Text(prefix.to_string()));
3461 values.push(Value::Text(format!("{prefix}0")));
3462 values.push(Value::Text(prefix.to_string()));
3463 values.push(Value::Text(format!("{prefix}/")));
3464 }
3465 if let Some(parser) = query.parser {
3466 sql.push_str(" AND metadata.source_parser = ?");
3467 values.push(Value::Text(parser.to_string()));
3468 }
3469 if let Some(provider) = query.provider {
3470 sql.push_str(" AND metadata.fact_parser = ?");
3471 values.push(Value::Text(provider.to_string()));
3472 }
3473 if let Some(relation) = query.relation {
3474 let (scope, kind) = relation_parts(relation);
3475 sql.push_str(" AND coverage.relation_scope = ? AND coverage.relation_kind = ?");
3476 values.push(Value::Text(scope.to_string()));
3477 values.push(Value::Text(kind.to_string()));
3478 }
3479 if let Some(state) = query.state {
3480 match state {
3481 CoverageState::NoCandidates => {
3482 sql.push_str(
3483 " AND coverage.state = 'complete' AND coverage.total = 0 AND coverage.relation_scope = 'extended' AND coverage.relation_kind = 'documents'",
3484 );
3485 }
3486 CoverageState::Complete => {
3487 sql.push_str(
3488 " AND coverage.state = 'complete' AND NOT (coverage.total = 0 AND coverage.relation_scope IS 'extended' AND coverage.relation_kind IS 'documents')",
3489 );
3490 }
3491 _ => {
3492 sql.push_str(" AND coverage.state = ?");
3493 values.push(Value::Text(coverage_state_name(state).to_string()));
3494 }
3495 }
3496 }
3497 if let Some(reason) = query.reason.as_deref() {
3498 sql.push_str(" AND coverage.reason = ?");
3499 values.push(Value::Text(reason.to_string()));
3500 }
3501
3502 if matches!(query.state, Some(CoverageState::NoCandidates)) {
3503 sql.push_str(
3504 " ORDER BY coverage.relation_scope, coverage.relation_kind,
3505 coverage.state, coverage.id",
3506 );
3507 } else if provenance_driven {
3508 sql.push_str(" ORDER BY metadata.path, coverage.id");
3509 } else if path_prefix.is_some() {
3510 sql.push_str(
3511 " ORDER BY coverage.scope_path, coverage.relation_scope,
3512 coverage.relation_kind, coverage.state, coverage.id",
3513 );
3514 } else if query.relation.is_some() {
3515 sql.push_str(
3516 " ORDER BY coverage.relation_scope, coverage.relation_kind,
3517 coverage.state, coverage.id",
3518 );
3519 } else if query.state.is_some() {
3520 sql.push_str(" ORDER BY coverage.state, coverage.scope_path, coverage.id");
3521 } else if query.reason.is_some() {
3522 sql.push_str(" ORDER BY coverage.reason, coverage.scope_path, coverage.id");
3523 } else {
3524 sql.push_str(
3525 " ORDER BY coverage.scope_kind, coverage.scope_path,
3526 coverage.relation_scope, coverage.relation_kind,
3527 coverage.state, coverage.id",
3528 );
3529 }
3530 sql.push_str(" LIMIT ? OFFSET ?");
3531 values.push(Value::Integer(limit_plus_one));
3532 values.push(Value::Integer(i64::from(query.start_index)));
3533
3534 let raw = with_sqlite_read_progress(
3535 &self.connection,
3536 control,
3537 IndexWorkStage::RepositoryTraversal,
3538 || {
3539 let mut statement = self.connection.prepare_cached(&sql)?;
3540 let mut rows = statement.query(params_from_iter(values.iter()))?;
3541 let mut collected = Vec::new();
3542 while let Some(row) = rows.next()? {
3543 collected.push(coverage_row(row)?);
3544 }
3545 Ok(collected)
3546 },
3547 )?;
3548 page_from_raw(raw, query.limit, |row| {
3549 coverage_discovery_from_row(row, project, generation)
3550 })
3551 }
3552
3553 pub fn repository_graph_identity_rejections(
3561 &self,
3562 project: ProjectInstanceId,
3563 paths: &[RepositoryNodePath],
3564 limit: u32,
3565 control: Option<&IndexWorkControl>,
3566 ) -> DbResult<Vec<GraphIdentityRejection>> {
3567 if limit == 0 || limit > GraphLimits::MAX_ROWS {
3568 return Err(GraphContractError::InvalidLimits {
3569 reason: "identity rejection limit must be nonzero and within the product ceiling",
3570 }
3571 .into());
3572 }
3573 let Some(generation) = self.repository_graph_generation()? else {
3574 return Ok(Vec::new());
3575 };
3576 if !verify_project_identity(&self.connection, project)? {
3577 return Ok(Vec::new());
3578 }
3579 if paths.is_empty() {
3580 return Ok(Vec::new());
3581 }
3582 if paths.len() > GraphLimits::MAX_ROWS as usize {
3583 return Err(GraphContractError::InvalidLimits {
3584 reason: "identity rejection path set exceeds the product ceiling",
3585 }
3586 .into());
3587 }
3588 let placeholders = numbered_placeholders(4, paths.len());
3589 let sql = format!(
3590 "SELECT file_path, start_line, start_column, end_line, end_column,
3591 parser, field, reason, fact_index
3592 FROM graph_identity_rejections
3593 WHERE project_instance_id = ?1 AND generation = ?2
3594 AND file_path IN ({placeholders})
3595 ORDER BY file_path, start_line, start_column, end_line, end_column,
3596 parser, field, reason, fact_index, id
3597 LIMIT ?3"
3598 );
3599 let mut values = Vec::with_capacity(paths.len() + 3);
3600 values.push(Value::Blob(project.as_bytes().to_vec()));
3601 values.push(Value::Integer(i64::try_from(generation.get()).map_err(
3602 |_error| GraphContractError::InvalidLimits {
3603 reason: "identity rejection generation exceeded SQLite integer range",
3604 },
3605 )?));
3606 values.push(Value::Integer(i64::from(limit)));
3607 values.extend(
3608 paths
3609 .iter()
3610 .map(|path| Value::Text(path.as_str().to_string())),
3611 );
3612 with_sqlite_read_progress(
3613 &self.connection,
3614 control,
3615 IndexWorkStage::RepositoryTraversal,
3616 || {
3617 let mut statement = self.connection.prepare_cached(&sql)?;
3618 let mut rows = statement.query(params_from_iter(values.iter()))?;
3619 let mut rejections = Vec::new();
3620 while let Some(row) = rows.next()? {
3621 rejections.push(graph_identity_rejection_from_row(row)?);
3622 }
3623 Ok(rejections)
3624 },
3625 )
3626 }
3627
3628 pub fn repository_graph_generation(&self) -> DbResult<Option<IndexGeneration>> {
3635 let Some(publication) = self.index_publication()? else {
3636 return Ok(None);
3637 };
3638 if publication.state != IndexPublicationState::Complete
3639 || publication.generation == IndexGeneration::ZERO
3640 {
3641 return Err(DbError::GraphPublicationUnavailable);
3642 }
3643 let Some(graph_generation) = load_graph_generation(&self.connection)? else {
3644 return Ok(None);
3645 };
3646 if graph_generation == IndexGeneration::ZERO || graph_generation != publication.generation {
3647 return Err(DbError::GraphRowShape {
3648 table: "project_identity",
3649 reason: "typed graph generation does not match complete publication",
3650 });
3651 }
3652 Ok(Some(graph_generation))
3653 }
3654
3655 pub(crate) fn require_repository_graph_snapshot(
3657 &self,
3658 project: ProjectInstanceId,
3659 generation: IndexGeneration,
3660 ) -> DbResult<()> {
3661 require_bound_project_identity(&self.connection, project)?;
3662 let current = self
3663 .repository_graph_generation()?
3664 .ok_or(DbError::GraphPublicationUnavailable)?;
3665 if current != generation {
3666 return Err(GraphContractError::InvalidLimits {
3667 reason: "graph hydration generation does not match the current publication",
3668 }
3669 .into());
3670 }
3671 Ok(())
3672 }
3673
3674 fn collect_relation_rows_by_key(
3676 &self,
3677 key_column: &'static str,
3678 key: &[u8; 32],
3679 limit_plus_one: i64,
3680 ) -> DbResult<Vec<RelationRow>> {
3681 let sql = match key_column {
3682 "source_entity_key" => {
3683 "SELECT relation_key, project_instance_id, canonical_identity,
3684 source_entity_key, relation_scope, relation_kind,
3685 resolution_status, target_entity_key, reference_text,
3686 candidate_count, document_unresolved_reason, confidence, completeness
3687 FROM graph_relations
3688 WHERE source_entity_key = ?1
3689 ORDER BY relation_scope, relation_kind, relation_key
3690 LIMIT ?2"
3691 }
3692 "target_entity_key" => {
3693 "SELECT relation_key, project_instance_id, canonical_identity,
3694 source_entity_key, relation_scope, relation_kind,
3695 resolution_status, target_entity_key, reference_text,
3696 candidate_count, document_unresolved_reason, confidence, completeness
3697 FROM graph_relations
3698 WHERE target_entity_key = ?1
3699 ORDER BY relation_scope, relation_kind, relation_key
3700 LIMIT ?2"
3701 }
3702 _ => {
3703 return Err(DbError::GraphRowShape {
3704 table: "graph_relations",
3705 reason: "unsupported internal relation lookup",
3706 });
3707 }
3708 };
3709 let mut statement = self.connection.prepare_cached(sql)?;
3710 collect_relation_rows(statement.query(params![&key[..], limit_plus_one])?)
3711 }
3712}
3713
3714pub(crate) fn capture_derived_graph(
3716 connection: &Connection,
3717 project: ProjectInstanceId,
3718 generation: IndexGeneration,
3719 budget: &mut SnapshotBudget,
3720) -> DbResult<CapturedGraph> {
3721 let mut entities = Vec::new();
3722 let mut entities_by_key = HashMap::new();
3723 {
3724 let mut statement = connection.prepare(
3725 "SELECT entity_key, project_instance_id, canonical_identity, entity_kind,
3726 repository_path, package_manager, package_name, manifest_path,
3727 symbol_name, symbol_kind, symbol_parent, symbol_signature,
3728 external_system, external_identity
3729 FROM graph_entities
3730 WHERE project_instance_id = ?1
3731 ORDER BY entity_key",
3732 )?;
3733 let mut rows = statement.query(params![&project.as_bytes()[..]])?;
3734 while let Some(row) = rows.next()? {
3735 let raw = entity_row(row)?;
3736 budget.admit(entity_row_decoded_bytes(&raw)?)?;
3737 let entity = entity_from_row(raw, project, generation)?;
3738 let key = entity.key().digest_bytes()?;
3739 if entities_by_key.insert(key, entity.clone()).is_some() {
3740 return Err(DbError::DerivedSnapshotInvalid {
3741 reason: "private capture contains a duplicate entity",
3742 });
3743 }
3744 entities.push(entity);
3745 }
3746 }
3747
3748 let mut relations = Vec::new();
3749 let mut relations_by_key = HashMap::new();
3750 let mut document_unresolved_reasons = Vec::new();
3751 {
3752 let mut statement = connection.prepare(
3753 "SELECT relation_key, project_instance_id, canonical_identity,
3754 source_entity_key, relation_scope, relation_kind,
3755 resolution_status, target_entity_key, reference_text,
3756 candidate_count, document_unresolved_reason, confidence, completeness
3757 FROM graph_relations
3758 WHERE project_instance_id = ?1
3759 ORDER BY relation_key",
3760 )?;
3761 let mut rows = statement.query(params![&project.as_bytes()[..]])?;
3762 while let Some(row) = rows.next()? {
3763 let raw = relation_row(row)?;
3764 budget.admit(relation_row_decoded_bytes(&raw)?)?;
3765 let document_unresolved_reason = document_unresolved_reason_from_row(&raw)?;
3766 let relation = relation_from_row(&entities_by_key, raw, project, generation)?;
3767 let key = relation.key().digest_bytes()?;
3768 if relations_by_key.insert(key, relation.clone()).is_some() {
3769 return Err(DbError::DerivedSnapshotInvalid {
3770 reason: "private capture contains a duplicate relation",
3771 });
3772 }
3773 if let Some(reason) = document_unresolved_reason {
3774 document_unresolved_reasons.push((key, reason));
3775 }
3776 relations.push(relation);
3777 }
3778 }
3779
3780 let mut occurrences = Vec::new();
3781 {
3782 let mut statement = connection.prepare(
3783 "SELECT relation_key, file_path, start_line, start_column, end_line, end_column
3784 FROM graph_relation_occurrences
3785 ORDER BY relation_key, file_path, start_line, start_column, end_line, end_column",
3786 )?;
3787 let mut rows = statement.query([])?;
3788 while let Some(row) = rows.next()? {
3789 let raw = occurrence_row(row)?;
3790 budget.admit(occurrence_row_decoded_bytes(&raw)?)?;
3791 let key = fixed_bytes::<32>(
3792 "graph_relation_occurrences.relation_key",
3793 raw.relation.clone(),
3794 )?;
3795 let relation = relations_by_key
3796 .get(&key)
3797 .ok_or(DbError::DerivedSnapshotInvalid {
3798 reason: "private capture occurrence owner is absent",
3799 })?;
3800 occurrences.push(occurrence_from_row(raw, relation, generation)?);
3801 }
3802 }
3803
3804 let mut coverage = Vec::new();
3805 {
3806 let mut statement = connection.prepare(
3807 "SELECT project_instance_id, scope_kind, scope_path, relation_scope,
3808 relation_kind, state, total, covered, omitted, reason,
3809 reached_limit, NULL, NULL
3810 FROM graph_coverage
3811 WHERE project_instance_id = ?1
3812 ORDER BY scope_kind, scope_path, relation_scope, relation_kind, state, id",
3813 )?;
3814 let mut rows = statement.query(params![&project.as_bytes()[..]])?;
3815 while let Some(row) = rows.next()? {
3816 let raw = coverage_row(row)?;
3817 budget.admit(coverage_row_decoded_bytes(&raw)?)?;
3818 coverage.push(coverage_from_row(raw, project, generation)?);
3819 }
3820 }
3821
3822 let mut entity_exports = Vec::new();
3823 {
3824 let mut statement = connection.prepare(
3825 "SELECT key.project_instance_id, key.resolution_domain, key.key_digest,
3826 key.canonical_identity, export.entity_key
3827 FROM graph_entity_exports AS export
3828 JOIN graph_resolution_keys AS key
3829 ON key.project_instance_id = export.project_instance_id
3830 AND key.resolution_domain = export.resolution_domain
3831 AND key.key_digest = export.key_digest
3832 WHERE export.project_instance_id = ?1
3833 ORDER BY export.entity_key, key.resolution_domain, key.canonical_identity",
3834 )?;
3835 let mut rows = statement.query(params![&project.as_bytes()[..]])?;
3836 while let Some(row) = rows.next()? {
3837 let key = resolution_key_from_row(row, project)?;
3838 let owner =
3839 fixed_bytes::<32>("graph_entity_exports.entity_key", row.get::<_, Vec<u8>>(4)?)?;
3840 budget.admit(decoded_payload_bytes(
3841 [
3842 key.canonical_identity().len(),
3843 key.digest_bytes().len(),
3844 owner.len(),
3845 ],
3846 16,
3847 )?)?;
3848 entity_exports.push((owner, key));
3849 }
3850 }
3851
3852 let mut relation_dependencies = Vec::new();
3853 {
3854 let mut statement = connection.prepare(
3855 "SELECT key.project_instance_id, key.resolution_domain, key.key_digest,
3856 key.canonical_identity, dependency.relation_key
3857 FROM graph_relation_dependencies AS dependency
3858 JOIN graph_resolution_keys AS key
3859 ON key.project_instance_id = dependency.project_instance_id
3860 AND key.resolution_domain = dependency.resolution_domain
3861 AND key.key_digest = dependency.key_digest
3862 WHERE dependency.project_instance_id = ?1
3863 ORDER BY dependency.relation_key, key.resolution_domain, key.canonical_identity",
3864 )?;
3865 let mut rows = statement.query(params![&project.as_bytes()[..]])?;
3866 while let Some(row) = rows.next()? {
3867 let key = resolution_key_from_row(row, project)?;
3868 let owner = fixed_bytes::<32>(
3869 "graph_relation_dependencies.relation_key",
3870 row.get::<_, Vec<u8>>(4)?,
3871 )?;
3872 budget.admit(decoded_payload_bytes(
3873 [
3874 key.canonical_identity().len(),
3875 key.digest_bytes().len(),
3876 owner.len(),
3877 ],
3878 16,
3879 )?)?;
3880 relation_dependencies.push((owner, key));
3881 }
3882 }
3883
3884 Ok(CapturedGraph {
3885 file_classifications: Vec::new(),
3886 entities,
3887 relations,
3888 document_unresolved_reasons,
3889 occurrences,
3890 coverage,
3891 entity_exports,
3892 relation_dependencies,
3893 })
3894}
3895
3896impl AtlasStore {
3897 pub fn repository_graph_staging_belongs_to(
3907 path: &Path,
3908 root: &Path,
3909 project: ProjectInstanceId,
3910 ) -> DbResult<bool> {
3911 let connection = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
3912 let marker = connection
3913 .query_row(
3914 "SELECT value FROM metadata WHERE key = ?1",
3915 [GRAPH_STAGING_MARKER_KEY],
3916 |row| row.get::<_, String>(0),
3917 )
3918 .optional()?;
3919 let schema_version = connection
3920 .query_row(
3921 "SELECT value FROM metadata WHERE key = ?1",
3922 [schema::SCHEMA_VERSION_KEY],
3923 |row| row.get::<_, String>(0),
3924 )
3925 .optional()?
3926 .and_then(|value| value.parse::<i64>().ok());
3927 let Some(schema_version) = schema_version else {
3928 return Ok(false);
3929 };
3930 let expected_root = projectatlas_core::CanonicalProjectRoot::from_path(root)?;
3931 let roots_match = match schema_version {
3932 schema::SCHEMA_VERSION => {
3933 load_project_root_identity(&connection)?.is_some_and(|stored_root| {
3934 prove_existing_root_equivalence(expected_root.as_path(), stored_root.as_path())
3935 .is_ok()
3936 })
3937 }
3938 schema::CANONICAL_ROOT_PREDECESSOR_SCHEMA_VERSION => true,
3943 _ => false,
3944 };
3945 Ok(roots_match
3946 && load_project_identity(&connection)? == Some(project)
3947 && marker.as_deref() == Some(GRAPH_STAGING_MARKER_VALUE))
3948 }
3949
3950 pub fn create_repository_graph_staging(
3957 path: &Path,
3958 root: &Path,
3959 project: ProjectInstanceId,
3960 ) -> DbResult<Self> {
3961 if path.exists() {
3962 return Err(DbError::GraphRowShape {
3963 table: "repository graph staging database",
3964 reason: "staging path already exists",
3965 });
3966 }
3967 let mut store = Self::open_for_project(path, root)?;
3968 set_project_identity(&store.connection, project)?;
3969 store.connection.execute(
3970 "INSERT INTO metadata(key, value) VALUES (?1, ?2)
3971 ON CONFLICT(key) DO UPDATE SET value = excluded.value",
3972 params![GRAPH_STAGING_MARKER_KEY, GRAPH_STAGING_MARKER_VALUE],
3973 )?;
3974 drop_graph_rebuildable_indexes(&store.connection)?;
3975 store.checkpoint_repository_graph_staging()?;
3976 store.validated_project_instance_id = Some(project);
3977 Ok(store)
3978 }
3979
3980 pub fn checkpoint_repository_graph_staging(&self) -> DbResult<()> {
3987 let (busy, _log_frames, _checkpointed_frames) =
3988 self.connection
3989 .query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |row| {
3990 Ok((
3991 row.get::<_, i64>(0)?,
3992 row.get::<_, i64>(1)?,
3993 row.get::<_, i64>(2)?,
3994 ))
3995 })?;
3996 if busy != 0 {
3997 return Err(DbError::GraphRowShape {
3998 table: "repository graph staging database",
3999 reason: "WAL checkpoint remained busy",
4000 });
4001 }
4002 Ok(())
4003 }
4004
4005 pub fn begin_repository_graph_staging(
4012 &mut self,
4013 project: ProjectInstanceId,
4014 generation: IndexGeneration,
4015 ) -> DbResult<RepositoryGraphStagingGuard<'_>> {
4016 if generation == IndexGeneration::ZERO {
4017 return Err(GraphContractError::InvalidGeneration.into());
4018 }
4019 let transaction = self.connection.transaction()?;
4020 require_bound_project_identity(&transaction, project)?;
4021 transaction.execute("DELETE FROM graph_resolution_keys", [])?;
4022 transaction.execute("DELETE FROM graph_coverage", [])?;
4023 transaction.execute("DELETE FROM graph_identity_rejections", [])?;
4024 transaction.execute("DELETE FROM graph_relations", [])?;
4025 transaction.execute("DELETE FROM graph_entities", [])?;
4026 Ok(RepositoryGraphStagingGuard {
4027 transaction,
4028 project,
4029 generation,
4030 })
4031 }
4032}
4033
4034impl RepositoryGraphStagingGuard<'_> {
4035 pub fn append_entity_refs(&mut self, entities: &[&GraphEntity]) -> DbResult<()> {
4042 if entities.iter().any(|entity| {
4043 entity.key().project() != self.project || entity.generation() != self.generation
4044 }) {
4045 return Err(GraphContractError::GenerationMismatch {
4046 context: "repository graph staging entities",
4047 }
4048 .into());
4049 }
4050 insert_entities(&self.transaction, self.project, entities.iter().copied())
4051 }
4052
4053 #[allow(clippy::too_many_arguments)]
4060 pub fn append_batch(
4061 &mut self,
4062 entities: &[GraphEntity],
4063 relations: &[LogicalRelation],
4064 occurrences: &[RelationOccurrence],
4065 coverage: &[CoverageRecord],
4066 entity_exports: &[EntityResolutionKey],
4067 relation_dependencies: &[RelationDependencyKey],
4068 ) -> DbResult<()> {
4069 validate_graph_batch(
4070 self.project,
4071 self.generation,
4072 entities,
4073 relations,
4074 occurrences,
4075 coverage,
4076 )?;
4077 validate_resolution_key_batch(self.project, entity_exports, relation_dependencies)?;
4078 insert_graph_batch(
4079 &self.transaction,
4080 self.project,
4081 entities,
4082 relations,
4083 occurrences,
4084 coverage,
4085 )?;
4086 insert_resolution_key_batch(
4087 &self.transaction,
4088 self.project,
4089 entity_exports,
4090 relation_dependencies,
4091 )
4092 }
4093
4094 pub fn set_document_unresolved_reasons(
4101 &mut self,
4102 reasons: &[(LogicalRelationKey, DocumentTargetUnresolvedReason)],
4103 ) -> DbResult<()> {
4104 self.set_document_unresolved_reasons_with_control(reasons, None)
4105 }
4106
4107 pub fn set_document_unresolved_reasons_controlled(
4114 &mut self,
4115 reasons: &[(LogicalRelationKey, DocumentTargetUnresolvedReason)],
4116 control: &IndexWorkControl,
4117 ) -> DbResult<()> {
4118 self.set_document_unresolved_reasons_with_control(reasons, Some(control))
4119 }
4120
4121 fn set_document_unresolved_reasons_with_control(
4123 &mut self,
4124 reasons: &[(LogicalRelationKey, DocumentTargetUnresolvedReason)],
4125 control: Option<&IndexWorkControl>,
4126 ) -> DbResult<()> {
4127 let savepoint = self.transaction.savepoint()?;
4128 write_document_unresolved_reasons(&savepoint, reasons, control)?;
4129 savepoint.commit()?;
4130 Ok(())
4131 }
4132
4133 pub fn complete(self) -> DbResult<()> {
4139 validate_complete_document_unresolved_reasons(&self.transaction)?;
4140 set_graph_generation(&self.transaction, self.generation)?;
4141 self.transaction.commit()?;
4142 Ok(())
4143 }
4144}
4145
4146const GRAPH_STAGE_COPY_TABLES: &[(&str, &str)] = &[
4148 (
4149 "graph_entities",
4150 "entity_key, project_instance_id, canonical_identity, entity_kind, repository_path, \
4151 package_manager, package_name, manifest_path, symbol_name, symbol_kind, symbol_parent, \
4152 symbol_signature, external_system, external_identity",
4153 ),
4154 (
4155 "graph_relations",
4156 "relation_key, project_instance_id, canonical_identity, source_entity_key, \
4157 relation_scope, relation_kind, resolution_status, target_entity_key, reference_text, \
4158 candidate_count, document_unresolved_reason, confidence, completeness",
4159 ),
4160 (
4161 "graph_relation_occurrences",
4162 "relation_key, file_path, start_line, start_column, end_line, end_column",
4163 ),
4164 (
4165 "graph_coverage",
4166 "project_instance_id, scope_kind, scope_path, relation_scope, relation_kind, state, \
4167 total, covered, omitted, reason, reached_limit",
4168 ),
4169 (
4170 "graph_resolution_keys",
4171 "project_instance_id, resolution_domain, key_digest, canonical_identity",
4172 ),
4173 (
4174 "graph_entity_exports",
4175 "project_instance_id, entity_key, owner_path, resolution_domain, key_digest",
4176 ),
4177 (
4178 "graph_relation_dependencies",
4179 "project_instance_id, relation_key, owner_path, resolution_domain, key_digest",
4180 ),
4181];
4182
4183const GRAPH_REBUILDABLE_INDEX_NAMES: &[&str] = &[
4185 "idx_graph_entities_path",
4186 "idx_graph_entities_package",
4187 "idx_graph_entities_manifest_path",
4188 "idx_graph_entities_symbol",
4189 "idx_graph_entities_external",
4190 "idx_graph_relations_source_kind",
4191 "idx_graph_relations_target_kind",
4192 "idx_graph_relations_kind_order",
4193 "idx_graph_relations_kind_resolution",
4194 "idx_graph_occurrences_file_span",
4195 "idx_graph_coverage_scope_state",
4196 "idx_graph_coverage_scope_order",
4197 "idx_graph_coverage_path",
4198 "idx_graph_coverage_relation_state",
4199 "idx_graph_coverage_discovery_state",
4200 "idx_graph_coverage_discovery_reason",
4201 "idx_graph_entity_exports_key",
4202 "idx_graph_entity_exports_owner",
4203 "idx_graph_relation_dependencies_key",
4204 "idx_graph_relation_dependencies_owner",
4205];
4206
4207fn drop_graph_rebuildable_indexes(connection: &Connection) -> DbResult<()> {
4209 for name in GRAPH_REBUILDABLE_INDEX_NAMES {
4210 connection.execute(&format!("DROP INDEX IF EXISTS {name}"), [])?;
4211 }
4212 Ok(())
4213}
4214
4215fn graph_rebuildable_index_sql(connection: &Connection) -> DbResult<Vec<String>> {
4217 GRAPH_REBUILDABLE_INDEX_NAMES
4218 .iter()
4219 .map(|name| {
4220 connection
4221 .query_row(
4222 "SELECT sql FROM sqlite_schema WHERE type = 'index' AND name = ?1",
4223 [name],
4224 |row| row.get::<_, String>(0),
4225 )
4226 .optional()?
4227 .ok_or(DbError::GraphRowShape {
4228 table: "repository graph indexes",
4229 reason: "required rebuildable index is missing",
4230 })
4231 })
4232 .collect()
4233}
4234const GRAPH_STAGE_COPY_ROWS: usize = 512;
4236
4237fn insert_graph_stage_values(
4239 target: &Connection,
4240 table: &'static str,
4241 columns: &'static str,
4242 column_count: usize,
4243 values: &[Value],
4244) -> DbResult<()> {
4245 let rows = values.len() / column_count;
4246 let row = format!(
4247 "({})",
4248 std::iter::repeat_n("?", column_count)
4249 .collect::<Vec<_>>()
4250 .join(",")
4251 );
4252 let placeholders = std::iter::repeat_n(row, rows).collect::<Vec<_>>().join(",");
4253 let insert_sql = format!("INSERT INTO {table}({columns}) VALUES{placeholders}");
4254 target.execute(&insert_sql, params_from_iter(values.iter()))?;
4255 Ok(())
4256}
4257
4258fn copy_graph_stage_table(
4260 source: &Connection,
4261 target: &Connection,
4262 table: &'static str,
4263 columns: &'static str,
4264 control: Option<&IndexWorkControl>,
4265) -> DbResult<()> {
4266 let column_count = columns.split(',').count();
4267 let select_sql = format!("SELECT {columns} FROM {table}");
4268 let mut select = source.prepare(&select_sql)?;
4269 let mut rows = select.query([])?;
4270 let mut copied = 0_usize;
4271 let mut values = Vec::with_capacity(GRAPH_STAGE_COPY_ROWS.saturating_mul(column_count));
4272 while let Some(row) = rows.next()? {
4273 if copied.is_multiple_of(256)
4274 && let Some(control) = control
4275 {
4276 control.check(IndexWorkStage::Publication)?;
4277 }
4278 for column in 0..column_count {
4279 values.push(row.get::<_, Value>(column)?);
4280 }
4281 copied = copied.saturating_add(1);
4282 if copied.is_multiple_of(GRAPH_STAGE_COPY_ROWS) {
4283 insert_graph_stage_values(target, table, columns, column_count, &values)?;
4284 values.clear();
4285 }
4286 }
4287 if !values.is_empty() {
4288 insert_graph_stage_values(target, table, columns, column_count, &values)?;
4289 }
4290 Ok(())
4291}
4292
4293impl IndexPublicationGuard<'_> {
4294 pub fn replace_repository_graph_from_staging(
4301 &mut self,
4302 project: ProjectInstanceId,
4303 staging: &AtlasStore,
4304 control: Option<&IndexWorkControl>,
4305 ) -> DbResult<()> {
4306 let generation = self.pending_graph_generation()?;
4307 let staged_project = load_project_identity(&staging.connection)?;
4308 if staged_project != Some(project) {
4309 return Err(DbError::GraphProjectIdentityMismatch {
4310 expected: project.to_string(),
4311 found: staged_project
4312 .map_or_else(|| "missing".to_string(), |value| value.to_string()),
4313 });
4314 }
4315 if load_graph_generation(&staging.connection)? != Some(generation) {
4316 return Err(GraphContractError::GenerationMismatch {
4317 context: "staged repository graph",
4318 }
4319 .into());
4320 }
4321 let quick_check = staging
4322 .connection
4323 .query_row("PRAGMA quick_check(1)", [], |row| row.get::<_, String>(0))?;
4324 if quick_check != "ok" {
4325 return Err(DbError::GraphRowShape {
4326 table: "repository graph staging database",
4327 reason: "quick check failed",
4328 });
4329 }
4330 let mut foreign_keys = staging.connection.prepare("PRAGMA foreign_key_check")?;
4331 if foreign_keys.query([])?.next()?.is_some() {
4332 return Err(DbError::GraphRowShape {
4333 table: "repository graph staging database",
4334 reason: "foreign key check failed",
4335 });
4336 }
4337 validate_complete_document_unresolved_reasons(&staging.connection)?;
4338
4339 let savepoint = self.store.connection.savepoint()?;
4340 require_bound_project_identity(&savepoint, project)?;
4341 prepare_graph_identity_rejections(&savepoint, project, generation, None)?;
4342 let rebuildable_indexes = graph_rebuildable_index_sql(&savepoint)?;
4343 savepoint.execute("DELETE FROM graph_resolution_keys", [])?;
4344 savepoint.execute("DELETE FROM graph_coverage", [])?;
4345 savepoint.execute("DELETE FROM graph_relations", [])?;
4346 savepoint.execute("DELETE FROM graph_entities", [])?;
4347 drop_graph_rebuildable_indexes(&savepoint)?;
4348 for (table, columns) in GRAPH_STAGE_COPY_TABLES {
4349 copy_graph_stage_table(&staging.connection, &savepoint, table, columns, control)?;
4350 }
4351 set_graph_generation(&savepoint, generation)?;
4352 for index_sql in rebuildable_indexes {
4353 savepoint.execute_batch(&index_sql)?;
4354 }
4355 savepoint.commit()?;
4356 Ok(())
4357 }
4358
4359 pub fn replace_repository_graph(
4366 &mut self,
4367 project: ProjectInstanceId,
4368 entities: &[GraphEntity],
4369 relations: &[LogicalRelation],
4370 occurrences: &[RelationOccurrence],
4371 coverage: &[CoverageRecord],
4372 ) -> DbResult<()> {
4373 self.replace_repository_graph_with_resolution_keys(
4374 project,
4375 entities,
4376 relations,
4377 occurrences,
4378 coverage,
4379 &[],
4380 &[],
4381 )
4382 }
4383
4384 #[allow(clippy::too_many_arguments)]
4396 pub fn replace_repository_graph_with_resolution_keys(
4397 &mut self,
4398 project: ProjectInstanceId,
4399 entities: &[GraphEntity],
4400 relations: &[LogicalRelation],
4401 occurrences: &[RelationOccurrence],
4402 coverage: &[CoverageRecord],
4403 entity_exports: &[EntityResolutionKey],
4404 relation_dependencies: &[RelationDependencyKey],
4405 ) -> DbResult<()> {
4406 let generation = self.pending_graph_generation()?;
4407 validate_graph_batch(
4408 project,
4409 generation,
4410 entities,
4411 relations,
4412 occurrences,
4413 coverage,
4414 )?;
4415 validate_resolution_key_batch(project, entity_exports, relation_dependencies)?;
4416 let savepoint = self.store.connection.savepoint()?;
4417 require_bound_project_identity(&savepoint, project)?;
4418 prepare_graph_identity_rejections(&savepoint, project, generation, None)?;
4419 let rebuildable_indexes = graph_rebuildable_index_sql(&savepoint)?;
4420 savepoint.execute("DELETE FROM graph_resolution_keys", [])?;
4421 savepoint.execute("DELETE FROM graph_coverage", [])?;
4422 savepoint.execute("DELETE FROM graph_relations", [])?;
4423 savepoint.execute("DELETE FROM graph_entities", [])?;
4424 drop_graph_rebuildable_indexes(&savepoint)?;
4425 insert_graph_batch(
4426 &savepoint,
4427 project,
4428 entities,
4429 relations,
4430 occurrences,
4431 coverage,
4432 )?;
4433 insert_resolution_key_batch(&savepoint, project, entity_exports, relation_dependencies)?;
4434 set_graph_generation(&savepoint, generation)?;
4435 for index_sql in rebuildable_indexes {
4436 savepoint.execute_batch(&index_sql)?;
4437 }
4438 savepoint.commit()?;
4439 Ok(())
4440 }
4441
4442 pub fn replace_repository_graph_for_paths(
4453 &mut self,
4454 project: ProjectInstanceId,
4455 affected_paths: &[String],
4456 entities: &[GraphEntity],
4457 relations: &[LogicalRelation],
4458 occurrences: &[RelationOccurrence],
4459 coverage: &[CoverageRecord],
4460 ) -> DbResult<()> {
4461 self.replace_repository_graph_for_paths_with_resolution_keys(
4462 project,
4463 affected_paths,
4464 entities,
4465 relations,
4466 occurrences,
4467 coverage,
4468 &[],
4469 &[],
4470 )
4471 }
4472
4473 #[allow(clippy::too_many_arguments)]
4481 pub fn replace_repository_graph_for_paths_with_resolution_keys(
4482 &mut self,
4483 project: ProjectInstanceId,
4484 affected_paths: &[String],
4485 entities: &[GraphEntity],
4486 relations: &[LogicalRelation],
4487 occurrences: &[RelationOccurrence],
4488 coverage: &[CoverageRecord],
4489 entity_exports: &[EntityResolutionKey],
4490 relation_dependencies: &[RelationDependencyKey],
4491 ) -> DbResult<()> {
4492 let generation = self.pending_graph_generation()?;
4493 validate_graph_batch(
4494 project,
4495 generation,
4496 entities,
4497 relations,
4498 occurrences,
4499 coverage,
4500 )?;
4501 validate_resolution_key_batch(project, entity_exports, relation_dependencies)?;
4502 let affected_paths = affected_paths
4503 .iter()
4504 .map(|path| RepositoryNodePath::new(Path::new(path)))
4505 .collect::<Result<Vec<_>, _>>()?;
4506 let savepoint = self.store.connection.savepoint()?;
4507 require_bound_project_identity(&savepoint, project)?;
4508 if affected_paths.iter().any(|path| path.as_str() == ".") {
4509 prepare_graph_identity_rejections(&savepoint, project, generation, None)?;
4510 savepoint.execute("DELETE FROM graph_resolution_keys", [])?;
4511 savepoint.execute("DELETE FROM graph_coverage", [])?;
4512 savepoint.execute("DELETE FROM graph_relations", [])?;
4513 savepoint.execute("DELETE FROM graph_entities", [])?;
4514 insert_graph_batch(
4515 &savepoint,
4516 project,
4517 entities,
4518 relations,
4519 occurrences,
4520 coverage,
4521 )?;
4522 insert_resolution_key_batch(
4523 &savepoint,
4524 project,
4525 entity_exports,
4526 relation_dependencies,
4527 )?;
4528 set_graph_generation(&savepoint, generation)?;
4529 savepoint.commit()?;
4530 return Ok(());
4531 }
4532 prepare_graph_identity_rejections(&savepoint, project, generation, Some(&affected_paths))?;
4533 let touched_keys = resolution_keys_for_owner_paths(
4534 &savepoint,
4535 project,
4536 &affected_paths,
4537 ResolutionOwner::Both,
4538 None,
4539 &mut ResolutionReadBudget::new(u64::MAX),
4540 )?;
4541 let mut orphan_candidates = affected_external_candidates(&savepoint, &affected_paths)?;
4542 invalidate_repository_graph_paths(&savepoint, &affected_paths, &mut orphan_candidates)?;
4543 insert_graph_batch(
4544 &savepoint,
4545 project,
4546 entities,
4547 relations,
4548 occurrences,
4549 coverage,
4550 )?;
4551 insert_resolution_key_batch(&savepoint, project, entity_exports, relation_dependencies)?;
4552 for entity in entities {
4553 if matches!(entity.selector(), EntitySelector::External { .. }) {
4554 orphan_candidates.insert(entity.key().digest_bytes()?);
4555 }
4556 }
4557 remove_orphan_external_candidates(&savepoint, &orphan_candidates)?;
4558 remove_touched_orphan_resolution_keys(&savepoint, &touched_keys)?;
4559 set_graph_generation(&savepoint, generation)?;
4560 savepoint.commit()?;
4561 Ok(())
4562 }
4563
4564 pub fn set_document_unresolved_reasons(
4576 &mut self,
4577 reasons: &[(LogicalRelationKey, DocumentTargetUnresolvedReason)],
4578 ) -> DbResult<()> {
4579 self.set_document_unresolved_reasons_with_control(reasons, None)
4580 }
4581
4582 pub fn set_document_unresolved_reasons_controlled(
4589 &mut self,
4590 reasons: &[(LogicalRelationKey, DocumentTargetUnresolvedReason)],
4591 control: &IndexWorkControl,
4592 ) -> DbResult<()> {
4593 self.set_document_unresolved_reasons_with_control(reasons, Some(control))
4594 }
4595
4596 fn set_document_unresolved_reasons_with_control(
4598 &mut self,
4599 reasons: &[(LogicalRelationKey, DocumentTargetUnresolvedReason)],
4600 control: Option<&IndexWorkControl>,
4601 ) -> DbResult<()> {
4602 let savepoint = self.store.validated_savepoint()?;
4603 write_document_unresolved_reasons(&savepoint, reasons, control)?;
4604 savepoint.commit()?;
4605 Ok(())
4606 }
4607
4608 pub fn replace_graph_identity_rejections(
4620 &mut self,
4621 project: ProjectInstanceId,
4622 rejections: &[GraphIdentityRejection],
4623 ) -> DbResult<()> {
4624 if rejections.len() > GraphLimits::MAX_ROWS as usize {
4625 return Err(GraphContractError::InvalidLimits {
4626 reason: "graph identity rejection batch exceeds the graph row ceiling",
4627 }
4628 .into());
4629 }
4630 let generation = self.pending_graph_generation()?;
4631 let savepoint = self.store.validated_savepoint()?;
4632 require_bound_project_identity(&savepoint, project)?;
4633 insert_graph_identity_rejections(&savepoint, project, generation, rejections)?;
4634 let generation_value =
4635 sqlite_count("graph_identity_rejections.generation", generation.get())?;
4636 let persisted_count = savepoint.query_row(
4637 "SELECT COUNT(*)
4638 FROM graph_identity_rejections
4639 WHERE project_instance_id = ?1 AND generation = ?2",
4640 params![&project.as_bytes()[..], generation_value],
4641 |row| row.get::<_, i64>(0),
4642 )?;
4643 if persisted_count > i64::from(GraphLimits::MAX_ROWS) {
4644 return Err(GraphContractError::InvalidLimits {
4645 reason: "complete graph identity rejection generation exceeds the graph row ceiling",
4646 }
4647 .into());
4648 }
4649 savepoint.commit()?;
4650 Ok(())
4651 }
4652
4653 fn pending_graph_generation(&self) -> DbResult<IndexGeneration> {
4655 self.previous_generation
4656 .checked_next()
4657 .ok_or(DbError::PublicationGenerationOverflow)
4658 }
4659}
4660
4661fn write_document_unresolved_reasons(
4663 connection: &Connection,
4664 reasons: &[(LogicalRelationKey, DocumentTargetUnresolvedReason)],
4665 control: Option<&IndexWorkControl>,
4666) -> DbResult<()> {
4667 if let Some(control) = control {
4668 control.check(IndexWorkStage::Publication)?;
4669 }
4670 validate_document_unresolved_reasons(connection, reasons, control)?;
4671 for chunk in reasons.chunks(GraphLimits::MAX_ROWS as usize) {
4672 if let Some(control) = control {
4673 control.check(IndexWorkStage::Publication)?;
4674 }
4675 write_document_unresolved_reason_chunk(connection, chunk, control)?;
4676 }
4677 Ok(())
4678}
4679
4680fn validate_document_unresolved_reasons(
4682 connection: &Connection,
4683 reasons: &[(LogicalRelationKey, DocumentTargetUnresolvedReason)],
4684 control: Option<&IndexWorkControl>,
4685) -> DbResult<()> {
4686 let mut keys = HashSet::with_capacity(reasons.len());
4687 for (index, (key, _)) in reasons.iter().enumerate() {
4688 if index.is_multiple_of(256)
4689 && let Some(control) = control
4690 {
4691 control.check(IndexWorkStage::Publication)?;
4692 }
4693 if !keys.insert(key.digest_bytes()?) {
4694 return Err(GraphContractError::InvalidLimits {
4695 reason: "document unresolved reason batch repeats a relation key",
4696 }
4697 .into());
4698 }
4699 }
4700 let mut validate = connection.prepare_cached(
4701 "SELECT EXISTS(
4702 SELECT 1
4703 FROM graph_relations
4704 INDEXED BY idx_graph_relations_project_key
4705 WHERE relation_key = ?1
4706 AND project_instance_id = ?2
4707 AND relation_scope = 'extended'
4708 AND relation_kind = 'documents'
4709 AND resolution_status = 'unresolved'
4710 )",
4711 )?;
4712 for (index, (key, _)) in reasons.iter().enumerate() {
4713 if index.is_multiple_of(256)
4714 && let Some(control) = control
4715 {
4716 control.check(IndexWorkStage::Publication)?;
4717 }
4718 let digest = key.digest_bytes()?;
4719 let compatible = validate
4720 .query_row(params![&digest[..], &key.project().as_bytes()[..]], |row| {
4721 row.get::<_, bool>(0)
4722 })?;
4723 if !compatible {
4724 return Err(DbError::GraphRowShape {
4725 table: "graph_relations",
4726 reason: "document unresolved reason target is missing or incompatible",
4727 });
4728 }
4729 }
4730 drop(validate);
4731 Ok(())
4732}
4733
4734fn write_document_unresolved_reason_chunk(
4736 connection: &Connection,
4737 reasons: &[(LogicalRelationKey, DocumentTargetUnresolvedReason)],
4738 control: Option<&IndexWorkControl>,
4739) -> DbResult<()> {
4740 let mut statement = connection.prepare_cached(
4741 "UPDATE graph_relations
4742 SET document_unresolved_reason = ?2
4743 WHERE relation_key = ?1
4744 AND project_instance_id = ?3
4745 AND relation_scope = 'extended'
4746 AND relation_kind = 'documents'
4747 AND resolution_status = 'unresolved'",
4748 )?;
4749 for (index, (key, reason)) in reasons.iter().enumerate() {
4750 if index.is_multiple_of(256)
4751 && let Some(control) = control
4752 {
4753 control.check(IndexWorkStage::Publication)?;
4754 }
4755 let digest = key.digest_bytes()?;
4756 let changed = statement.execute(params![
4757 &digest[..],
4758 reason.as_str(),
4759 &key.project().as_bytes()[..],
4760 ])?;
4761 debug_assert_eq!(changed, 1);
4762 }
4763 if let Some(control) = control {
4764 control.check(IndexWorkStage::Publication)?;
4765 }
4766 Ok(())
4767}
4768
4769pub(crate) fn validate_complete_document_unresolved_reasons(
4771 connection: &Connection,
4772) -> DbResult<()> {
4773 let invalid = connection
4774 .query_row(
4775 "SELECT relation_key
4776 FROM graph_relations
4777 WHERE (
4778 relation_scope = 'extended'
4779 AND relation_kind = 'documents'
4780 AND resolution_status = 'unresolved'
4781 AND document_unresolved_reason IS NULL
4782 ) OR (
4783 document_unresolved_reason IS NOT NULL
4784 AND NOT (
4785 relation_scope = 'extended'
4786 AND relation_kind = 'documents'
4787 AND resolution_status = 'unresolved'
4788 )
4789 )
4790 LIMIT 1",
4791 [],
4792 |row| row.get::<_, Vec<u8>>(0),
4793 )
4794 .optional()?;
4795 if invalid.is_some() {
4796 return Err(DbError::GraphRowShape {
4797 table: "graph_relations",
4798 reason: "published document unresolved reason invariant is incomplete",
4799 });
4800 }
4801 Ok(())
4802}
4803
4804const RESOLUTION_KEYS_PER_QUERY: usize = 200;
4806const RESOLUTION_PATHS_PER_QUERY: usize = 400;
4808const EXTERNAL_CANDIDATE_KEYS_PER_QUERY: usize = 400;
4810
4811#[derive(Clone, Copy)]
4813enum ResolutionOwner {
4814 EntityExports,
4816 Both,
4818}
4819
4820impl ResolutionOwner {
4821 fn tables(self) -> &'static [(&'static str, &'static str)] {
4823 match self {
4824 Self::EntityExports => &[("graph_entity_exports", "idx_graph_entity_exports_owner")],
4825 Self::Both => &[
4826 ("graph_entity_exports", "idx_graph_entity_exports_owner"),
4827 (
4828 "graph_relation_dependencies",
4829 "idx_graph_relation_dependencies_owner",
4830 ),
4831 ],
4832 }
4833 }
4834}
4835
4836fn normalized_file_paths(paths: &[String]) -> DbResult<Vec<RepositoryNodePath>> {
4838 let mut normalized = BTreeSet::new();
4839 for path in paths {
4840 let file = RepositoryFilePath::new(Path::new(path))?;
4841 normalized.insert(RepositoryNodePath::new(Path::new(file.as_str()))?);
4842 }
4843 Ok(normalized.into_iter().collect())
4844}
4845
4846struct ResolutionReadBudget {
4848 limit: u64,
4850 retained: u64,
4852 output_retained: u64,
4854}
4855
4856impl ResolutionReadBudget {
4857 const fn new(limit: u64) -> Self {
4859 Self {
4860 limit,
4861 retained: 0,
4862 output_retained: 0,
4863 }
4864 }
4865
4866 fn check(&self, additional: u64) -> DbResult<()> {
4868 let requested = self.retained.saturating_add(additional);
4869 if requested > self.limit {
4870 return Err(DbError::IndexWork(
4871 projectatlas_core::IndexWorkFailure::resource_limit(
4872 IndexWorkStage::SymbolParsing,
4873 projectatlas_core::IndexWorkResource::OutputBytes,
4874 self.limit,
4875 requested,
4876 ),
4877 ));
4878 }
4879 Ok(())
4880 }
4881
4882 fn reserve(&mut self, additional: u64) -> DbResult<()> {
4884 self.check(additional)?;
4885 self.retained = self.retained.saturating_add(additional);
4886 Ok(())
4887 }
4888
4889 fn reserve_output(&mut self, bytes: u64) -> DbResult<()> {
4892 self.reserve(bytes)?;
4893 self.output_retained = self.output_retained.saturating_add(bytes / 2);
4894 Ok(())
4895 }
4896
4897 fn row_bytes(row: &Row<'_>, inline: usize) -> DbResult<u64> {
4900 let mut bytes = 128_u64.saturating_add(inline as u64);
4901 for column in 0..row.as_ref().column_count() {
4902 bytes = bytes.saturating_add(match row.get_ref(column)? {
4903 rusqlite::types::ValueRef::Text(value) | rusqlite::types::ValueRef::Blob(value) => {
4904 value.len() as u64
4905 }
4906 _ => 8,
4907 });
4908 }
4909 Ok(bytes.saturating_mul(4))
4910 }
4911}
4912
4913fn normalized_resolution_keys(
4915 project: ProjectInstanceId,
4916 keys: &[CanonicalResolutionKey],
4917 budget: &mut ResolutionReadBudget,
4918) -> DbResult<Vec<CanonicalResolutionKey>> {
4919 let mut normalized = BTreeSet::new();
4920 for key in keys {
4921 if key.project() != project {
4922 return Err(DbError::GraphProjectIdentityMismatch {
4923 expected: project.to_string(),
4924 found: key.project().to_string(),
4925 });
4926 }
4927 if !normalized.contains(key) {
4928 budget.reserve(
4929 4_u64.saturating_mul(
4930 128_u64
4931 .saturating_add((2 * std::mem::size_of::<CanonicalResolutionKey>()) as u64)
4932 .saturating_add(key.retained_heap_bytes()),
4933 ),
4934 )?;
4935 normalized.insert(key.clone());
4936 }
4937 }
4938 Ok(normalized.into_iter().collect())
4939}
4940
4941fn page_from_ordered_set<T: Ord>(rows: BTreeSet<T>, limit: u32) -> RepositoryGraphPage<T> {
4943 let truncated = rows.len() > limit as usize;
4944 RepositoryGraphPage {
4945 rows: rows.into_iter().take(limit as usize).collect(),
4946 truncated,
4947 }
4948}
4949
4950fn resolution_keys_for_owner_paths(
4952 connection: &Connection,
4953 project: ProjectInstanceId,
4954 paths: &[RepositoryNodePath],
4955 owners: ResolutionOwner,
4956 limit: Option<u32>,
4957 budget: &mut ResolutionReadBudget,
4958) -> DbResult<BTreeSet<CanonicalResolutionKey>> {
4959 let mut keys = BTreeSet::new();
4960 for (table, index) in owners.tables() {
4961 for chunk in paths.chunks(RESOLUTION_PATHS_PER_QUERY) {
4962 if chunk.is_empty() {
4963 continue;
4964 }
4965 let placeholders = vec!["?"; chunk.len()].join(",");
4966 let limit_clause = limit.map_or("", |_| " LIMIT ?");
4967 let sql = format!(
4968 "SELECT DISTINCT resolution.project_instance_id,
4969 resolution.resolution_domain, resolution.key_digest,
4970 resolution.canonical_identity
4971 FROM {table} AS owner INDEXED BY {index}
4972 JOIN graph_resolution_keys AS resolution
4973 ON resolution.project_instance_id = owner.project_instance_id
4974 AND resolution.resolution_domain = owner.resolution_domain
4975 AND resolution.key_digest = owner.key_digest
4976 WHERE owner.project_instance_id = ?
4977 AND owner.owner_path IN ({placeholders})
4978 ORDER BY resolution.resolution_domain, resolution.key_digest{limit_clause}"
4979 );
4980 let mut values = Vec::with_capacity(chunk.len() + 2);
4981 values.push(Value::Blob(project.as_bytes().to_vec()));
4982 values.extend(
4983 chunk
4984 .iter()
4985 .map(|path| Value::Text(path.as_str().to_string())),
4986 );
4987 if let Some(limit) = limit {
4988 values.push(Value::Integer(i64::from(limit) + 1));
4989 }
4990 let mut statement = connection.prepare(&sql)?;
4991 let mut rows = statement.query(params_from_iter(values.iter()))?;
4992 while let Some(row) = rows.next()? {
4993 let bytes = ResolutionReadBudget::row_bytes(
4994 row,
4995 std::mem::size_of::<CanonicalResolutionKey>(),
4996 )?;
4997 budget.check(bytes)?;
4998 let key = resolution_key_from_row(row, project)?;
4999 if !keys.contains(&key) {
5000 budget.reserve_output(bytes)?;
5001 keys.insert(key);
5002 }
5003 }
5004 if limit.is_some_and(|limit| keys.len() > limit as usize) {
5005 return Ok(keys);
5006 }
5007 }
5008 }
5009 Ok(keys)
5010}
5011
5012fn resolution_key_from_row(
5014 row: &Row<'_>,
5015 expected_project: ProjectInstanceId,
5016) -> DbResult<CanonicalResolutionKey> {
5017 let project = project_from_blob(
5018 "graph_resolution_keys.project_instance_id",
5019 row.get::<_, Vec<u8>>(0)?,
5020 )?;
5021 require_project(expected_project, project)?;
5022 let domain_text = row.get::<_, String>(1)?;
5023 let domain = ResolutionKeyDomain::try_from(domain_text.as_str())?;
5024 let digest = fixed_bytes::<32>(
5025 "graph_resolution_keys.key_digest",
5026 row.get::<_, Vec<u8>>(2)?,
5027 )?;
5028 Ok(CanonicalResolutionKey::from_persisted(
5029 project,
5030 domain,
5031 digest,
5032 row.get(3)?,
5033 )?)
5034}
5035
5036fn validate_persisted_resolution_key(
5038 connection: &Connection,
5039 key: &CanonicalResolutionKey,
5040) -> DbResult<bool> {
5041 let stored = connection
5042 .query_row(
5043 "SELECT canonical_identity
5044 FROM graph_resolution_keys
5045 WHERE project_instance_id = ?1
5046 AND resolution_domain = ?2
5047 AND key_digest = ?3",
5048 params![
5049 &key.project().as_bytes()[..],
5050 key.domain().as_str(),
5051 &key.digest_bytes()[..],
5052 ],
5053 |row| row.get::<_, String>(0),
5054 )
5055 .optional()?;
5056 let Some(stored) = stored else {
5057 return Ok(false);
5058 };
5059 if stored != key.canonical_identity() {
5060 return Err(DbError::ResolutionKeyCollision {
5061 domain: key.domain().as_str(),
5062 digest: key.digest_bytes(),
5063 });
5064 }
5065 CanonicalResolutionKey::from_persisted(
5066 key.project(),
5067 key.domain(),
5068 key.digest_bytes(),
5069 stored,
5070 )?;
5071 Ok(true)
5072}
5073
5074fn validate_persisted_resolution_keys(
5076 connection: &Connection,
5077 keys: &[CanonicalResolutionKey],
5078 budget: &ResolutionReadBudget,
5079) -> DbResult<()> {
5080 for chunk in keys.chunks(RESOLUTION_KEYS_PER_QUERY) {
5081 if chunk.is_empty() {
5082 continue;
5083 }
5084 let values_clause = anonymous_values_clause(chunk.len(), 4);
5085 let sql = format!(
5086 "WITH requested(project_instance_id, resolution_domain, key_digest, canonical_identity)
5087 AS (VALUES {values_clause})
5088 SELECT requested.resolution_domain, requested.key_digest,
5089 requested.canonical_identity, stored.canonical_identity
5090 FROM requested
5091 JOIN graph_resolution_keys AS stored
5092 ON stored.project_instance_id = requested.project_instance_id
5093 AND stored.resolution_domain = requested.resolution_domain
5094 AND stored.key_digest = requested.key_digest
5095 ORDER BY requested.resolution_domain, requested.key_digest"
5096 );
5097 let values = resolution_key_values(chunk, true);
5098 let mut statement = connection.prepare(&sql)?;
5099 let mut rows = statement.query(params_from_iter(values.iter()))?;
5100 while let Some(row) = rows.next()? {
5101 budget.check(ResolutionReadBudget::row_bytes(row, 0)?)?;
5102 let domain_text = row.get::<_, String>(0)?;
5103 let domain = ResolutionKeyDomain::try_from(domain_text.as_str())?;
5104 let digest = fixed_bytes::<32>(
5105 "graph_resolution_keys.key_digest",
5106 row.get::<_, Vec<u8>>(1)?,
5107 )?;
5108 let requested = row.get::<_, String>(2)?;
5109 let stored = row.get::<_, String>(3)?;
5110 if requested != stored {
5111 return Err(DbError::ResolutionKeyCollision {
5112 domain: domain.as_str(),
5113 digest,
5114 });
5115 }
5116 }
5117 }
5118 Ok(())
5119}
5120
5121fn affected_source_paths(
5123 connection: &Connection,
5124 keys: &[CanonicalResolutionKey],
5125 limit: u32,
5126 budget: &mut ResolutionReadBudget,
5127) -> DbResult<BTreeSet<RepositoryFilePath>> {
5128 let mut paths = BTreeSet::new();
5129 for chunk in keys.chunks(RESOLUTION_KEYS_PER_QUERY) {
5130 if chunk.is_empty() {
5131 continue;
5132 }
5133 let values_clause = anonymous_values_clause(chunk.len(), 4);
5134 let sql = format!(
5135 "WITH requested(project_instance_id, resolution_domain, key_digest, canonical_identity)
5136 AS (VALUES {values_clause})
5137 SELECT DISTINCT dependency.owner_path
5138 FROM requested
5139 JOIN graph_resolution_keys AS stored
5140 ON stored.project_instance_id = requested.project_instance_id
5141 AND stored.resolution_domain = requested.resolution_domain
5142 AND stored.key_digest = requested.key_digest
5143 AND stored.canonical_identity = requested.canonical_identity
5144 JOIN graph_relation_dependencies AS dependency
5145 INDEXED BY idx_graph_relation_dependencies_key
5146 ON dependency.project_instance_id = stored.project_instance_id
5147 AND dependency.resolution_domain = stored.resolution_domain
5148 AND dependency.key_digest = stored.key_digest
5149 ORDER BY dependency.owner_path
5150 LIMIT ?"
5151 );
5152 let mut values = resolution_key_values(chunk, true);
5153 values.push(Value::Integer(i64::from(limit) + 1));
5154 let mut statement = connection.prepare(&sql)?;
5155 let mut rows = statement.query(params_from_iter(values.iter()))?;
5156 while let Some(row) = rows.next()? {
5157 let bytes =
5158 ResolutionReadBudget::row_bytes(row, std::mem::size_of::<RepositoryFilePath>())?;
5159 budget.check(bytes)?;
5160 let path = RepositoryFilePath::new(Path::new(&row.get::<_, String>(0)?))?;
5161 if !paths.contains(&path) {
5162 budget.reserve_output(bytes)?;
5163 paths.insert(path);
5164 }
5165 }
5166 if paths.len() > limit as usize {
5167 return Ok(paths);
5168 }
5169 }
5170 Ok(paths)
5171}
5172
5173fn affected_source_footprint(
5175 connection: &Connection,
5176 project: ProjectInstanceId,
5177 paths: &[RepositoryNodePath],
5178 limit_plus_one: i64,
5179) -> DbResult<RepositoryAffectedSourceFootprint> {
5180 let maximum_rows = u64::try_from(limit_plus_one).map_err(|source| DbError::InvalidCount {
5181 field: "affected_source_footprint.limit_plus_one",
5182 value: limit_plus_one,
5183 source,
5184 })?;
5185 let mut footprint = empty_affected_source_footprint();
5186 for chunk in paths.chunks(RESOLUTION_PATHS_PER_QUERY) {
5187 if chunk.is_empty() {
5188 continue;
5189 }
5190 let remaining = maximum_rows.saturating_sub(footprint.rows);
5191 if remaining == 0 {
5192 footprint.truncated = true;
5193 break;
5194 }
5195 let sql = affected_source_footprint_sql(chunk.len());
5196 let mut values = Vec::with_capacity(chunk.len() + 2);
5197 values.push(Value::Blob(project.as_bytes().to_vec()));
5198 values.extend(
5199 chunk
5200 .iter()
5201 .map(|path| Value::Text(path.as_str().to_string())),
5202 );
5203 values.push(Value::Integer(i64::try_from(remaining).map_err(
5204 |source| DbError::InvalidCount {
5205 field: "affected_source_footprint.remaining_rows",
5206 value: i64::MAX,
5207 source,
5208 },
5209 )?));
5210 let mut statement = connection.prepare(&sql)?;
5211 let mut rows = statement.query(params_from_iter(values.iter()))?;
5212 while let Some(row) = rows.next()? {
5213 let bytes = nonnegative_u64(
5214 "affected_source_footprint.retained_bytes",
5215 row.get::<_, i64>(0)?,
5216 )?;
5217 footprint.rows = footprint.rows.saturating_add(1);
5218 footprint.retained_bytes = footprint.retained_bytes.saturating_add(bytes);
5219 }
5220 if footprint.rows >= maximum_rows {
5221 footprint.truncated = true;
5222 break;
5223 }
5224 }
5225 Ok(footprint)
5226}
5227
5228fn affected_source_footprint_sql(path_count: usize) -> String {
5230 let requested = vec!["(?)"; path_count].join(",");
5231 format!(
5232 "WITH selected_project(project_instance_id) AS (VALUES (?)),
5233 requested(path) AS (VALUES {requested})
5234 SELECT retained_bytes
5235 FROM (
5236 SELECT length(CAST(metadata.path AS BLOB))
5237 + coalesce(length(CAST(metadata.language AS BLOB)), 0)
5238 + length(CAST(metadata.source_parser AS BLOB))
5239 + length(CAST(metadata.fact_parser AS BLOB)) + 16
5240 + length(CAST(metadata.updated_at AS BLOB)) AS retained_bytes
5241 FROM requested
5242 JOIN source_parse_metadata AS metadata
5243 INDEXED BY sqlite_autoindex_source_parse_metadata_1
5244 ON metadata.path = requested.path
5245 UNION ALL
5246 SELECT 32 + length(CAST(symbol.path AS BLOB))
5247 + coalesce(length(CAST(symbol.language AS BLOB)), 0)
5248 + length(CAST(symbol.name AS BLOB))
5249 + length(CAST(symbol.kind AS BLOB))
5250 + length(CAST(symbol.signature AS BLOB))
5251 + coalesce(length(CAST(symbol.documentation AS BLOB)), 0)
5252 + coalesce(length(CAST(symbol.parent AS BLOB)), 0)
5253 + length(CAST(symbol.parser AS BLOB))
5254 + coalesce(length(CAST(symbol.detail AS BLOB)), 0)
5255 + length(CAST(symbol.created_at AS BLOB))
5256 + length(CAST(symbol.updated_at AS BLOB))
5257 FROM requested
5258 JOIN symbols AS symbol INDEXED BY idx_symbols_path
5259 ON symbol.path = requested.path
5260 UNION ALL
5261 SELECT 16 + length(CAST(symbol_relation.path AS BLOB))
5262 + length(CAST(symbol_relation.source_name AS BLOB))
5263 + length(CAST(symbol_relation.target_name AS BLOB))
5264 + length(CAST(symbol_relation.kind AS BLOB))
5265 + length(CAST(symbol_relation.context AS BLOB))
5266 + length(CAST(symbol_relation.parser AS BLOB))
5267 + length(CAST(symbol_relation.created_at AS BLOB))
5268 FROM requested
5269 JOIN symbol_relations AS symbol_relation INDEXED BY idx_symbol_relations_path
5270 ON symbol_relation.path = requested.path
5271 UNION ALL
5272 SELECT 48 + length(CAST(entity.canonical_identity AS BLOB))
5273 + length(CAST(entity.entity_kind AS BLOB))
5274 + coalesce(length(CAST(entity.repository_path AS BLOB)), 0)
5275 + coalesce(length(CAST(entity.package_manager AS BLOB)), 0)
5276 + coalesce(length(CAST(entity.package_name AS BLOB)), 0)
5277 + coalesce(length(CAST(entity.manifest_path AS BLOB)), 0)
5278 + coalesce(length(CAST(entity.symbol_name AS BLOB)), 0)
5279 + coalesce(length(CAST(entity.symbol_kind AS BLOB)), 0)
5280 + coalesce(length(CAST(entity.symbol_parent AS BLOB)), 0)
5281 + coalesce(length(CAST(entity.symbol_signature AS BLOB)), 0)
5282 + coalesce(length(CAST(entity.external_system AS BLOB)), 0)
5283 + coalesce(length(CAST(entity.external_identity AS BLOB)), 0)
5284 FROM requested
5285 JOIN graph_entities AS entity INDEXED BY idx_graph_entities_path
5286 ON entity.repository_path = requested.path
5287 JOIN selected_project
5288 ON selected_project.project_instance_id = entity.project_instance_id
5289 UNION ALL
5290 SELECT 48 + length(CAST(entity.canonical_identity AS BLOB))
5291 + length(CAST(entity.entity_kind AS BLOB))
5292 + coalesce(length(CAST(entity.repository_path AS BLOB)), 0)
5293 + coalesce(length(CAST(entity.package_manager AS BLOB)), 0)
5294 + coalesce(length(CAST(entity.package_name AS BLOB)), 0)
5295 + coalesce(length(CAST(entity.manifest_path AS BLOB)), 0)
5296 + coalesce(length(CAST(entity.symbol_name AS BLOB)), 0)
5297 + coalesce(length(CAST(entity.symbol_kind AS BLOB)), 0)
5298 + coalesce(length(CAST(entity.symbol_parent AS BLOB)), 0)
5299 + coalesce(length(CAST(entity.symbol_signature AS BLOB)), 0)
5300 + coalesce(length(CAST(entity.external_system AS BLOB)), 0)
5301 + coalesce(length(CAST(entity.external_identity AS BLOB)), 0)
5302 FROM requested
5303 JOIN graph_entities AS entity INDEXED BY idx_graph_entities_manifest_path
5304 ON entity.manifest_path = requested.path
5305 JOIN selected_project
5306 ON selected_project.project_instance_id = entity.project_instance_id
5307 UNION ALL
5308 SELECT 80 + length(CAST(relation.canonical_identity AS BLOB))
5309 + length(CAST(relation.relation_scope AS BLOB))
5310 + length(CAST(relation.relation_kind AS BLOB))
5311 + length(CAST(relation.resolution_status AS BLOB))
5312 + coalesce(length(relation.target_entity_key), 0)
5313 + coalesce(length(CAST(relation.reference_text AS BLOB)), 0)
5314 + CASE WHEN relation.candidate_count IS NULL THEN 0 ELSE 8 END
5315 + length(CAST(relation.confidence AS BLOB))
5316 + length(CAST(relation.completeness AS BLOB))
5317 FROM requested
5318 JOIN graph_entities AS entity INDEXED BY idx_graph_entities_path
5319 ON entity.repository_path = requested.path
5320 JOIN selected_project
5321 ON selected_project.project_instance_id = entity.project_instance_id
5322 JOIN graph_relations AS relation INDEXED BY idx_graph_relations_source_kind
5323 ON relation.source_entity_key = entity.entity_key
5324 AND relation.project_instance_id = selected_project.project_instance_id
5325 UNION ALL
5326 SELECT 80 + length(CAST(relation.canonical_identity AS BLOB))
5327 + length(CAST(relation.relation_scope AS BLOB))
5328 + length(CAST(relation.relation_kind AS BLOB))
5329 + length(CAST(relation.resolution_status AS BLOB))
5330 + coalesce(length(relation.target_entity_key), 0)
5331 + coalesce(length(CAST(relation.reference_text AS BLOB)), 0)
5332 + CASE WHEN relation.candidate_count IS NULL THEN 0 ELSE 8 END
5333 + length(CAST(relation.confidence AS BLOB))
5334 + length(CAST(relation.completeness AS BLOB))
5335 FROM requested
5336 JOIN graph_entities AS entity INDEXED BY idx_graph_entities_manifest_path
5337 ON entity.manifest_path = requested.path
5338 JOIN selected_project
5339 ON selected_project.project_instance_id = entity.project_instance_id
5340 JOIN graph_relations AS relation INDEXED BY idx_graph_relations_source_kind
5341 ON relation.source_entity_key = entity.entity_key
5342 AND relation.project_instance_id = selected_project.project_instance_id
5343 UNION ALL
5344 SELECT 72 + length(CAST(occurrence.file_path AS BLOB))
5345 FROM requested
5346 JOIN graph_relation_occurrences AS occurrence
5347 INDEXED BY idx_graph_occurrences_file_span
5348 ON occurrence.file_path = requested.path
5349 UNION ALL
5350 SELECT 48 + length(CAST(coverage.scope_kind AS BLOB))
5351 + coalesce(length(CAST(coverage.scope_path AS BLOB)), 0)
5352 + coalesce(length(CAST(coverage.relation_scope AS BLOB)), 0)
5353 + coalesce(length(CAST(coverage.relation_kind AS BLOB)), 0)
5354 + length(CAST(coverage.state AS BLOB))
5355 + coalesce(length(CAST(coverage.reason AS BLOB)), 0)
5356 + coalesce(length(CAST(coverage.reached_limit AS BLOB)), 0)
5357 FROM requested
5358 JOIN graph_coverage AS coverage INDEXED BY idx_graph_coverage_path
5359 ON coverage.scope_path = requested.path
5360 JOIN selected_project
5361 ON selected_project.project_instance_id = coverage.project_instance_id
5362 UNION ALL
5363 SELECT 80 + length(CAST(export.owner_path AS BLOB))
5364 + length(CAST(export.resolution_domain AS BLOB))
5365 FROM requested
5366 JOIN graph_entity_exports AS export INDEXED BY idx_graph_entity_exports_owner
5367 ON export.owner_path = requested.path
5368 JOIN selected_project
5369 ON selected_project.project_instance_id = export.project_instance_id
5370 UNION ALL
5371 SELECT length(witness.project_instance_id)
5372 + length(CAST(witness.resolution_domain AS BLOB))
5373 + length(witness.key_digest)
5374 + length(CAST(witness.canonical_identity AS BLOB))
5375 FROM requested
5376 JOIN graph_entity_exports AS export INDEXED BY idx_graph_entity_exports_owner
5377 ON export.owner_path = requested.path
5378 JOIN selected_project
5379 ON selected_project.project_instance_id = export.project_instance_id
5380 LEFT JOIN graph_resolution_keys AS witness
5381 ON witness.project_instance_id = export.project_instance_id
5382 AND witness.resolution_domain = export.resolution_domain
5383 AND witness.key_digest = export.key_digest
5384 UNION ALL
5385 SELECT 80 + length(CAST(dependency.owner_path AS BLOB))
5386 + length(CAST(dependency.resolution_domain AS BLOB))
5387 FROM requested
5388 JOIN graph_relation_dependencies AS dependency
5389 INDEXED BY idx_graph_relation_dependencies_owner
5390 ON dependency.owner_path = requested.path
5391 JOIN selected_project
5392 ON selected_project.project_instance_id = dependency.project_instance_id
5393 UNION ALL
5394 SELECT length(witness.project_instance_id)
5395 + length(CAST(witness.resolution_domain AS BLOB))
5396 + length(witness.key_digest)
5397 + length(CAST(witness.canonical_identity AS BLOB))
5398 FROM requested
5399 JOIN graph_relation_dependencies AS dependency
5400 INDEXED BY idx_graph_relation_dependencies_owner
5401 ON dependency.owner_path = requested.path
5402 JOIN selected_project
5403 ON selected_project.project_instance_id = dependency.project_instance_id
5404 LEFT JOIN graph_resolution_keys AS witness
5405 ON witness.project_instance_id = dependency.project_instance_id
5406 AND witness.resolution_domain = dependency.resolution_domain
5407 AND witness.key_digest = dependency.key_digest
5408 )
5409 LIMIT ?"
5410 )
5411}
5412
5413fn anonymous_values_clause(rows: usize, columns: usize) -> String {
5415 let row = format!("({})", vec!["?"; columns].join(","));
5416 vec![row; rows].join(",")
5417}
5418
5419fn resolution_key_values(keys: &[CanonicalResolutionKey], witness: bool) -> Vec<Value> {
5421 let columns = if witness { 4 } else { 3 };
5422 let mut values = Vec::with_capacity(keys.len() * columns);
5423 for key in keys {
5424 values.push(Value::Blob(key.project().as_bytes().to_vec()));
5425 values.push(Value::Text(key.domain().as_str().to_string()));
5426 values.push(Value::Blob(key.digest_bytes().to_vec()));
5427 if witness {
5428 values.push(Value::Text(key.canonical_identity().to_string()));
5429 }
5430 }
5431 values
5432}
5433
5434fn validate_resolution_key_batch(
5436 project: ProjectInstanceId,
5437 entity_exports: &[EntityResolutionKey],
5438 relation_dependencies: &[RelationDependencyKey],
5439) -> DbResult<()> {
5440 let foreign = entity_exports.iter().find_map(|binding| {
5441 (binding.entity().project() != project || binding.key().project() != project)
5442 .then(|| binding.key().project())
5443 });
5444 let foreign = foreign.or_else(|| {
5445 relation_dependencies.iter().find_map(|binding| {
5446 (binding.relation().project() != project || binding.key().project() != project)
5447 .then(|| binding.key().project())
5448 })
5449 });
5450 if let Some(found) = foreign {
5451 return Err(DbError::GraphProjectIdentityMismatch {
5452 expected: project.to_string(),
5453 found: found.to_string(),
5454 });
5455 }
5456 Ok(())
5457}
5458
5459fn insert_resolution_key_batch(
5461 connection: &Connection,
5462 project: ProjectInstanceId,
5463 entity_exports: &[EntityResolutionKey],
5464 relation_dependencies: &[RelationDependencyKey],
5465) -> DbResult<()> {
5466 let mut insert_key = connection.prepare_cached(
5467 "INSERT INTO graph_resolution_keys(
5468 project_instance_id, resolution_domain, key_digest, canonical_identity
5469 ) VALUES(?1, ?2, ?3, ?4)
5470 ON CONFLICT(project_instance_id, resolution_domain, key_digest)
5471 DO UPDATE SET canonical_identity = excluded.canonical_identity
5472 WHERE graph_resolution_keys.canonical_identity = excluded.canonical_identity",
5473 )?;
5474 let mut insert_export = connection.prepare_cached(
5475 "INSERT INTO graph_entity_exports(
5476 project_instance_id, entity_key, owner_path, resolution_domain, key_digest
5477 )
5478 SELECT entity.project_instance_id, entity.entity_key,
5479 CASE entity.entity_kind
5480 WHEN 'file' THEN entity.repository_path
5481 WHEN 'symbol' THEN entity.repository_path
5482 WHEN 'package' THEN entity.manifest_path
5483 END,
5484 ?3, ?4
5485 FROM graph_entities AS entity
5486 WHERE entity.project_instance_id = ?1 AND entity.entity_key = ?2
5487 AND entity.entity_kind IN ('file', 'symbol', 'package')
5488 ON CONFLICT(project_instance_id, entity_key, resolution_domain, key_digest)
5489 DO UPDATE SET owner_path = excluded.owner_path",
5490 )?;
5491 let mut insert_dependency = connection.prepare_cached(
5492 "INSERT INTO graph_relation_dependencies(
5493 project_instance_id, relation_key, owner_path, resolution_domain, key_digest
5494 )
5495 SELECT relation.project_instance_id, relation.relation_key,
5496 CASE source.entity_kind
5497 WHEN 'file' THEN source.repository_path
5498 WHEN 'symbol' THEN source.repository_path
5499 WHEN 'package' THEN source.manifest_path
5500 END,
5501 ?3, ?4
5502 FROM graph_relations AS relation
5503 JOIN graph_entities AS source
5504 ON source.project_instance_id = relation.project_instance_id
5505 AND source.entity_key = relation.source_entity_key
5506 WHERE relation.project_instance_id = ?1 AND relation.relation_key = ?2
5507 AND source.entity_kind IN ('file', 'symbol', 'package')
5508 ON CONFLICT(project_instance_id, relation_key, resolution_domain, key_digest)
5509 DO UPDATE SET owner_path = excluded.owner_path",
5510 )?;
5511
5512 for binding in entity_exports {
5513 insert_resolution_key(&mut insert_key, binding.key())?;
5514 let key = binding.key();
5515 let inserted = insert_export.execute(params![
5516 &project.as_bytes()[..],
5517 &binding.entity().digest_bytes()?[..],
5518 key.domain().as_str(),
5519 &key.digest_bytes()[..],
5520 ])?;
5521 if inserted != 1 {
5522 return Err(DbError::GraphRowShape {
5523 table: "graph_entity_exports",
5524 reason: "export keys require a local file, symbol, or package owner",
5525 });
5526 }
5527 }
5528 for binding in relation_dependencies {
5529 insert_resolution_key(&mut insert_key, binding.key())?;
5530 let key = binding.key();
5531 let inserted = insert_dependency.execute(params![
5532 &project.as_bytes()[..],
5533 &binding.relation().digest_bytes()?[..],
5534 key.domain().as_str(),
5535 &key.digest_bytes()[..],
5536 ])?;
5537 if inserted != 1 {
5538 return Err(DbError::GraphRowShape {
5539 table: "graph_relation_dependencies",
5540 reason: "dependency keys require a local file, symbol, or package source",
5541 });
5542 }
5543 }
5544 Ok(())
5545}
5546
5547fn insert_resolution_key(
5549 statement: &mut rusqlite::CachedStatement<'_>,
5550 key: &CanonicalResolutionKey,
5551) -> DbResult<()> {
5552 let inserted = statement.execute(params![
5553 &key.project().as_bytes()[..],
5554 key.domain().as_str(),
5555 &key.digest_bytes()[..],
5556 key.canonical_identity(),
5557 ])?;
5558 if inserted == 0 {
5559 return Err(DbError::ResolutionKeyCollision {
5560 domain: key.domain().as_str(),
5561 digest: key.digest_bytes(),
5562 });
5563 }
5564 Ok(())
5565}
5566
5567fn remove_touched_orphan_resolution_keys(
5569 connection: &Connection,
5570 keys: &BTreeSet<CanonicalResolutionKey>,
5571) -> DbResult<()> {
5572 let keys = keys.iter().cloned().collect::<Vec<_>>();
5573 for chunk in keys.chunks(RESOLUTION_KEYS_PER_QUERY) {
5574 if chunk.is_empty() {
5575 continue;
5576 }
5577 let values_clause = anonymous_values_clause(chunk.len(), 3);
5578 let sql = format!(
5579 "WITH touched(project_instance_id, resolution_domain, key_digest)
5580 AS (VALUES {values_clause})
5581 DELETE FROM graph_resolution_keys
5582 WHERE rowid IN (
5583 SELECT stored.rowid
5584 FROM graph_resolution_keys AS stored
5585 JOIN touched
5586 ON touched.project_instance_id = stored.project_instance_id
5587 AND touched.resolution_domain = stored.resolution_domain
5588 AND touched.key_digest = stored.key_digest
5589 WHERE NOT EXISTS (
5590 SELECT 1 FROM graph_entity_exports AS export
5591 WHERE export.project_instance_id = stored.project_instance_id
5592 AND export.resolution_domain = stored.resolution_domain
5593 AND export.key_digest = stored.key_digest
5594 )
5595 AND NOT EXISTS (
5596 SELECT 1 FROM graph_relation_dependencies AS dependency
5597 WHERE dependency.project_instance_id = stored.project_instance_id
5598 AND dependency.resolution_domain = stored.resolution_domain
5599 AND dependency.key_digest = stored.key_digest
5600 )
5601 )"
5602 );
5603 let values = resolution_key_values(chunk, false);
5604 connection.execute(&sql, params_from_iter(values.iter()))?;
5605 }
5606 Ok(())
5607}
5608
5609fn affected_external_candidates(
5611 connection: &Connection,
5612 affected_paths: &[RepositoryNodePath],
5613) -> DbResult<HashSet<[u8; 32]>> {
5614 let mut by_repository_path = connection.prepare_cached(
5615 "SELECT entity_key FROM graph_entities
5616 WHERE repository_path = ?1
5617 OR (repository_path >= ?2 AND repository_path < ?3)",
5618 )?;
5619 let mut by_manifest_path = connection.prepare_cached(
5620 "SELECT entity_key FROM graph_entities
5621 WHERE manifest_path = ?1 OR (manifest_path >= ?2 AND manifest_path < ?3)",
5622 )?;
5623 let mut local_keys = HashSet::new();
5624 for path in affected_paths {
5625 let path = path.as_str();
5626 let (descendant_start, descendant_end) = repository_descendant_bounds(path);
5627 for statement in [&mut by_repository_path, &mut by_manifest_path] {
5628 let mut rows = statement.query(params![path, descendant_start, descendant_end])?;
5629 while let Some(row) = rows.next()? {
5630 local_keys.insert(fixed_bytes::<32>(
5631 "graph_entities.entity_key",
5632 row.get::<_, Vec<u8>>(0)?,
5633 )?);
5634 }
5635 }
5636 }
5637
5638 let mut candidates = HashSet::new();
5639 let local_keys = local_keys.into_iter().collect::<Vec<_>>();
5640 for chunk in local_keys.chunks(EXTERNAL_CANDIDATE_KEYS_PER_QUERY) {
5641 let sql = external_candidate_batch_sql(chunk.len());
5642 let mut statement = connection.prepare_cached(&sql)?;
5643 let mut rows = statement.query(params_from_iter(chunk.iter().map(|key| &key[..])))?;
5644 while let Some(row) = rows.next()? {
5645 candidates.insert(fixed_bytes::<32>(
5646 "graph_entities.entity_key",
5647 row.get::<_, Vec<u8>>(0)?,
5648 )?);
5649 }
5650 }
5651 Ok(candidates)
5652}
5653
5654fn external_candidate_batch_sql(key_count: usize) -> String {
5656 let values = anonymous_values_clause(key_count, 1);
5657 format!(
5658 "WITH affected(entity_key) AS (VALUES {values})
5659 SELECT relation.target_entity_key
5660 FROM affected
5661 CROSS JOIN graph_relations AS relation
5662 INDEXED BY idx_graph_relations_source_kind
5663 JOIN graph_entities AS external
5664 ON external.entity_key = relation.target_entity_key
5665 WHERE relation.source_entity_key = affected.entity_key
5666 AND external.entity_kind = 'external'
5667 UNION ALL
5668 SELECT relation.source_entity_key
5669 FROM affected
5670 CROSS JOIN graph_relations AS relation
5671 INDEXED BY idx_graph_relations_target_kind
5672 JOIN graph_entities AS external
5673 ON external.entity_key = relation.source_entity_key
5674 WHERE relation.target_entity_key = affected.entity_key
5675 AND external.entity_kind = 'external'"
5676 )
5677}
5678
5679fn invalidate_repository_graph_paths(
5681 connection: &Connection,
5682 affected_paths: &[RepositoryNodePath],
5683 orphan_candidates: &mut HashSet<[u8; 32]>,
5684) -> DbResult<()> {
5685 let mut affected_relations = HashSet::new();
5686 let mut relation_occurrences = connection.prepare_cached(
5687 "SELECT relation_key FROM graph_relation_occurrences
5688 WHERE file_path = ?1 OR (file_path >= ?2 AND file_path < ?3)",
5689 )?;
5690 let mut occurrences = connection.prepare_cached(
5691 "DELETE FROM graph_relation_occurrences
5692 WHERE file_path = ?1 OR (file_path >= ?2 AND file_path < ?3)",
5693 )?;
5694 let mut coverage = connection.prepare_cached(
5695 "DELETE FROM graph_coverage
5696 INDEXED BY idx_graph_coverage_path
5697 WHERE scope_kind = 'path'
5698 AND (scope_path = ?1 OR (scope_path >= ?2 AND scope_path < ?3))",
5699 )?;
5700 let mut entities_by_path = connection.prepare_cached(
5701 "DELETE FROM graph_entities
5702 WHERE repository_path = ?1
5703 OR (repository_path >= ?2 AND repository_path < ?3)",
5704 )?;
5705 let mut entities_by_manifest = connection.prepare_cached(
5706 "DELETE FROM graph_entities
5707 WHERE manifest_path = ?1 OR (manifest_path >= ?2 AND manifest_path < ?3)",
5708 )?;
5709 for path in affected_paths {
5710 let path = path.as_str();
5711 let (descendant_start, descendant_end) = repository_descendant_bounds(path);
5712 let mut rows =
5713 relation_occurrences.query(params![path, descendant_start, descendant_end])?;
5714 while let Some(row) = rows.next()? {
5715 affected_relations.insert(fixed_bytes::<32>(
5716 "graph_relation_occurrences.relation_key",
5717 row.get::<_, Vec<u8>>(0)?,
5718 )?);
5719 }
5720 occurrences.execute(params![path, descendant_start, descendant_end])?;
5721 coverage.execute(params![path, descendant_start, descendant_end])?;
5722 entities_by_path.execute(params![path, descendant_start, descendant_end])?;
5723 entities_by_manifest.execute(params![path, descendant_start, descendant_end])?;
5724 }
5725 collect_external_relation_endpoints(connection, &affected_relations, orphan_candidates)?;
5726 let mut relation = connection.prepare_cached(
5727 "DELETE FROM graph_relations
5728 WHERE relation_key = ?1
5729 AND NOT EXISTS (
5730 SELECT 1 FROM graph_relation_occurrences
5731 WHERE relation_key = ?1
5732 )",
5733 )?;
5734 for relation_key in affected_relations {
5735 relation.execute([&relation_key[..]])?;
5736 }
5737 Ok(())
5738}
5739
5740fn collect_external_relation_endpoints(
5742 connection: &Connection,
5743 relation_keys: &HashSet<[u8; 32]>,
5744 candidates: &mut HashSet<[u8; 32]>,
5745) -> DbResult<()> {
5746 let mut endpoints = connection.prepare_cached(
5747 "SELECT source_entity_key, target_entity_key
5748 FROM graph_relations
5749 WHERE relation_key = ?1",
5750 )?;
5751 let mut is_external = connection.prepare_cached(
5752 "SELECT EXISTS(
5753 SELECT 1 FROM graph_entities
5754 WHERE entity_key = ?1 AND entity_kind = 'external'
5755 )",
5756 )?;
5757 for relation_key in relation_keys {
5758 let endpoints = endpoints
5759 .query_row([&relation_key[..]], |row| {
5760 Ok((row.get::<_, Vec<u8>>(0)?, row.get::<_, Option<Vec<u8>>>(1)?))
5761 })
5762 .optional()?;
5763 let Some((source, target)) = endpoints else {
5764 continue;
5765 };
5766 for endpoint in [Some(source), target].into_iter().flatten() {
5767 let endpoint = fixed_bytes::<32>("graph_relations endpoint", endpoint)?;
5768 if is_external.query_row([&endpoint[..]], |row| row.get::<_, bool>(0))? {
5769 candidates.insert(endpoint);
5770 }
5771 }
5772 }
5773 Ok(())
5774}
5775
5776fn remove_orphan_external_candidates(
5778 connection: &Connection,
5779 candidates: &HashSet<[u8; 32]>,
5780) -> DbResult<()> {
5781 let mut statement = connection.prepare_cached(
5782 "DELETE FROM graph_entities
5783 WHERE entity_key = ?1 AND entity_kind = 'external'
5784 AND NOT EXISTS (
5785 SELECT 1 FROM graph_relations INDEXED BY idx_graph_relations_source_kind
5786 WHERE source_entity_key = ?1
5787 )
5788 AND NOT EXISTS (
5789 SELECT 1 FROM graph_relations INDEXED BY idx_graph_relations_target_kind
5790 WHERE target_entity_key = ?1
5791 )",
5792 )?;
5793 for candidate in candidates {
5794 statement.execute([&candidate[..]])?;
5795 }
5796 Ok(())
5797}
5798
5799fn repository_descendant_bounds(path: &str) -> (String, String) {
5801 (format!("{path}/"), format!("{path}0"))
5802}
5803
5804fn validate_graph_batch(
5806 project: ProjectInstanceId,
5807 generation: IndexGeneration,
5808 entities: &[GraphEntity],
5809 relations: &[LogicalRelation],
5810 occurrences: &[RelationOccurrence],
5811 coverage: &[CoverageRecord],
5812) -> DbResult<()> {
5813 if entities
5814 .iter()
5815 .any(|entity| entity.key().project() != project)
5816 || relations
5817 .iter()
5818 .any(|relation| relation.key().project() != project)
5819 || occurrences
5820 .iter()
5821 .any(|occurrence| occurrence.relation().project() != project)
5822 {
5823 return Err(DbError::GraphProjectIdentityMismatch {
5824 expected: project.to_string(),
5825 found: "record from another project".to_string(),
5826 });
5827 }
5828 if entities
5829 .iter()
5830 .any(|entity| entity.generation() != generation)
5831 || relations
5832 .iter()
5833 .any(|relation| relation.generation() != generation)
5834 || occurrences
5835 .iter()
5836 .any(|occurrence| occurrence.generation() != generation)
5837 || coverage
5838 .iter()
5839 .any(|record| record.generation() != generation)
5840 {
5841 return Err(
5842 projectatlas_core::graph::GraphContractError::GenerationMismatch {
5843 context: "repository graph publication batch",
5844 }
5845 .into(),
5846 );
5847 }
5848 Ok(())
5849}
5850
5851fn insert_graph_batch(
5853 connection: &Connection,
5854 project: ProjectInstanceId,
5855 entities: &[GraphEntity],
5856 relations: &[LogicalRelation],
5857 occurrences: &[RelationOccurrence],
5858 coverage: &[CoverageRecord],
5859) -> DbResult<()> {
5860 insert_entities(connection, project, entities)?;
5861 insert_relations(connection, project, relations)?;
5862 insert_occurrences(connection, occurrences)?;
5863 insert_coverage(connection, project, coverage)
5864}
5865
5866fn insert_entities<'entity>(
5868 connection: &Connection,
5869 project: ProjectInstanceId,
5870 entities: impl IntoIterator<Item = &'entity GraphEntity>,
5871) -> DbResult<()> {
5872 let mut insert = connection.prepare_cached(
5873 "INSERT INTO graph_entities(
5874 entity_key, project_instance_id, canonical_identity, entity_kind,
5875 repository_path, package_manager, package_name, manifest_path,
5876 symbol_name, symbol_kind, symbol_parent, symbol_signature,
5877 external_system, external_identity
5878 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)
5879 ON CONFLICT(entity_key) DO NOTHING",
5880 )?;
5881 let mut existing = connection.prepare_cached(
5882 "SELECT project_instance_id, canonical_identity
5883 FROM graph_entities WHERE entity_key = ?1",
5884 )?;
5885 for entity in entities {
5886 let columns = entity_columns(entity.selector());
5887 let key = entity.key().digest_bytes()?;
5888 insert.execute(params![
5889 &key[..],
5890 &project.as_bytes()[..],
5891 entity.key().canonical_identity(),
5892 columns.kind,
5893 columns.repository_path,
5894 columns.package_manager,
5895 columns.package_name,
5896 columns.manifest_path,
5897 columns.symbol_name,
5898 columns.symbol_kind,
5899 columns.symbol_parent,
5900 columns.symbol_signature,
5901 columns.external_system,
5902 columns.external_identity,
5903 ])?;
5904 let (stored_project, stored_canonical): (Vec<u8>, String) =
5905 existing.query_row([&key[..]], |row| Ok((row.get(0)?, row.get(1)?)))?;
5906 if fixed_bytes::<16>("graph_entities.project_instance_id", stored_project)?
5907 != project.as_bytes()
5908 || stored_canonical != entity.key().canonical_identity()
5909 {
5910 return Err(
5911 projectatlas_core::graph::GraphContractError::StableKeyCollision {
5912 digest: entity.key().digest().to_string(),
5913 }
5914 .into(),
5915 );
5916 }
5917 }
5918 Ok(())
5919}
5920
5921fn insert_relations(
5923 connection: &Connection,
5924 project: ProjectInstanceId,
5925 relations: &[LogicalRelation],
5926) -> DbResult<()> {
5927 let mut statement = connection.prepare_cached(
5928 "INSERT INTO graph_relations(
5929 relation_key, project_instance_id, canonical_identity, source_entity_key,
5930 relation_scope, relation_kind, resolution_status, target_entity_key,
5931 reference_text, candidate_count, confidence, completeness
5932 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
5933 ON CONFLICT(relation_key) DO UPDATE SET
5934 confidence = excluded.confidence,
5935 completeness = excluded.completeness
5936 WHERE graph_relations.project_instance_id = excluded.project_instance_id
5937 AND graph_relations.canonical_identity = excluded.canonical_identity
5938 AND graph_relations.source_entity_key = excluded.source_entity_key
5939 AND graph_relations.relation_scope = excluded.relation_scope
5940 AND graph_relations.relation_kind = excluded.relation_kind
5941 AND graph_relations.resolution_status = excluded.resolution_status
5942 AND graph_relations.target_entity_key IS excluded.target_entity_key
5943 AND graph_relations.reference_text IS excluded.reference_text
5944 AND graph_relations.candidate_count IS excluded.candidate_count",
5945 )?;
5946 for relation in relations {
5947 let (scope, kind) = relation_parts(relation.kind());
5948 let resolution = resolution_columns(relation.resolution())?;
5949 let key = relation.key().digest_bytes()?;
5950 let source = relation.source().digest_bytes()?;
5951 let changed = statement.execute(params![
5952 &key[..],
5953 &project.as_bytes()[..],
5954 relation.key().canonical_identity(),
5955 &source[..],
5956 scope,
5957 kind,
5958 resolution.status,
5959 resolution.target.as_ref().map(|target| &target[..]),
5960 resolution.reference,
5961 resolution.candidate_count,
5962 confidence_name(relation.confidence()),
5963 completeness_name(relation.completeness()),
5964 ])?;
5965 if changed == 0 {
5966 return Err(
5967 projectatlas_core::graph::GraphContractError::StableKeyCollision {
5968 digest: relation.key().digest().to_string(),
5969 }
5970 .into(),
5971 );
5972 }
5973 }
5974 Ok(())
5975}
5976
5977fn insert_occurrences(connection: &Connection, occurrences: &[RelationOccurrence]) -> DbResult<()> {
5979 let mut statement = connection.prepare_cached(
5980 "INSERT INTO graph_relation_occurrences(
5981 relation_key, file_path, start_line, start_column, end_line, end_column
5982 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)
5983 ON CONFLICT(relation_key, file_path, start_line, start_column, end_line, end_column)
5984 DO NOTHING",
5985 )?;
5986 for occurrence in occurrences {
5987 let key = occurrence.relation().digest_bytes()?;
5988 let span = occurrence.span();
5989 statement.execute(params![
5990 &key[..],
5991 occurrence.file().as_str(),
5992 i64::from(span.start_line()),
5993 i64::from(span.start_column()),
5994 i64::from(span.end_line()),
5995 i64::from(span.end_column()),
5996 ])?;
5997 }
5998 Ok(())
5999}
6000
6001fn insert_coverage(
6003 connection: &Connection,
6004 project: ProjectInstanceId,
6005 coverage: &[CoverageRecord],
6006) -> DbResult<()> {
6007 let mut remove = connection.prepare_cached(
6008 "DELETE FROM graph_coverage
6009 WHERE project_instance_id = ?1 AND scope_kind = ?2 AND scope_path IS ?3
6010 AND relation_scope IS ?4 AND relation_kind IS ?5",
6011 )?;
6012 let mut insert = connection.prepare_cached(
6013 "INSERT INTO graph_coverage(
6014 project_instance_id, scope_kind, scope_path, relation_scope, relation_kind,
6015 state, total, covered, omitted, reason, reached_limit
6016 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
6017 )?;
6018 for record in coverage {
6019 let (scope_kind, scope_path) = coverage_scope_parts(record.scope());
6020 let (relation_scope, relation_kind) = record
6021 .relation()
6022 .map(relation_parts)
6023 .map_or((None, None), |(scope, kind)| (Some(scope), Some(kind)));
6024 let values = params![
6025 &project.as_bytes()[..],
6026 scope_kind,
6027 scope_path,
6028 relation_scope,
6029 relation_kind,
6030 ];
6031 remove.execute(values)?;
6032 insert.execute(params![
6033 &project.as_bytes()[..],
6034 scope_kind,
6035 scope_path,
6036 relation_scope,
6037 relation_kind,
6038 coverage_state_name(record.state()),
6039 sqlite_count("graph_coverage.total", record.total())?,
6040 sqlite_count("graph_coverage.covered", record.covered())?,
6041 sqlite_count("graph_coverage.omitted", record.omitted())?,
6042 record.reason().map(GraphIdentityText::as_str),
6043 record.reached_limit().map(GraphLimitKind::as_str),
6044 ])?;
6045 }
6046 Ok(())
6047}
6048
6049fn prepare_graph_identity_rejections(
6056 connection: &Connection,
6057 project: ProjectInstanceId,
6058 generation: IndexGeneration,
6059 affected_paths: Option<&[RepositoryNodePath]>,
6060) -> DbResult<()> {
6061 let generation = sqlite_count("graph_identity_rejections.generation", generation.get())?;
6062 let project_bytes = project.as_bytes();
6063 match affected_paths {
6064 None => {
6065 connection.execute(
6066 "DELETE FROM graph_identity_rejections WHERE project_instance_id = ?1",
6067 [project_bytes.as_slice()],
6068 )?;
6069 }
6070 Some(paths) => {
6071 connection.execute(
6072 "UPDATE graph_identity_rejections
6073 SET generation = ?2
6074 WHERE project_instance_id = ?1",
6075 params![project_bytes.as_slice(), generation],
6076 )?;
6077 let mut remove = connection.prepare_cached(
6078 "DELETE FROM graph_identity_rejections
6079 WHERE project_instance_id = ?1
6080 AND generation = ?2
6081 AND (file_path = ?3 OR (file_path >= ?4 AND file_path < ?5))",
6082 )?;
6083 for path in paths {
6084 let (descendant_start, descendant_end) =
6085 repository_descendant_bounds(path.as_str());
6086 remove.execute(params![
6087 project_bytes.as_slice(),
6088 generation,
6089 path.as_str(),
6090 descendant_start,
6091 descendant_end,
6092 ])?;
6093 }
6094 }
6095 }
6096 Ok(())
6097}
6098
6099fn insert_graph_identity_rejections(
6101 connection: &Connection,
6102 project: ProjectInstanceId,
6103 generation: IndexGeneration,
6104 rejections: &[GraphIdentityRejection],
6105) -> DbResult<()> {
6106 let mut insert = connection.prepare_cached(
6107 "INSERT INTO graph_identity_rejections(
6108 project_instance_id, generation, file_path,
6109 start_line, start_column, end_line, end_column,
6110 parser, field, reason, fact_index
6111 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
6112 ON CONFLICT(
6113 project_instance_id, generation, file_path, start_line, start_column,
6114 end_line, end_column, parser, field, reason, fact_index
6115 ) DO NOTHING",
6116 )?;
6117 let generation = sqlite_count("graph_identity_rejections.generation", generation.get())?;
6118 for rejection in rejections {
6119 let span = rejection.span;
6120 insert.execute(params![
6121 &project.as_bytes()[..],
6122 generation,
6123 rejection.path.as_str(),
6124 i64::from(span.start_line()),
6125 i64::from(span.start_column()),
6126 i64::from(span.end_line()),
6127 i64::from(span.end_column()),
6128 rejection.parser.to_string(),
6129 rejection.field.to_string(),
6130 rejection.reason.to_string(),
6131 i64::try_from(rejection.fact_index).map_err(|_error| {
6132 GraphContractError::InvalidLimits {
6133 reason: "graph identity rejection fact index exceeded SQLite integer range",
6134 }
6135 })?,
6136 ])?;
6137 }
6138 Ok(())
6139}
6140
6141struct EntityColumns<'selector> {
6143 kind: &'static str,
6145 repository_path: Option<&'selector str>,
6147 package_manager: Option<&'selector str>,
6149 package_name: Option<&'selector str>,
6151 manifest_path: Option<&'selector str>,
6153 symbol_name: Option<&'selector str>,
6155 symbol_kind: Option<&'static str>,
6157 symbol_parent: Option<&'selector str>,
6159 symbol_signature: Option<&'selector str>,
6161 external_system: Option<&'selector str>,
6163 external_identity: Option<&'selector str>,
6165}
6166
6167fn entity_columns(selector: &EntitySelector) -> EntityColumns<'_> {
6169 match selector {
6170 EntitySelector::Project => EntityColumns {
6171 kind: "project",
6172 repository_path: None,
6173 package_manager: None,
6174 package_name: None,
6175 manifest_path: None,
6176 symbol_name: None,
6177 symbol_kind: None,
6178 symbol_parent: None,
6179 symbol_signature: None,
6180 external_system: None,
6181 external_identity: None,
6182 },
6183 EntitySelector::Folder { path } => EntityColumns {
6184 kind: "folder",
6185 repository_path: Some(path.as_str()),
6186 package_manager: None,
6187 package_name: None,
6188 manifest_path: None,
6189 symbol_name: None,
6190 symbol_kind: None,
6191 symbol_parent: None,
6192 symbol_signature: None,
6193 external_system: None,
6194 external_identity: None,
6195 },
6196 EntitySelector::File { path } => EntityColumns {
6197 kind: "file",
6198 repository_path: Some(path.as_str()),
6199 package_manager: None,
6200 package_name: None,
6201 manifest_path: None,
6202 symbol_name: None,
6203 symbol_kind: None,
6204 symbol_parent: None,
6205 symbol_signature: None,
6206 external_system: None,
6207 external_identity: None,
6208 },
6209 EntitySelector::Package { package } => EntityColumns {
6210 kind: "package",
6211 repository_path: None,
6212 package_manager: Some(package.manager.as_str()),
6213 package_name: Some(package.name.as_str()),
6214 manifest_path: Some(package.manifest.as_str()),
6215 symbol_name: None,
6216 symbol_kind: None,
6217 symbol_parent: None,
6218 symbol_signature: None,
6219 external_system: None,
6220 external_identity: None,
6221 },
6222 EntitySelector::Symbol { symbol } => EntityColumns {
6223 kind: "symbol",
6224 repository_path: Some(symbol.file.as_str()),
6225 package_manager: None,
6226 package_name: None,
6227 manifest_path: None,
6228 symbol_name: Some(symbol.name.as_str()),
6229 symbol_kind: Some(symbol_kind_name(symbol.kind)),
6230 symbol_parent: symbol.parent.as_ref().map(GraphIdentityText::as_str),
6231 symbol_signature: Some(symbol.signature.as_str()),
6232 external_system: None,
6233 external_identity: None,
6234 },
6235 EntitySelector::External { external } => EntityColumns {
6236 kind: "external",
6237 repository_path: None,
6238 package_manager: None,
6239 package_name: None,
6240 manifest_path: None,
6241 symbol_name: None,
6242 symbol_kind: None,
6243 symbol_parent: None,
6244 symbol_signature: None,
6245 external_system: Some(external.system.as_str()),
6246 external_identity: Some(external.identity.as_str()),
6247 },
6248 }
6249}
6250
6251struct ResolutionColumns<'resolution> {
6253 status: &'static str,
6255 target: Option<[u8; 32]>,
6257 reference: Option<&'resolution str>,
6259 candidate_count: Option<i64>,
6261}
6262
6263fn resolution_columns(resolution: &RelationResolution) -> DbResult<ResolutionColumns<'_>> {
6265 match resolution {
6266 RelationResolution::Resolved { target, .. } => Ok(ResolutionColumns {
6267 status: "resolved",
6268 target: Some(target.digest_bytes()?),
6269 reference: None,
6270 candidate_count: None,
6271 }),
6272 RelationResolution::Ambiguous {
6273 reference,
6274 candidates,
6275 } => Ok(ResolutionColumns {
6276 status: "ambiguous",
6277 target: None,
6278 reference: Some(reference.as_str()),
6279 candidate_count: Some(i64::from(candidates.get())),
6280 }),
6281 RelationResolution::Unresolved { reference } => Ok(ResolutionColumns {
6282 status: "unresolved",
6283 target: None,
6284 reference: Some(reference.as_str()),
6285 candidate_count: None,
6286 }),
6287 RelationResolution::External { target, .. } => Ok(ResolutionColumns {
6288 status: "external",
6289 target: Some(target.digest_bytes()?),
6290 reference: None,
6291 candidate_count: None,
6292 }),
6293 }
6294}
6295
6296fn entity_from_row(
6298 row: EntityRow,
6299 expected_project: ProjectInstanceId,
6300 generation: IndexGeneration,
6301) -> DbResult<GraphEntity> {
6302 let project = project_from_blob("graph_entities.project_instance_id", row.project.clone())?;
6303 require_project(expected_project, project)?;
6304 validate_entity_row_shape(&row)?;
6305 let selector = match row.kind.as_str() {
6306 "project" => EntitySelector::Project,
6307 "folder" => EntitySelector::Folder {
6308 path: RepositoryNodePath::new(Path::new(required_text(
6309 "graph_entities",
6310 "folder path is missing",
6311 row.repository_path.as_deref(),
6312 )?))?,
6313 },
6314 "file" => EntitySelector::File {
6315 path: RepositoryFilePath::new(Path::new(required_text(
6316 "graph_entities",
6317 "file path is missing",
6318 row.repository_path.as_deref(),
6319 )?))?,
6320 },
6321 "package" => EntitySelector::Package {
6322 package: PackageSelector {
6323 manager: GraphIdentityText::new(required_text(
6324 "graph_entities",
6325 "package manager is missing",
6326 row.package_manager.as_deref(),
6327 )?)?,
6328 name: GraphIdentityText::new(required_text(
6329 "graph_entities",
6330 "package name is missing",
6331 row.package_name.as_deref(),
6332 )?)?,
6333 manifest: RepositoryFilePath::new(Path::new(required_text(
6334 "graph_entities",
6335 "package manifest is missing",
6336 row.manifest_path.as_deref(),
6337 )?))?,
6338 },
6339 },
6340 "symbol" => EntitySelector::Symbol {
6341 symbol: SymbolSelector {
6342 file: RepositoryFilePath::new(Path::new(required_text(
6343 "graph_entities",
6344 "symbol file is missing",
6345 row.repository_path.as_deref(),
6346 )?))?,
6347 name: GraphIdentityText::new(required_text(
6348 "graph_entities",
6349 "symbol name is missing",
6350 row.symbol_name.as_deref(),
6351 )?)?,
6352 kind: parse_symbol_kind(required_text(
6353 "graph_entities",
6354 "symbol kind is missing",
6355 row.symbol_kind.as_deref(),
6356 )?)?,
6357 parent: row.symbol_parent.map(GraphIdentityText::new).transpose()?,
6358 signature: GraphIdentityText::new(required_text(
6359 "graph_entities",
6360 "symbol signature is missing",
6361 row.symbol_signature.as_deref(),
6362 )?)?,
6363 },
6364 },
6365 "external" => EntitySelector::External {
6366 external: ExternalSelector {
6367 system: GraphIdentityText::new(required_text(
6368 "graph_entities",
6369 "external system is missing",
6370 row.external_system.as_deref(),
6371 )?)?,
6372 identity: GraphIdentityText::new(required_text(
6373 "graph_entities",
6374 "external identity is missing",
6375 row.external_identity.as_deref(),
6376 )?)?,
6377 },
6378 },
6379 value => {
6380 return Err(DbError::InvalidEnum {
6381 field: "graph_entities.entity_kind",
6382 value: value.to_string(),
6383 });
6384 }
6385 };
6386 let entity = GraphEntity::new(project, selector, generation)?;
6387 validate_entity_key(&entity, row.key, &row.canonical)?;
6388 Ok(entity)
6389}
6390
6391fn validate_entity_row_shape(row: &EntityRow) -> DbResult<()> {
6393 let repository = row.repository_path.is_some();
6394 let package = (
6395 row.package_manager.is_some(),
6396 row.package_name.is_some(),
6397 row.manifest_path.is_some(),
6398 );
6399 let symbol = (
6400 row.symbol_name.is_some(),
6401 row.symbol_kind.is_some(),
6402 row.symbol_parent.is_some(),
6403 row.symbol_signature.is_some(),
6404 );
6405 let external = (
6406 row.external_system.is_some(),
6407 row.external_identity.is_some(),
6408 );
6409 let valid = match row.kind.as_str() {
6410 "project" => {
6411 !repository
6412 && package == (false, false, false)
6413 && symbol == (false, false, false, false)
6414 && external == (false, false)
6415 }
6416 "folder" | "file" => {
6417 repository
6418 && package == (false, false, false)
6419 && symbol == (false, false, false, false)
6420 && external == (false, false)
6421 }
6422 "package" => {
6423 !repository
6424 && package == (true, true, true)
6425 && symbol == (false, false, false, false)
6426 && external == (false, false)
6427 }
6428 "symbol" => {
6429 repository
6430 && package == (false, false, false)
6431 && symbol.0
6432 && symbol.1
6433 && symbol.3
6434 && external == (false, false)
6435 }
6436 "external" => {
6437 !repository
6438 && package == (false, false, false)
6439 && symbol == (false, false, false, false)
6440 && external == (true, true)
6441 }
6442 _ => true,
6443 };
6444 if !valid {
6445 return Err(DbError::GraphRowShape {
6446 table: "graph_entities",
6447 reason: "selector columns contradict entity kind",
6448 });
6449 }
6450 Ok(())
6451}
6452
6453fn resolved_relation_hub_keys_sql() -> &'static str {
6455 "WITH endpoints(entity_key) AS (
6456 SELECT relation.source_entity_key
6457 FROM graph_relations AS relation INDEXED BY idx_graph_relations_kind_resolution
6458 WHERE relation.project_instance_id = ?1
6459 AND relation.relation_scope = ?2
6460 AND relation.relation_kind = ?3
6461 AND relation.resolution_status = ?4
6462 AND relation.target_entity_key IS NOT NULL
6463 AND relation.source_entity_key <> relation.target_entity_key
6464 UNION ALL
6465 SELECT relation.target_entity_key
6466 FROM graph_relations AS relation INDEXED BY idx_graph_relations_kind_resolution
6467 WHERE relation.project_instance_id = ?1
6468 AND relation.relation_scope = ?2
6469 AND relation.relation_kind = ?3
6470 AND relation.resolution_status = ?4
6471 AND relation.target_entity_key IS NOT NULL
6472 AND relation.source_entity_key <> relation.target_entity_key
6473 ), endpoint_degrees(entity_key, degree) AS (
6474 SELECT entity_key, COUNT(*)
6475 FROM endpoints
6476 GROUP BY entity_key
6477 )
6478 SELECT entity_key
6479 FROM endpoint_degrees
6480 ORDER BY endpoint_degrees.degree DESC, endpoint_degrees.entity_key
6481 LIMIT ?5"
6482}
6483
6484fn is_default<T: Default + PartialEq>(value: &T) -> bool {
6486 value == &T::default()
6487}
6488
6489fn adjacency_relation_sql(
6491 frontier_count: usize,
6492 direction: RepositoryGraphDirection,
6493 continuation_index: Option<usize>,
6494 relation_filter: bool,
6495 resolved_only: bool,
6496 include_documents: bool,
6497) -> String {
6498 let (key_column, index_name) = match direction {
6499 RepositoryGraphDirection::Outbound => {
6500 ("source_entity_key", "idx_graph_relations_source_kind")
6501 }
6502 RepositoryGraphDirection::Inbound => {
6503 ("target_entity_key", "idx_graph_relations_target_kind")
6504 }
6505 };
6506 let request = if resolved_only {
6507 "request(project_instance_id, resolution_status) AS (VALUES (?, ?))"
6508 } else {
6509 "request(project_instance_id) AS (VALUES (?))"
6510 };
6511 let document_filter = if relation_filter || include_documents {
6512 ""
6513 } else {
6514 "AND NOT (
6515 relation.relation_scope = 'extended'
6516 AND relation.relation_kind = 'documents'
6517 )"
6518 };
6519 let relation_filter = if relation_filter {
6520 "AND relation.relation_scope = ? AND relation.relation_kind = ?"
6521 } else {
6522 ""
6523 };
6524 let resolution_filter = if resolved_only {
6525 "AND relation.resolution_status = request.resolution_status
6526 AND relation.target_entity_key IS NOT NULL
6527 AND relation.source_entity_key <> relation.target_entity_key"
6528 } else {
6529 ""
6530 };
6531 let branches = (continuation_index.unwrap_or(0)..frontier_count)
6532 .map(|frontier_index| {
6533 let continuation = if continuation_index == Some(frontier_index) {
6534 "AND (relation.relation_scope, relation.relation_kind,
6535 relation.relation_key) > (?, ?, ?)"
6536 .to_string()
6537 } else {
6538 String::new()
6539 };
6540 format!(
6541 "SELECT * FROM (
6542 SELECT {frontier_index} AS frontier_index,
6543 relation.relation_key, relation.project_instance_id,
6544 relation.canonical_identity, relation.source_entity_key,
6545 relation.relation_scope, relation.relation_kind,
6546 relation.resolution_status, relation.target_entity_key,
6547 relation.reference_text, relation.candidate_count,
6548 relation.document_unresolved_reason,
6549 relation.confidence, relation.completeness
6550 FROM graph_relations AS relation INDEXED BY {index_name}
6551 CROSS JOIN request
6552 WHERE relation.{key_column} = ?
6553 AND relation.project_instance_id = request.project_instance_id
6554 {relation_filter}
6555 {resolution_filter}
6556 {document_filter}
6557 {continuation}
6558 ORDER BY relation.relation_scope, relation.relation_kind,
6559 relation.relation_key
6560 LIMIT ?
6561 )"
6562 )
6563 })
6564 .collect::<Vec<_>>()
6565 .join(" UNION ALL ");
6566 format!(
6567 "WITH {request}
6568 {branches}
6569 ORDER BY frontier_index, relation_scope, relation_kind,
6570 relation_key
6571 LIMIT ?"
6572 )
6573}
6574
6575fn graph_values_clause(rows: usize, columns: usize) -> String {
6577 let row = format!("({})", vec!["?"; columns].join(", "));
6578 vec![row; rows].join(", ")
6579}
6580
6581fn graph_entity_hydration_sql(entity_count: usize) -> String {
6583 format!(
6584 "WITH requested(entity_key) AS (VALUES {})
6585 SELECT entity.entity_key, entity.project_instance_id,
6586 entity.canonical_identity, entity.entity_kind,
6587 entity.repository_path, entity.package_manager,
6588 entity.package_name, entity.manifest_path,
6589 entity.symbol_name, entity.symbol_kind,
6590 entity.symbol_parent, entity.symbol_signature,
6591 entity.external_system, entity.external_identity
6592 FROM requested
6593 JOIN graph_entities AS entity
6594 ON entity.entity_key = requested.entity_key
6595 WHERE entity.project_instance_id = ?",
6596 graph_values_clause(entity_count, 1),
6597 )
6598}
6599
6600fn graph_relation_hydration_sql(relation_count: usize) -> String {
6602 format!(
6603 "WITH requested(relation_key) AS (VALUES {})
6604 SELECT relation.relation_key, relation.project_instance_id,
6605 relation.canonical_identity, relation.source_entity_key,
6606 relation.relation_scope, relation.relation_kind,
6607 relation.resolution_status, relation.target_entity_key,
6608 relation.reference_text, relation.candidate_count,
6609 relation.document_unresolved_reason,
6610 relation.confidence, relation.completeness
6611 FROM requested
6612 JOIN graph_relations AS relation INDEXED BY idx_graph_relations_project_key
6613 ON relation.relation_key = requested.relation_key
6614 AND relation.project_instance_id = ?",
6615 graph_values_clause(relation_count, 1),
6616 )
6617}
6618
6619fn classified_relation_family_sql(selection: ContentSelection) -> String {
6621 let selection_predicate = match selection {
6622 ContentSelection::UnspecifiedLegacy => "",
6623 ContentSelection::Source | ContentSelection::Documentation => {
6624 "AND source_classification.classification = ?"
6625 }
6626 ContentSelection::Both => "AND source_classification.classification IN (?, ?)",
6627 };
6628 format!(
6629 "SELECT relation.relation_key, relation.project_instance_id,
6630 relation.canonical_identity, relation.source_entity_key,
6631 relation.relation_scope, relation.relation_kind,
6632 relation.resolution_status, relation.target_entity_key,
6633 relation.reference_text, relation.candidate_count,
6634 relation.document_unresolved_reason,
6635 relation.confidence, relation.completeness,
6636 source_classification.classification
6637 FROM graph_relations AS relation INDEXED BY idx_graph_relations_kind_order
6638 JOIN graph_entities AS source
6639 ON source.project_instance_id = relation.project_instance_id
6640 AND source.entity_key = relation.source_entity_key
6641 LEFT JOIN file_content_classifications AS source_classification
6642 ON source_classification.path = CASE source.entity_kind
6643 WHEN 'file' THEN source.repository_path
6644 WHEN 'symbol' THEN source.repository_path
6645 WHEN 'package' THEN source.manifest_path
6646 END
6647 WHERE relation.project_instance_id = ?
6648 AND relation.relation_scope = ?
6649 AND relation.relation_kind = ?
6650 {selection_predicate}
6651 ORDER BY relation.relation_key
6652 LIMIT ?"
6653 )
6654}
6655
6656fn occurrence_pages_sql(relation_count: usize) -> String {
6658 let branches = (0..relation_count)
6659 .map(|index| {
6660 format!(
6661 "SELECT {index} AS relation_index, relation_key, file_path,
6662 start_line, start_column, end_line, end_column
6663 FROM (
6664 SELECT relation_key, file_path, start_line, start_column,
6665 end_line, end_column
6666 FROM graph_relation_occurrences
6667 WHERE relation_key = ?
6668 ORDER BY file_path, start_line, start_column,
6669 end_line, end_column
6670 LIMIT ?
6671 ) AS occurrence_page_{index}"
6672 )
6673 })
6674 .collect::<Vec<_>>()
6675 .join(" UNION ALL ");
6676 format!(
6677 "{branches}
6678 ORDER BY relation_index, file_path, start_line, start_column,
6679 end_line, end_column"
6680 )
6681}
6682
6683fn path_coverage_sql(path_count: usize) -> String {
6685 let path_bindings = vec!["?"; path_count].join(", ");
6686 format!(
6687 "SELECT project_instance_id, scope_kind, scope_path, relation_scope,
6688 relation_kind, state, total, covered, omitted, reason, reached_limit,
6689 NULL, NULL
6690 FROM graph_coverage
6691 WHERE project_instance_id = ?
6692 AND scope_kind = ?
6693 AND scope_path IN ({path_bindings})
6694 ORDER BY scope_path, relation_scope, relation_kind, state, id
6695 LIMIT ?"
6696 )
6697}
6698
6699fn load_relation_entities(
6701 store: &AtlasStore,
6702 rows: &[RelationRow],
6703 project: ProjectInstanceId,
6704 generation: IndexGeneration,
6705 control: Option<&IndexWorkControl>,
6706) -> DbResult<HashMap<[u8; 32], GraphEntity>> {
6707 let references = rows.iter().collect::<Vec<_>>();
6708 load_relation_entity_references(store, &references, project, generation, control)
6709}
6710
6711fn load_relation_entity_references(
6713 store: &AtlasStore,
6714 rows: &[&RelationRow],
6715 project: ProjectInstanceId,
6716 generation: IndexGeneration,
6717 control: Option<&IndexWorkControl>,
6718) -> DbResult<HashMap<[u8; 32], GraphEntity>> {
6719 load_relation_entity_references_metered(store, rows, project, generation, control, None)
6720}
6721
6722fn load_relation_entity_references_metered(
6724 store: &AtlasStore,
6725 rows: &[&RelationRow],
6726 project: ProjectInstanceId,
6727 generation: IndexGeneration,
6728 control: Option<&IndexWorkControl>,
6729 meter: Option<&mut RepositoryGraphReadMeter>,
6730) -> DbResult<HashMap<[u8; 32], GraphEntity>> {
6731 let mut digests = BTreeSet::new();
6732 for row in rows {
6733 let row_project =
6734 project_from_blob("graph_relations.project_instance_id", row.project.clone())?;
6735 require_project(project, row_project)?;
6736 digests.insert(fixed_bytes::<32>(
6737 "graph_relations.source_entity_key",
6738 row.source.clone(),
6739 )?);
6740 if let Some(target) = &row.target {
6741 digests.insert(fixed_bytes::<32>(
6742 "graph_relations.target_entity_key",
6743 target.clone(),
6744 )?);
6745 }
6746 }
6747 load_graph_entities_by_digest_metered(
6748 store,
6749 &digests.into_iter().collect::<Vec<_>>(),
6750 project,
6751 generation,
6752 control,
6753 meter,
6754 )
6755}
6756
6757fn load_graph_entities_by_digest_metered(
6759 store: &AtlasStore,
6760 digests: &[[u8; 32]],
6761 project: ProjectInstanceId,
6762 generation: IndexGeneration,
6763 control: Option<&IndexWorkControl>,
6764 mut meter: Option<&mut RepositoryGraphReadMeter>,
6765) -> DbResult<HashMap<[u8; 32], GraphEntity>> {
6766 let mut entities = HashMap::with_capacity(digests.len());
6767 for chunk in digests.chunks(GRAPH_ENTITY_HYDRATION_CHUNK) {
6768 if let Some(control) = control {
6769 control.check(IndexWorkStage::RepositoryTraversal)?;
6770 }
6771 let sql = graph_entity_hydration_sql(chunk.len());
6772 let mut bindings = chunk
6773 .iter()
6774 .map(|digest| Value::Blob(digest.to_vec()))
6775 .collect::<Vec<_>>();
6776 bindings.push(Value::Blob(project.as_bytes().to_vec()));
6777 let raw = with_sqlite_read_progress(
6778 &store.connection,
6779 control,
6780 IndexWorkStage::RepositoryTraversal,
6781 || {
6782 let mut statement = store.connection.prepare(&sql)?;
6783 let rows = statement.query(params_from_iter(bindings.iter()))?;
6784 if let Some(meter) = meter.as_deref_mut() {
6785 collect_entity_rows_metered(rows, meter)
6786 } else {
6787 collect_entity_rows(rows)
6788 }
6789 },
6790 )?;
6791 for row in raw {
6792 let entity = entity_from_row(row, project, generation)?;
6793 let digest = entity.key().digest_bytes()?;
6794 if entities.contains_key(&digest) {
6795 return Err(DbError::GraphRowShape {
6796 table: "graph_entities",
6797 reason: "batched entity hydration returned a duplicate key",
6798 });
6799 }
6800 if let Some(meter) = meter.as_deref_mut() {
6801 meter.record_entity(&entity)?;
6802 }
6803 entities.insert(digest, entity);
6804 }
6805 }
6806 Ok(entities)
6807}
6808
6809fn maximum_repository_graph_read_budget() -> DbResult<RepositoryGraphReadBudget> {
6811 Ok(RepositoryGraphReadBudget::new(
6812 RepositoryGraphReadBudget::MAX_REQUESTED_ROWS,
6813 RepositoryGraphReadBudget::MAX_RETURNED_ROWS,
6814 RepositoryGraphReadBudget::MAX_DECODED_BYTES,
6815 RepositoryGraphReadBudget::MAX_HYDRATED_ENTITIES,
6816 RepositoryGraphReadBudget::MAX_HYDRATED_PATHS,
6817 )?)
6818}
6819
6820fn validate_path_coverage_request(paths: &[RepositoryNodePath]) -> DbResult<()> {
6822 if paths.len() > MAX_REPOSITORY_GRAPH_FRONTIER {
6823 return Err(GraphContractError::InvalidLimits {
6824 reason: "graph coverage path set exceeds the product ceiling",
6825 }
6826 .into());
6827 }
6828 if paths
6829 .iter()
6830 .map(RepositoryNodePath::as_str)
6831 .collect::<BTreeSet<_>>()
6832 .len()
6833 != paths.len()
6834 {
6835 return Err(GraphContractError::InvalidLimits {
6836 reason: "graph coverage paths must be unique",
6837 }
6838 .into());
6839 }
6840 Ok(())
6841}
6842
6843fn validate_graph_hydration_request(digests: &[[u8; 32]]) -> DbResult<()> {
6845 if digests.len() > MAX_REPOSITORY_GRAPH_FRONTIER {
6846 return Err(GraphContractError::InvalidLimits {
6847 reason: "graph hydration key set exceeds the product ceiling",
6848 }
6849 .into());
6850 }
6851 if digests.iter().copied().collect::<HashSet<_>>().len() != digests.len() {
6852 return Err(GraphContractError::InvalidLimits {
6853 reason: "graph hydration keys must be unique",
6854 }
6855 .into());
6856 }
6857 Ok(())
6858}
6859
6860fn graph_entity_purpose_owner(entity: &GraphEntity) -> Option<&str> {
6862 match entity.selector() {
6863 EntitySelector::Project => Some("."),
6864 EntitySelector::Folder { path } => Some(path.as_str()),
6865 EntitySelector::File { path } => Some(path.as_str()),
6866 EntitySelector::Package { package } => Some(package.manifest.as_str()),
6867 EntitySelector::Symbol { symbol } => Some(symbol.file.as_str()),
6868 EntitySelector::External { .. } => None,
6869 }
6870}
6871
6872fn graph_entity_classification_owner(entity: &GraphEntity) -> Option<&str> {
6874 match entity.selector() {
6875 EntitySelector::File { path } => Some(path.as_str()),
6876 EntitySelector::Package { package } => Some(package.manifest.as_str()),
6877 EntitySelector::Symbol { symbol } => Some(symbol.file.as_str()),
6878 EntitySelector::Project
6879 | EntitySelector::Folder { .. }
6880 | EntitySelector::External { .. } => None,
6881 }
6882}
6883
6884fn classified_relation_detail_from_row(
6886 entities: &HashMap<[u8; 32], GraphEntity>,
6887 row: ClassifiedRelationRow,
6888 expected_project: ProjectInstanceId,
6889 generation: IndexGeneration,
6890) -> DbResult<RepositoryGraphClassifiedRelationRow> {
6891 let source_classification = row
6892 .source_classification
6893 .map(parse_classification)
6894 .transpose()?;
6895 let detail = relation_detail_from_row(entities, row.relation, expected_project, generation)?;
6896 match (
6897 graph_entity_classification_owner(&detail.source),
6898 source_classification,
6899 ) {
6900 (Some(path), None) => Err(DbError::FileContentClassificationMissing {
6901 path: path.to_string(),
6902 }),
6903 (None, Some(_)) => Err(DbError::GraphRowShape {
6904 table: "file_content_classifications",
6905 reason: "non-file-bearing graph source retained a classification",
6906 }),
6907 (_, source_classification) => Ok(RepositoryGraphClassifiedRelationRow {
6908 detail,
6909 source_classification,
6910 }),
6911 }
6912}
6913
6914fn relation_detail_from_row(
6916 entities: &HashMap<[u8; 32], GraphEntity>,
6917 row: RelationRow,
6918 expected_project: ProjectInstanceId,
6919 generation: IndexGeneration,
6920) -> DbResult<RepositoryGraphRelationRow> {
6921 let source_key = fixed_bytes::<32>("graph_relations.source_entity_key", row.source.clone())?;
6922 let target_key = row
6923 .target
6924 .as_ref()
6925 .map(|target| fixed_bytes::<32>("graph_relations.target_entity_key", target.clone()))
6926 .transpose()?;
6927 let document_unresolved_reason = document_unresolved_reason_from_row(&row)?;
6928 let relation = relation_from_row(entities, row, expected_project, generation)?;
6929 let source = entities
6930 .get(&source_key)
6931 .cloned()
6932 .ok_or(DbError::GraphRowShape {
6933 table: "graph_relations",
6934 reason: "source entity is missing",
6935 })?;
6936 let target = target_key
6937 .map(|key| {
6938 entities.get(&key).cloned().ok_or(DbError::GraphRowShape {
6939 table: "graph_relations",
6940 reason: "retained target entity is missing",
6941 })
6942 })
6943 .transpose()?;
6944 Ok(RepositoryGraphRelationRow {
6945 relation,
6946 source,
6947 target,
6948 document_unresolved_reason,
6949 })
6950}
6951
6952fn relation_from_row(
6954 entities: &HashMap<[u8; 32], GraphEntity>,
6955 row: RelationRow,
6956 expected_project: ProjectInstanceId,
6957 generation: IndexGeneration,
6958) -> DbResult<LogicalRelation> {
6959 let _document_unresolved_reason = document_unresolved_reason_from_row(&row)?;
6960 let project = project_from_blob("graph_relations.project_instance_id", row.project.clone())?;
6961 require_project(expected_project, project)?;
6962 let source_key = fixed_bytes::<32>("graph_relations.source_entity_key", row.source.clone())?;
6963 let source = entities
6964 .get(&source_key)
6965 .cloned()
6966 .ok_or(DbError::GraphRowShape {
6967 table: "graph_relations",
6968 reason: "source entity is missing",
6969 })?;
6970 let kind = parse_relation_kind(&row.relation_scope, &row.relation_kind)?;
6971 let resolution = match row.resolution_status.as_str() {
6972 "resolved" => {
6973 require_relation_resolution_shape(&row, true, false, false)?;
6974 let target_key = fixed_bytes::<32>(
6975 "graph_relations.target_entity_key",
6976 row.target.clone().ok_or(DbError::GraphRowShape {
6977 table: "graph_relations",
6978 reason: "resolved target is missing",
6979 })?,
6980 )?;
6981 let target = entities
6982 .get(&target_key)
6983 .cloned()
6984 .ok_or(DbError::GraphRowShape {
6985 table: "graph_relations",
6986 reason: "resolved target entity is missing",
6987 })?;
6988 RelationResolution::resolved(&target)?
6989 }
6990 "external" => {
6991 require_relation_resolution_shape(&row, true, false, false)?;
6992 let target_key = fixed_bytes::<32>(
6993 "graph_relations.target_entity_key",
6994 row.target.clone().ok_or(DbError::GraphRowShape {
6995 table: "graph_relations",
6996 reason: "external target is missing",
6997 })?,
6998 )?;
6999 let target = entities
7000 .get(&target_key)
7001 .cloned()
7002 .ok_or(DbError::GraphRowShape {
7003 table: "graph_relations",
7004 reason: "external target entity is missing",
7005 })?;
7006 RelationResolution::external(&target)?
7007 }
7008 "ambiguous" => {
7009 require_relation_resolution_shape(&row, false, true, true)?;
7010 let candidates = positive_u32(
7011 "graph_relations.candidate_count",
7012 row.candidate_count.ok_or(DbError::GraphRowShape {
7013 table: "graph_relations",
7014 reason: "ambiguous candidate count is missing",
7015 })?,
7016 )?;
7017 RelationResolution::Ambiguous {
7018 reference: GraphIdentityText::new(row.reference.clone().ok_or(
7019 DbError::GraphRowShape {
7020 table: "graph_relations",
7021 reason: "ambiguous reference is missing",
7022 },
7023 )?)?,
7024 candidates,
7025 }
7026 }
7027 "unresolved" => {
7028 require_relation_resolution_shape(&row, false, true, false)?;
7029 RelationResolution::Unresolved {
7030 reference: GraphIdentityText::new(row.reference.clone().ok_or(
7031 DbError::GraphRowShape {
7032 table: "graph_relations",
7033 reason: "unresolved reference is missing",
7034 },
7035 )?)?,
7036 }
7037 }
7038 value => {
7039 return Err(DbError::InvalidEnum {
7040 field: "graph_relations.resolution_status",
7041 value: value.to_string(),
7042 });
7043 }
7044 };
7045 let relation = LogicalRelation::new(
7046 &source,
7047 kind,
7048 resolution,
7049 parse_confidence(&row.confidence)?,
7050 parse_completeness(&row.completeness)?,
7051 generation,
7052 )?;
7053 validate_relation_key(&relation, row.key, &row.canonical)?;
7054 Ok(relation)
7055}
7056
7057fn document_unresolved_reason_from_row(
7059 row: &RelationRow,
7060) -> DbResult<Option<DocumentTargetUnresolvedReason>> {
7061 match (
7062 row.relation_scope.as_str(),
7063 row.relation_kind.as_str(),
7064 row.resolution_status.as_str(),
7065 row.document_unresolved_reason.as_deref(),
7066 ) {
7067 ("extended", "documents", "unresolved", Some(value)) => {
7068 DocumentTargetUnresolvedReason::from_db(value)
7069 .map(Some)
7070 .ok_or_else(|| DbError::InvalidEnum {
7071 field: "graph_relations.document_unresolved_reason",
7072 value: value.to_string(),
7073 })
7074 }
7075 ("extended", "documents", "unresolved", None) => Err(DbError::GraphRowShape {
7076 table: "graph_relations",
7077 reason: "unresolved document relation is missing its closed reason",
7078 }),
7079 (_, _, _, None) => Ok(None),
7080 _ => Err(DbError::GraphRowShape {
7081 table: "graph_relations",
7082 reason: "document unresolved reason contradicts relation family or status",
7083 }),
7084 }
7085}
7086
7087fn require_relation_resolution_shape(
7089 row: &RelationRow,
7090 target_required: bool,
7091 reference_required: bool,
7092 candidates_required: bool,
7093) -> DbResult<()> {
7094 let valid = row.target.is_some() == target_required
7095 && row.reference.is_some() == reference_required
7096 && row.candidate_count.is_some() == candidates_required;
7097 if !valid {
7098 return Err(DbError::GraphRowShape {
7099 table: "graph_relations",
7100 reason: "resolution columns contradict status",
7101 });
7102 }
7103 Ok(())
7104}
7105
7106fn occurrence_from_row(
7108 row: OccurrenceRow,
7109 relation: &LogicalRelation,
7110 generation: IndexGeneration,
7111) -> DbResult<RelationOccurrence> {
7112 let stored_key = fixed_bytes::<32>("graph_relation_occurrences.relation_key", row.relation)?;
7113 if stored_key != relation.key().digest_bytes()? {
7114 return Err(DbError::GraphRowShape {
7115 table: "graph_relation_occurrences",
7116 reason: "occurrence relation key does not match query",
7117 });
7118 }
7119 RelationOccurrence::new(
7120 relation,
7121 RepositoryFilePath::new(Path::new(&row.file_path))?,
7122 SourceSpan::new(
7123 positive_u32_value("graph_relation_occurrences.start_line", row.start_line)?,
7124 nonnegative_u32("graph_relation_occurrences.start_column", row.start_column)?,
7125 positive_u32_value("graph_relation_occurrences.end_line", row.end_line)?,
7126 nonnegative_u32("graph_relation_occurrences.end_column", row.end_column)?,
7127 )?,
7128 generation,
7129 )
7130 .map_err(Into::into)
7131}
7132
7133fn coverage_from_row(
7135 row: CoverageRow,
7136 expected_project: ProjectInstanceId,
7137 generation: IndexGeneration,
7138) -> DbResult<CoverageRecord> {
7139 let project = project_from_blob("graph_coverage.project_instance_id", row.project)?;
7140 require_project(expected_project, project)?;
7141 let scope = match (row.scope_kind.as_str(), row.scope_path) {
7142 ("project", None) => CoverageScope::Project,
7143 ("path", Some(path)) => CoverageScope::Path {
7144 path: RepositoryNodePath::new(Path::new(&path))?,
7145 },
7146 ("project" | "path", _) => {
7147 return Err(DbError::GraphRowShape {
7148 table: "graph_coverage",
7149 reason: "scope columns contradict scope kind",
7150 });
7151 }
7152 (value, _) => {
7153 return Err(DbError::InvalidEnum {
7154 field: "graph_coverage.scope_kind",
7155 value: value.to_string(),
7156 });
7157 }
7158 };
7159 let relation = match (row.relation_scope, row.relation_kind) {
7160 (None, None) => None,
7161 (Some(scope), Some(kind)) => Some(parse_relation_kind(&scope, &kind)?),
7162 _ => {
7163 return Err(DbError::GraphRowShape {
7164 table: "graph_coverage",
7165 reason: "relation scope and kind must both be present or absent",
7166 });
7167 }
7168 };
7169 let persisted_total = nonnegative_u64("graph_coverage.total", row.total)?;
7170 let covered = nonnegative_u64("graph_coverage.covered", row.covered)?;
7171 let omitted = nonnegative_u64("graph_coverage.omitted", row.omitted)?;
7172 let persisted_state = parse_coverage_state(&row.state)?;
7173 let state = if persisted_state == CoverageState::Complete
7174 && covered == 0
7175 && omitted == 0
7176 && relation == Some(GraphRelationKind::Extended(ExtendedRelationKind::Documents))
7177 {
7178 CoverageState::NoCandidates
7179 } else {
7180 persisted_state
7181 };
7182 let record = CoverageRecord::new(
7183 scope,
7184 relation,
7185 state,
7186 covered,
7187 omitted,
7188 generation,
7189 row.reason.map(GraphIdentityText::new).transpose()?,
7190 row.reached_limit
7191 .as_deref()
7192 .map(parse_limit_kind)
7193 .transpose()?,
7194 )?;
7195 if record.total() != persisted_total {
7196 return Err(DbError::GraphRowShape {
7197 table: "graph_coverage",
7198 reason: "total does not equal covered plus omitted",
7199 });
7200 }
7201 Ok(record)
7202}
7203
7204fn coverage_discovery_from_row(
7206 row: CoverageRow,
7207 expected_project: ProjectInstanceId,
7208 generation: IndexGeneration,
7209) -> DbResult<RepositoryCoverageRow> {
7210 let parser = row
7211 .parser
7212 .as_deref()
7213 .map(|value| parse_parser_kind("source_parse_metadata.source_parser", value))
7214 .transpose()?;
7215 let provider = row
7216 .provider
7217 .as_deref()
7218 .map(|value| parse_parser_kind("source_parse_metadata.fact_parser", value))
7219 .transpose()?;
7220 let coverage = coverage_from_row(row, expected_project, generation)?;
7221 Ok(RepositoryCoverageRow {
7222 coverage,
7223 parser,
7224 provider,
7225 })
7226}
7227
7228fn require_project(expected: ProjectInstanceId, found: ProjectInstanceId) -> DbResult<()> {
7230 if expected != found {
7231 return Err(DbError::GraphProjectIdentityMismatch {
7232 expected: expected.to_string(),
7233 found: found.to_string(),
7234 });
7235 }
7236 Ok(())
7237}
7238
7239fn validate_entity_key(
7241 entity: &GraphEntity,
7242 stored_key: Vec<u8>,
7243 stored_canonical: &str,
7244) -> DbResult<()> {
7245 let stored_key = fixed_bytes::<32>("graph_entities.entity_key", stored_key)?;
7246 if stored_key != entity.key().digest_bytes()? {
7247 return Err(projectatlas_core::graph::GraphContractError::InvalidStableKeyDigest.into());
7248 }
7249 if stored_canonical != entity.key().canonical_identity() {
7250 return Err(
7251 projectatlas_core::graph::GraphContractError::StableKeyCollision {
7252 digest: entity.key().digest().to_string(),
7253 }
7254 .into(),
7255 );
7256 }
7257 Ok(())
7258}
7259
7260fn validate_relation_key(
7262 relation: &LogicalRelation,
7263 stored_key: Vec<u8>,
7264 stored_canonical: &str,
7265) -> DbResult<()> {
7266 let stored_key = fixed_bytes::<32>("graph_relations.relation_key", stored_key)?;
7267 if stored_key != relation.key().digest_bytes()? {
7268 return Err(projectatlas_core::graph::GraphContractError::InvalidStableKeyDigest.into());
7269 }
7270 if stored_canonical != relation.key().canonical_identity() {
7271 return Err(
7272 projectatlas_core::graph::GraphContractError::StableKeyCollision {
7273 digest: relation.key().digest().to_string(),
7274 }
7275 .into(),
7276 );
7277 }
7278 Ok(())
7279}
7280
7281fn collect_entity_rows(mut rows: rusqlite::Rows<'_>) -> DbResult<Vec<EntityRow>> {
7283 let mut collected = Vec::new();
7284 while let Some(row) = rows.next()? {
7285 collected.push(entity_row(row)?);
7286 }
7287 Ok(collected)
7288}
7289
7290fn collect_entity_rows_metered(
7292 mut rows: rusqlite::Rows<'_>,
7293 meter: &mut RepositoryGraphReadMeter,
7294) -> DbResult<Vec<EntityRow>> {
7295 let mut collected = Vec::new();
7296 while let Some(row) = rows.next()? {
7297 let raw = entity_row(row)?;
7298 meter.record_decoded_bytes(entity_row_decoded_bytes(&raw)?)?;
7299 collected.push(raw);
7300 }
7301 Ok(collected)
7302}
7303
7304fn entity_row(row: &Row<'_>) -> rusqlite::Result<EntityRow> {
7306 Ok(EntityRow {
7307 key: row.get(0)?,
7308 project: row.get(1)?,
7309 canonical: row.get(2)?,
7310 kind: row.get(3)?,
7311 repository_path: row.get(4)?,
7312 package_manager: row.get(5)?,
7313 package_name: row.get(6)?,
7314 manifest_path: row.get(7)?,
7315 symbol_name: row.get(8)?,
7316 symbol_kind: row.get(9)?,
7317 symbol_parent: row.get(10)?,
7318 symbol_signature: row.get(11)?,
7319 external_system: row.get(12)?,
7320 external_identity: row.get(13)?,
7321 })
7322}
7323
7324fn entity_row_decoded_bytes(row: &EntityRow) -> DbResult<u64> {
7326 decoded_payload_bytes(
7327 [
7328 row.key.len(),
7329 row.project.len(),
7330 row.canonical.len(),
7331 row.kind.len(),
7332 row.repository_path.as_ref().map_or(0, String::len),
7333 row.package_manager.as_ref().map_or(0, String::len),
7334 row.package_name.as_ref().map_or(0, String::len),
7335 row.manifest_path.as_ref().map_or(0, String::len),
7336 row.symbol_name.as_ref().map_or(0, String::len),
7337 row.symbol_kind.as_ref().map_or(0, String::len),
7338 row.symbol_parent.as_ref().map_or(0, String::len),
7339 row.symbol_signature.as_ref().map_or(0, String::len),
7340 row.external_system.as_ref().map_or(0, String::len),
7341 row.external_identity.as_ref().map_or(0, String::len),
7342 ],
7343 0,
7344 )
7345}
7346
7347fn collect_relation_rows(mut rows: rusqlite::Rows<'_>) -> DbResult<Vec<RelationRow>> {
7349 let mut collected = Vec::new();
7350 while let Some(row) = rows.next()? {
7351 collected.push(relation_row(row)?);
7352 }
7353 Ok(collected)
7354}
7355
7356fn collect_classified_relation_rows(
7358 mut rows: rusqlite::Rows<'_>,
7359) -> DbResult<Vec<ClassifiedRelationRow>> {
7360 let mut collected = Vec::new();
7361 while let Some(row) = rows.next()? {
7362 collected.push(ClassifiedRelationRow {
7363 relation: relation_row_at(row, 0)?,
7364 source_classification: row.get(13)?,
7365 });
7366 }
7367 Ok(collected)
7368}
7369
7370fn collect_relation_rows_metered(
7372 mut rows: rusqlite::Rows<'_>,
7373 meter: &mut RepositoryGraphReadMeter,
7374) -> DbResult<Vec<RelationRow>> {
7375 let mut collected = Vec::new();
7376 while let Some(row) = rows.next()? {
7377 let raw = relation_row(row)?;
7378 meter.record_decoded_bytes(relation_row_decoded_bytes(&raw)?)?;
7379 collected.push(raw);
7380 }
7381 Ok(collected)
7382}
7383
7384fn collect_adjacency_relation_rows_metered(
7386 mut rows: rusqlite::Rows<'_>,
7387 meter: &mut RepositoryGraphReadMeter,
7388) -> DbResult<Vec<AdjacencyRelationRow>> {
7389 let mut collected = Vec::new();
7390 while let Some(row) = rows.next()? {
7391 let raw = AdjacencyRelationRow {
7392 frontier_index: row.get(0)?,
7393 relation: relation_row_at(row, 1)?,
7394 };
7395 meter.record_decoded_bytes(
7396 relation_row_decoded_bytes(&raw.relation)?
7397 .checked_add(8)
7398 .ok_or(GraphContractError::InvalidLimits {
7399 reason: "graph adjacency decoded row size overflowed",
7400 })?,
7401 )?;
7402 collected.push(raw);
7403 }
7404 Ok(collected)
7405}
7406
7407fn relation_row(row: &Row<'_>) -> rusqlite::Result<RelationRow> {
7409 relation_row_at(row, 0)
7410}
7411
7412fn relation_row_at(row: &Row<'_>, offset: usize) -> rusqlite::Result<RelationRow> {
7414 Ok(RelationRow {
7415 key: row.get(offset)?,
7416 project: row.get(offset + 1)?,
7417 canonical: row.get(offset + 2)?,
7418 source: row.get(offset + 3)?,
7419 relation_scope: row.get(offset + 4)?,
7420 relation_kind: row.get(offset + 5)?,
7421 resolution_status: row.get(offset + 6)?,
7422 target: row.get(offset + 7)?,
7423 reference: row.get(offset + 8)?,
7424 candidate_count: row.get(offset + 9)?,
7425 document_unresolved_reason: row.get(offset + 10)?,
7426 confidence: row.get(offset + 11)?,
7427 completeness: row.get(offset + 12)?,
7428 })
7429}
7430
7431fn relation_row_decoded_bytes(row: &RelationRow) -> DbResult<u64> {
7433 decoded_payload_bytes(
7434 [
7435 row.key.len(),
7436 row.project.len(),
7437 row.canonical.len(),
7438 row.source.len(),
7439 row.relation_scope.len(),
7440 row.relation_kind.len(),
7441 row.resolution_status.len(),
7442 row.target.as_ref().map_or(0, Vec::len),
7443 row.reference.as_ref().map_or(0, String::len),
7444 row.document_unresolved_reason
7445 .as_ref()
7446 .map_or(0, String::len),
7447 row.confidence.len(),
7448 row.completeness.len(),
7449 ],
7450 8,
7451 )
7452}
7453
7454fn decoded_payload_bytes(
7456 lengths: impl IntoIterator<Item = usize>,
7457 fixed_bytes: u64,
7458) -> DbResult<u64> {
7459 let mut decoded = fixed_bytes;
7460 for length in lengths {
7461 let length =
7462 u64::try_from(length).map_err(|_source| GraphContractError::InvalidLimits {
7463 reason: "graph read decoded field length overflowed",
7464 })?;
7465 decoded = decoded
7466 .checked_add(length)
7467 .ok_or(GraphContractError::InvalidLimits {
7468 reason: "graph read decoded row size overflowed",
7469 })?;
7470 }
7471 Ok(decoded)
7472}
7473
7474fn occurrence_row(row: &Row<'_>) -> rusqlite::Result<OccurrenceRow> {
7476 occurrence_row_at(row, 0)
7477}
7478
7479fn occurrence_row_at(row: &Row<'_>, offset: usize) -> rusqlite::Result<OccurrenceRow> {
7481 Ok(OccurrenceRow {
7482 relation: row.get(offset)?,
7483 file_path: row.get(offset + 1)?,
7484 start_line: row.get(offset + 2)?,
7485 start_column: row.get(offset + 3)?,
7486 end_line: row.get(offset + 4)?,
7487 end_column: row.get(offset + 5)?,
7488 })
7489}
7490
7491fn occurrence_row_decoded_bytes(row: &OccurrenceRow) -> DbResult<u64> {
7493 decoded_payload_bytes([row.relation.len(), row.file_path.len()], 32)
7494}
7495
7496fn coverage_row(row: &Row<'_>) -> rusqlite::Result<CoverageRow> {
7498 Ok(CoverageRow {
7499 project: row.get(0)?,
7500 scope_kind: row.get(1)?,
7501 scope_path: row.get(2)?,
7502 relation_scope: row.get(3)?,
7503 relation_kind: row.get(4)?,
7504 state: row.get(5)?,
7505 total: row.get(6)?,
7506 covered: row.get(7)?,
7507 omitted: row.get(8)?,
7508 reason: row.get(9)?,
7509 reached_limit: row.get(10)?,
7510 parser: row.get(11)?,
7511 provider: row.get(12)?,
7512 })
7513}
7514
7515fn coverage_row_decoded_bytes(row: &CoverageRow) -> DbResult<u64> {
7517 decoded_payload_bytes(
7518 [
7519 row.project.len(),
7520 row.scope_kind.len(),
7521 row.scope_path.as_ref().map_or(0, String::len),
7522 row.relation_scope.as_ref().map_or(0, String::len),
7523 row.relation_kind.as_ref().map_or(0, String::len),
7524 row.state.len(),
7525 row.reason.as_ref().map_or(0, String::len),
7526 row.reached_limit.as_ref().map_or(0, String::len),
7527 row.parser.as_ref().map_or(0, String::len),
7528 row.provider.as_ref().map_or(0, String::len),
7529 ],
7530 24,
7531 )
7532}
7533
7534fn page_from_raw<Raw, Domain>(
7536 raw: Vec<Raw>,
7537 limit: u32,
7538 mut convert: impl FnMut(Raw) -> DbResult<Domain>,
7539) -> DbResult<RepositoryGraphPage<Domain>> {
7540 let mut rows = raw
7541 .into_iter()
7542 .map(&mut convert)
7543 .collect::<DbResult<Vec<_>>>()?;
7544 let truncated = rows.len() > limit as usize;
7545 if truncated {
7546 rows.pop();
7547 }
7548 Ok(RepositoryGraphPage { rows, truncated })
7549}
7550
7551fn empty_page<T>() -> RepositoryGraphPage<T> {
7553 RepositoryGraphPage {
7554 rows: Vec::new(),
7555 truncated: false,
7556 }
7557}
7558
7559fn empty_adjacency_page() -> RepositoryGraphAdjacencyPage {
7561 RepositoryGraphAdjacencyPage {
7562 rows: Vec::new(),
7563 truncated: false,
7564 continuation: None,
7565 }
7566}
7567
7568const fn empty_affected_source_footprint() -> RepositoryAffectedSourceFootprint {
7570 RepositoryAffectedSourceFootprint {
7571 rows: 0,
7572 retained_bytes: 0,
7573 truncated: false,
7574 }
7575}
7576
7577fn validated_limit_plus_one(limit: u32, ceiling: u32, reason: &'static str) -> DbResult<i64> {
7579 if limit == 0 || limit > ceiling {
7580 return Err(projectatlas_core::graph::GraphContractError::InvalidLimits { reason }.into());
7581 }
7582 Ok(i64::from(limit) + 1)
7583}
7584
7585fn fixed_bytes<const WIDTH: usize>(field: &'static str, bytes: Vec<u8>) -> DbResult<[u8; WIDTH]> {
7587 let found = bytes.len();
7588 bytes
7589 .try_into()
7590 .map_err(|_bytes| DbError::InvalidBlobLength {
7591 field,
7592 expected: WIDTH,
7593 found,
7594 })
7595}
7596
7597fn project_from_blob(field: &'static str, bytes: Vec<u8>) -> DbResult<ProjectInstanceId> {
7599 ProjectInstanceId::from_bytes(fixed_bytes::<16>(field, bytes)?).map_err(Into::into)
7600}
7601
7602fn required_text<'value>(
7604 table: &'static str,
7605 reason: &'static str,
7606 value: Option<&'value str>,
7607) -> DbResult<&'value str> {
7608 value.ok_or(DbError::GraphRowShape { table, reason })
7609}
7610
7611fn nonnegative_u64(field: &'static str, value: i64) -> DbResult<u64> {
7613 u64::try_from(value).map_err(|source| DbError::InvalidCount {
7614 field,
7615 value,
7616 source,
7617 })
7618}
7619
7620fn positive_u32(field: &'static str, value: i64) -> DbResult<NonZeroU32> {
7622 let value = positive_u32_value(field, value)?;
7623 NonZeroU32::new(value).ok_or(DbError::GraphRowShape {
7624 table: "graph_relations",
7625 reason: "candidate count must be positive",
7626 })
7627}
7628
7629fn positive_u32_value(field: &'static str, value: i64) -> DbResult<u32> {
7631 let converted = u32::try_from(value).map_err(|source| DbError::InvalidCount {
7632 field,
7633 value,
7634 source,
7635 })?;
7636 if converted == 0 {
7637 return Err(DbError::GraphRowShape {
7638 table: "repository_graph",
7639 reason: "positive integer column contains zero",
7640 });
7641 }
7642 Ok(converted)
7643}
7644
7645fn nonnegative_u32(field: &'static str, value: i64) -> DbResult<u32> {
7647 u32::try_from(value).map_err(|source| DbError::InvalidCount {
7648 field,
7649 value,
7650 source,
7651 })
7652}
7653
7654fn sqlite_count(field: &'static str, value: u64) -> DbResult<i64> {
7656 i64::try_from(value).map_err(|_source| DbError::GraphCountOverflow { field, value })
7657}
7658
7659const fn relation_parts(relation: GraphRelationKind) -> (&'static str, &'static str) {
7661 match relation {
7662 GraphRelationKind::Legacy(RelationKind::Contains) => ("legacy", "contains"),
7663 GraphRelationKind::Legacy(RelationKind::Imports) => ("legacy", "imports"),
7664 GraphRelationKind::Legacy(RelationKind::Calls) => ("legacy", "calls"),
7665 GraphRelationKind::Legacy(RelationKind::DependsOn) => ("legacy", "depends-on"),
7666 GraphRelationKind::Extended(ExtendedRelationKind::References) => ("extended", "references"),
7667 GraphRelationKind::Extended(ExtendedRelationKind::Tests) => ("extended", "tests"),
7668 GraphRelationKind::Extended(ExtendedRelationKind::RoutesTo) => ("extended", "routes-to"),
7669 GraphRelationKind::Extended(ExtendedRelationKind::Configures) => ("extended", "configures"),
7670 GraphRelationKind::Extended(ExtendedRelationKind::Deploys) => ("extended", "deploys"),
7671 GraphRelationKind::Extended(ExtendedRelationKind::Reads) => ("extended", "reads"),
7672 GraphRelationKind::Extended(ExtendedRelationKind::Writes) => ("extended", "writes"),
7673 GraphRelationKind::Extended(ExtendedRelationKind::Documents) => ("extended", "documents"),
7674 }
7675}
7676
7677fn parse_relation_kind(scope: &str, kind: &str) -> DbResult<GraphRelationKind> {
7679 match (scope, kind) {
7680 ("legacy", "contains") => Ok(GraphRelationKind::Legacy(RelationKind::Contains)),
7681 ("legacy", "imports") => Ok(GraphRelationKind::Legacy(RelationKind::Imports)),
7682 ("legacy", "calls") => Ok(GraphRelationKind::Legacy(RelationKind::Calls)),
7683 ("legacy", "depends-on") => Ok(GraphRelationKind::Legacy(RelationKind::DependsOn)),
7684 ("extended", "references") => Ok(GraphRelationKind::Extended(
7685 ExtendedRelationKind::References,
7686 )),
7687 ("extended", "tests") => Ok(GraphRelationKind::Extended(ExtendedRelationKind::Tests)),
7688 ("extended", "routes-to") => {
7689 Ok(GraphRelationKind::Extended(ExtendedRelationKind::RoutesTo))
7690 }
7691 ("extended", "configures") => Ok(GraphRelationKind::Extended(
7692 ExtendedRelationKind::Configures,
7693 )),
7694 ("extended", "deploys") => Ok(GraphRelationKind::Extended(ExtendedRelationKind::Deploys)),
7695 ("extended", "reads") => Ok(GraphRelationKind::Extended(ExtendedRelationKind::Reads)),
7696 ("extended", "writes") => Ok(GraphRelationKind::Extended(ExtendedRelationKind::Writes)),
7697 ("extended", "documents") => {
7698 Ok(GraphRelationKind::Extended(ExtendedRelationKind::Documents))
7699 }
7700 _ => Err(DbError::InvalidEnum {
7701 field: "graph_relations.relation_kind",
7702 value: format!("{scope}:{kind}"),
7703 }),
7704 }
7705}
7706
7707const fn symbol_kind_name(kind: SymbolKind) -> &'static str {
7709 match kind {
7710 SymbolKind::Function => "function",
7711 SymbolKind::Method => "method",
7712 SymbolKind::Class => "class",
7713 SymbolKind::Struct => "struct",
7714 SymbolKind::Enum => "enum",
7715 SymbolKind::Trait => "trait",
7716 SymbolKind::Interface => "interface",
7717 SymbolKind::Module => "module",
7718 SymbolKind::Type => "type",
7719 SymbolKind::Value => "value",
7720 SymbolKind::Import => "import",
7721 SymbolKind::Package => "package",
7722 SymbolKind::Workspace => "workspace",
7723 SymbolKind::Dependency => "dependency",
7724 SymbolKind::Heading => "heading",
7725 SymbolKind::Unknown => "unknown",
7726 }
7727}
7728
7729fn parse_symbol_kind(value: &str) -> DbResult<SymbolKind> {
7731 match value {
7732 "function" => Ok(SymbolKind::Function),
7733 "method" => Ok(SymbolKind::Method),
7734 "class" => Ok(SymbolKind::Class),
7735 "struct" => Ok(SymbolKind::Struct),
7736 "enum" => Ok(SymbolKind::Enum),
7737 "trait" => Ok(SymbolKind::Trait),
7738 "interface" => Ok(SymbolKind::Interface),
7739 "module" => Ok(SymbolKind::Module),
7740 "type" => Ok(SymbolKind::Type),
7741 "value" => Ok(SymbolKind::Value),
7742 "import" => Ok(SymbolKind::Import),
7743 "package" => Ok(SymbolKind::Package),
7744 "workspace" => Ok(SymbolKind::Workspace),
7745 "dependency" => Ok(SymbolKind::Dependency),
7746 "heading" => Ok(SymbolKind::Heading),
7747 "unknown" => Ok(SymbolKind::Unknown),
7748 _ => Err(DbError::InvalidEnum {
7749 field: "graph_entities.symbol_kind",
7750 value: value.to_string(),
7751 }),
7752 }
7753}
7754
7755const fn confidence_name(confidence: ConfidenceClass) -> &'static str {
7757 match confidence {
7758 ConfidenceClass::Exact => "exact",
7759 ConfidenceClass::High => "high",
7760 ConfidenceClass::Medium => "medium",
7761 ConfidenceClass::Low => "low",
7762 }
7763}
7764
7765fn parse_confidence(value: &str) -> DbResult<ConfidenceClass> {
7767 match value {
7768 "exact" => Ok(ConfidenceClass::Exact),
7769 "high" => Ok(ConfidenceClass::High),
7770 "medium" => Ok(ConfidenceClass::Medium),
7771 "low" => Ok(ConfidenceClass::Low),
7772 _ => Err(DbError::InvalidEnum {
7773 field: "graph_relations.confidence",
7774 value: value.to_string(),
7775 }),
7776 }
7777}
7778
7779const fn completeness_name(completeness: Completeness) -> &'static str {
7781 match completeness {
7782 Completeness::Complete => "complete",
7783 Completeness::Partial => "partial",
7784 }
7785}
7786
7787fn parse_completeness(value: &str) -> DbResult<Completeness> {
7789 match value {
7790 "complete" => Ok(Completeness::Complete),
7791 "partial" => Ok(Completeness::Partial),
7792 _ => Err(DbError::InvalidEnum {
7793 field: "graph_relations.completeness",
7794 value: value.to_string(),
7795 }),
7796 }
7797}
7798
7799fn coverage_scope_parts(scope: &CoverageScope) -> (&'static str, Option<&str>) {
7801 match scope {
7802 CoverageScope::Project => ("project", None),
7803 CoverageScope::Path { path } => ("path", Some(path.as_str())),
7804 }
7805}
7806
7807const fn coverage_state_name(state: CoverageState) -> &'static str {
7809 match state {
7810 CoverageState::Complete | CoverageState::NoCandidates => "complete",
7811 CoverageState::Partial => "partial",
7812 CoverageState::Failed => "failed",
7813 CoverageState::Ignored => "ignored",
7814 CoverageState::Oversized => "oversized",
7815 CoverageState::Quarantined => "quarantined",
7816 CoverageState::Stale => "stale",
7817 }
7818}
7819
7820pub(crate) fn parse_coverage_state(value: &str) -> DbResult<CoverageState> {
7822 match value {
7823 "complete" => Ok(CoverageState::Complete),
7824 "partial" => Ok(CoverageState::Partial),
7825 "failed" => Ok(CoverageState::Failed),
7826 "ignored" => Ok(CoverageState::Ignored),
7827 "oversized" => Ok(CoverageState::Oversized),
7828 "quarantined" => Ok(CoverageState::Quarantined),
7829 "stale" => Ok(CoverageState::Stale),
7830 _ => Err(DbError::InvalidEnum {
7831 field: "graph_coverage.state",
7832 value: value.to_string(),
7833 }),
7834 }
7835}
7836
7837fn parse_parser_kind(field: &'static str, value: &str) -> DbResult<ParserKind> {
7839 match value {
7840 "tree-sitter" => Ok(ParserKind::TreeSitter),
7841 "manifest" => Ok(ParserKind::Manifest),
7842 "structural" => Ok(ParserKind::Structural),
7843 "fallback" => Ok(ParserKind::Fallback),
7844 _ => Err(DbError::InvalidEnum {
7845 field,
7846 value: value.to_string(),
7847 }),
7848 }
7849}
7850
7851fn parse_graph_identity_field(value: String) -> DbResult<GraphIdentityField> {
7853 match value.as_str() {
7854 "package" => Ok(GraphIdentityField::Package),
7855 "symbol" => Ok(GraphIdentityField::Symbol),
7856 "parent" => Ok(GraphIdentityField::Parent),
7857 "signature" => Ok(GraphIdentityField::Signature),
7858 "relation.source" => Ok(GraphIdentityField::RelationSource),
7859 "relation.target" => Ok(GraphIdentityField::RelationTarget),
7860 "resolution-key" => Ok(GraphIdentityField::ResolutionKey),
7861 _ => Err(DbError::InvalidEnum {
7862 field: "graph_identity_rejections.field",
7863 value,
7864 }),
7865 }
7866}
7867
7868fn parse_graph_identity_rejection_reason(value: String) -> DbResult<GraphIdentityRejectionReason> {
7870 match value.as_str() {
7871 "empty" => Ok(GraphIdentityRejectionReason::Empty),
7872 "surrounding-whitespace" => Ok(GraphIdentityRejectionReason::SurroundingWhitespace),
7873 "control-characters" => Ok(GraphIdentityRejectionReason::ControlCharacters),
7874 "oversized" => Ok(GraphIdentityRejectionReason::Oversized),
7875 "reserved-namespace" => Ok(GraphIdentityRejectionReason::ReservedNamespace),
7876 "contract" => Ok(GraphIdentityRejectionReason::Contract),
7877 _ => Err(DbError::InvalidEnum {
7878 field: "graph_identity_rejections.reason",
7879 value,
7880 }),
7881 }
7882}
7883
7884fn graph_identity_rejection_from_row(row: &Row<'_>) -> DbResult<GraphIdentityRejection> {
7886 let file_path = row.get::<_, String>(0)?;
7887 let start_line =
7888 u32::try_from(row.get::<_, i64>(1)?).map_err(|_error| DbError::GraphRowShape {
7889 table: "graph_identity_rejections",
7890 reason: "start line does not fit the graph span domain",
7891 })?;
7892 let start_column =
7893 u32::try_from(row.get::<_, i64>(2)?).map_err(|_error| DbError::GraphRowShape {
7894 table: "graph_identity_rejections",
7895 reason: "start column does not fit the graph span domain",
7896 })?;
7897 let end_line =
7898 u32::try_from(row.get::<_, i64>(3)?).map_err(|_error| DbError::GraphRowShape {
7899 table: "graph_identity_rejections",
7900 reason: "end line does not fit the graph span domain",
7901 })?;
7902 let end_column =
7903 u32::try_from(row.get::<_, i64>(4)?).map_err(|_error| DbError::GraphRowShape {
7904 table: "graph_identity_rejections",
7905 reason: "end column does not fit the graph span domain",
7906 })?;
7907 Ok(GraphIdentityRejection {
7908 path: RepositoryNodePath::new(Path::new(&file_path))?,
7909 span: SourceSpan::new(start_line, start_column, end_line, end_column)?,
7910 parser: parse_parser_kind(
7911 "graph_identity_rejections.parser",
7912 &row.get::<_, String>(5)?,
7913 )?,
7914 field: parse_graph_identity_field(row.get(6)?)?,
7915 reason: parse_graph_identity_rejection_reason(row.get(7)?)?,
7916 fact_index: u64::try_from(row.get::<_, i64>(8)?).map_err(|_error| {
7917 DbError::GraphRowShape {
7918 table: "graph_identity_rejections",
7919 reason: "fact index does not fit the graph identity domain",
7920 }
7921 })?,
7922 })
7923}
7924
7925pub(crate) fn parse_limit_kind(value: &str) -> DbResult<GraphLimitKind> {
7927 GraphLimitKind::from_stable_name(value).ok_or_else(|| DbError::InvalidEnum {
7928 field: "graph_coverage.reached_limit",
7929 value: value.to_string(),
7930 })
7931}
7932
7933#[cfg(test)]
7934mod tests {
7935 use super::*;
7936 use crate::IndexedFileText;
7937 use projectatlas_core::symbols::{CodeSymbol, ParserKind, SymbolGraph, SymbolRelation};
7938 use projectatlas_core::{IndexCancellation, Node, NodeKind};
7939 use std::cell::RefCell;
7940 use std::error::Error;
7941 use std::fmt::Debug;
7942 use std::fs;
7943 use std::io;
7944 use std::sync::{
7945 Arc,
7946 atomic::{AtomicUsize, Ordering},
7947 };
7948 use std::time::{Duration, Instant};
7949
7950 thread_local! {
7951 static TRACED_STATEMENTS: RefCell<Vec<String>> = const { RefCell::new(Vec::new()) };
7953 }
7954
7955 fn record_traced_statement(sql: &str) {
7957 TRACED_STATEMENTS.with(|statements| statements.borrow_mut().push(sql.to_string()));
7958 }
7959
7960 fn trace_statements<T>(
7962 store: &mut AtlasStore,
7963 operation: impl FnOnce(&AtlasStore) -> DbResult<T>,
7964 ) -> Result<(T, Vec<String>), Box<dyn Error>> {
7965 TRACED_STATEMENTS.with(|statements| statements.borrow_mut().clear());
7966 store.connection.trace_v2(
7967 rusqlite::trace::TraceEventCodes::SQLITE_TRACE_STMT,
7968 Some(record_traced_event),
7969 );
7970 let result = operation(store);
7971 store
7972 .connection
7973 .trace_v2(rusqlite::trace::TraceEventCodes::empty(), None);
7974 let statements =
7975 TRACED_STATEMENTS.with(|statements| std::mem::take(&mut *statements.borrow_mut()));
7976 Ok((result?, statements))
7977 }
7978
7979 #[allow(clippy::needless_pass_by_value)]
7981 fn record_traced_event(event: rusqlite::trace::TraceEvent<'_>) {
7982 if let rusqlite::trace::TraceEvent::Stmt(_, sql) = event {
7983 record_traced_statement(sql);
7984 }
7985 }
7986
7987 #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
7989 enum DetailedRelationTraceStatement {
7990 ProjectIdentity,
7992 PublicationMetadata,
7994 ActiveGraphGeneration,
7996 AdjacencyRelations,
7998 RelationsByDigest,
8000 RelationEntities,
8002 RelationOccurrences,
8004 PurposeOwners,
8006 }
8007
8008 fn classify_detailed_relation_statement(
8010 statement: &str,
8011 ) -> Option<DetailedRelationTraceStatement> {
8012 if statement
8013 .contains("SELECT project_instance_id FROM project_identity WHERE singleton = 1")
8014 {
8015 Some(DetailedRelationTraceStatement::ProjectIdentity)
8016 } else if statement.contains("SELECT state.value, fingerprint.value, generation.value")
8017 && statement.contains("FROM metadata AS state")
8018 {
8019 Some(DetailedRelationTraceStatement::PublicationMetadata)
8020 } else if statement
8021 .contains("SELECT active_generation FROM project_identity WHERE singleton = 1")
8022 {
8023 Some(DetailedRelationTraceStatement::ActiveGraphGeneration)
8024 } else if statement.contains("FROM graph_relations AS relation INDEXED BY")
8025 && statement.contains("idx_graph_relations_source_kind")
8026 {
8027 Some(DetailedRelationTraceStatement::AdjacencyRelations)
8028 } else if statement
8029 .contains("JOIN graph_relations AS relation INDEXED BY idx_graph_relations_project_key")
8030 {
8031 Some(DetailedRelationTraceStatement::RelationsByDigest)
8032 } else if statement.contains("JOIN graph_entities AS entity") {
8033 Some(DetailedRelationTraceStatement::RelationEntities)
8034 } else if statement.contains("FROM graph_relation_occurrences") {
8035 Some(DetailedRelationTraceStatement::RelationOccurrences)
8036 } else if statement.contains("FROM nodes n")
8037 && statement.contains("JOIN purposes p")
8038 && statement.contains("LEFT JOIN summaries s")
8039 {
8040 Some(DetailedRelationTraceStatement::PurposeOwners)
8041 } else {
8042 None
8043 }
8044 }
8045
8046 fn require_traced_statement_multiset(
8048 statements: &[String],
8049 expected: &[(DetailedRelationTraceStatement, usize)],
8050 context: &str,
8051 ) -> Result<(), Box<dyn Error>> {
8052 let mut actual = BTreeMap::new();
8053 for statement in statements {
8054 let family = classify_detailed_relation_statement(statement).ok_or_else(|| {
8055 io::Error::other(format!(
8056 "{context} executed an unclassified production statement: {statement}"
8057 ))
8058 })?;
8059 *actual.entry(family).or_insert(0) += 1;
8060 }
8061 let expected = expected.iter().copied().collect::<BTreeMap<_, _>>();
8062 require_eq(
8063 &actual,
8064 &expected,
8065 &format!("{context} complete statement multiset: {statements:?}"),
8066 )
8067 }
8068
8069 fn collect_bounded_outbound_calls(
8071 store: &AtlasStore,
8072 anchor: &GraphEntityKey,
8073 ) -> Result<(Vec<[u8; 32]>, usize, usize), Box<dyn Error>> {
8074 let mut visited = BTreeMap::new();
8075 visited.insert(anchor.digest_bytes()?, anchor.clone());
8076 let mut frontier = vec![anchor.clone()];
8077 let mut inspected_edges = 0_usize;
8078 let mut peak_frontier = frontier.len();
8079 let calls = GraphRelationKind::Legacy(RelationKind::Calls);
8080
8081 while !frontier.is_empty() {
8082 let mut next = BTreeMap::new();
8083 for chunk in frontier.chunks(MAX_REPOSITORY_GRAPH_FRONTIER) {
8084 let work_per_row = MAX_REPOSITORY_GRAPH_ADJACENCY_WORK_ROWS / chunk.len();
8085 let page_limit = work_per_row
8086 .saturating_sub(1)
8087 .min(GraphLimits::MAX_ROWS as usize)
8088 .max(1) as u32;
8089 let mut continuation = None;
8090 loop {
8091 let page = store.repository_graph_adjacency_page_filtered(
8092 chunk,
8093 RepositoryGraphDirection::Outbound,
8094 Some(calls),
8095 continuation.as_ref(),
8096 page_limit,
8097 None,
8098 )?;
8099 inspected_edges = inspected_edges
8100 .checked_add(page.rows.len())
8101 .ok_or_else(|| io::Error::other("measured edge count overflowed"))?;
8102 for row in page.rows {
8103 if let Some(target) = row.detail.target {
8104 let digest = target.key().digest_bytes()?;
8105 if !visited.contains_key(&digest) {
8106 next.entry(digest).or_insert_with(|| target.key().clone());
8107 }
8108 }
8109 }
8110 if !page.truncated {
8111 break;
8112 }
8113 continuation = Some(page.continuation.ok_or_else(|| {
8114 io::Error::other("truncated adjacency page omitted its continuation")
8115 })?);
8116 }
8117 }
8118 frontier = next.into_values().collect();
8119 peak_frontier = peak_frontier.max(frontier.len());
8120 for key in &frontier {
8121 visited.insert(key.digest_bytes()?, key.clone());
8122 }
8123 }
8124
8125 Ok((
8126 visited.into_keys().collect(),
8127 inspected_edges,
8128 peak_frontier,
8129 ))
8130 }
8131
8132 struct GraphFixture {
8134 project: ProjectInstanceId,
8136 entities: Vec<GraphEntity>,
8138 relations: Vec<LogicalRelation>,
8140 occurrences: Vec<RelationOccurrence>,
8142 coverage: Vec<CoverageRecord>,
8144 }
8145
8146 struct DetailedRelationTraceFixture {
8148 project: ProjectInstanceId,
8150 source: GraphEntity,
8152 relations: Vec<LogicalRelation>,
8154 }
8155
8156 struct ResolutionFixture {
8158 graph: GraphFixture,
8160 resolved: CanonicalResolutionKey,
8162 ambiguous: CanonicalResolutionKey,
8164 unresolved: CanonicalResolutionKey,
8166 }
8167
8168 fn graph_fixture(
8170 project: ProjectInstanceId,
8171 generation: IndexGeneration,
8172 ) -> Result<GraphFixture, Box<dyn Error>> {
8173 let project_entity = GraphEntity::new(project, EntitySelector::Project, generation)?;
8174 let folder = GraphEntity::new(
8175 project,
8176 EntitySelector::Folder {
8177 path: RepositoryNodePath::new(Path::new("src"))?,
8178 },
8179 generation,
8180 )?;
8181 let file = GraphEntity::new(
8182 project,
8183 EntitySelector::File {
8184 path: RepositoryFilePath::new(Path::new("src/Äuth.rs"))?,
8185 },
8186 generation,
8187 )?;
8188 let package = GraphEntity::new(
8189 project,
8190 EntitySelector::Package {
8191 package: PackageSelector {
8192 manager: GraphIdentityText::new("cargo")?,
8193 name: GraphIdentityText::new("ProjectAtlas")?,
8194 manifest: RepositoryFilePath::new(Path::new("Cargo.toml"))?,
8195 },
8196 },
8197 generation,
8198 )?;
8199 let symbol = GraphEntity::new(
8200 project,
8201 EntitySelector::Symbol {
8202 symbol: SymbolSelector {
8203 file: RepositoryFilePath::new(Path::new("src/Äuth.rs"))?,
8204 name: GraphIdentityText::new("verifyToken")?,
8205 kind: SymbolKind::Function,
8206 parent: Some(GraphIdentityText::new("Auth")?),
8207 signature: GraphIdentityText::new("verifyToken(&str)")?,
8208 },
8209 },
8210 generation,
8211 )?;
8212 let external = GraphEntity::new(
8213 project,
8214 EntitySelector::External {
8215 external: ExternalSelector {
8216 system: GraphIdentityText::new("crates.io")?,
8217 identity: GraphIdentityText::new("serde@1")?,
8218 },
8219 },
8220 generation,
8221 )?;
8222
8223 let resolved = LogicalRelation::new(
8224 &file,
8225 GraphRelationKind::Legacy(RelationKind::Calls),
8226 RelationResolution::resolved(&symbol)?,
8227 ConfidenceClass::Exact,
8228 Completeness::Complete,
8229 generation,
8230 )?;
8231 let ambiguous = LogicalRelation::new(
8232 &file,
8233 GraphRelationKind::Extended(ExtendedRelationKind::References),
8234 RelationResolution::Ambiguous {
8235 reference: GraphIdentityText::new("Session")?,
8236 candidates: NonZeroU32::new(2)
8237 .ok_or_else(|| io::Error::other("fixture candidate count is zero"))?,
8238 },
8239 ConfidenceClass::Medium,
8240 Completeness::Partial,
8241 generation,
8242 )?;
8243 let unresolved = LogicalRelation::new(
8244 &file,
8245 GraphRelationKind::Extended(ExtendedRelationKind::Configures),
8246 RelationResolution::Unresolved {
8247 reference: GraphIdentityText::new("AUTH_KEY")?,
8248 },
8249 ConfidenceClass::Low,
8250 Completeness::Partial,
8251 generation,
8252 )?;
8253 let external_relation = LogicalRelation::new(
8254 &file,
8255 GraphRelationKind::Legacy(RelationKind::DependsOn),
8256 RelationResolution::external(&external)?,
8257 ConfidenceClass::High,
8258 Completeness::Complete,
8259 generation,
8260 )?;
8261 let occurrences = vec![
8262 RelationOccurrence::new(
8263 &resolved,
8264 RepositoryFilePath::new(Path::new("src/Äuth.rs"))?,
8265 SourceSpan::new(10, 4, 10, 18)?,
8266 generation,
8267 )?,
8268 RelationOccurrence::new(
8269 &resolved,
8270 RepositoryFilePath::new(Path::new("src/Äuth.rs"))?,
8271 SourceSpan::new(22, 2, 22, 16)?,
8272 generation,
8273 )?,
8274 ];
8275 let coverage = vec![
8276 CoverageRecord::new(
8277 CoverageScope::Project,
8278 None,
8279 CoverageState::Complete,
8280 4,
8281 0,
8282 generation,
8283 None,
8284 None,
8285 )?,
8286 CoverageRecord::new(
8287 CoverageScope::Project,
8288 Some(GraphRelationKind::Extended(ExtendedRelationKind::Documents)),
8289 CoverageState::NoCandidates,
8290 0,
8291 0,
8292 generation,
8293 None,
8294 None,
8295 )?,
8296 CoverageRecord::new(
8297 CoverageScope::Project,
8298 Some(GraphRelationKind::Extended(ExtendedRelationKind::Tests)),
8299 CoverageState::Complete,
8300 0,
8301 0,
8302 generation,
8303 None,
8304 None,
8305 )?,
8306 CoverageRecord::new(
8307 CoverageScope::Path {
8308 path: RepositoryNodePath::new(Path::new("src"))?,
8309 },
8310 None,
8311 CoverageState::Partial,
8312 3,
8313 1,
8314 generation,
8315 Some(GraphIdentityText::new("one parser region omitted")?),
8316 Some(GraphLimitKind::Rows),
8317 )?,
8318 incomplete_coverage(
8319 GraphRelationKind::Legacy(RelationKind::Calls),
8320 CoverageState::Failed,
8321 "parser failed",
8322 generation,
8323 )?,
8324 incomplete_coverage(
8325 GraphRelationKind::Legacy(RelationKind::Imports),
8326 CoverageState::Ignored,
8327 "ignored by policy",
8328 generation,
8329 )?,
8330 incomplete_coverage(
8331 GraphRelationKind::Legacy(RelationKind::Contains),
8332 CoverageState::Oversized,
8333 "file exceeded limit",
8334 generation,
8335 )?,
8336 incomplete_coverage(
8337 GraphRelationKind::Legacy(RelationKind::DependsOn),
8338 CoverageState::Quarantined,
8339 "provider quarantined",
8340 generation,
8341 )?,
8342 incomplete_coverage(
8343 GraphRelationKind::Extended(ExtendedRelationKind::References),
8344 CoverageState::Stale,
8345 "source changed",
8346 generation,
8347 )?,
8348 ];
8349 Ok(GraphFixture {
8350 project,
8351 entities: vec![project_entity, folder, file, package, symbol, external],
8352 relations: vec![resolved, ambiguous, unresolved, external_relation],
8353 occurrences,
8354 coverage,
8355 })
8356 }
8357
8358 fn declaration_key(
8360 project: ProjectInstanceId,
8361 identity: &str,
8362 relation: GraphRelationKind,
8363 ) -> Result<CanonicalResolutionKey, Box<dyn Error>> {
8364 Ok(CanonicalResolutionKey::new(
8365 project,
8366 ResolutionKeyDomain::Declaration,
8367 &GraphIdentityText::new("tree-sitter")?,
8368 &GraphIdentityText::new("rust")?,
8369 None,
8370 Some(&GraphIdentityText::new("crate")?),
8371 Some(relation),
8372 &GraphIdentityText::new(identity)?,
8373 ))
8374 }
8375
8376 fn publish_resolution_fixture(
8378 store: &mut AtlasStore,
8379 fingerprint: &str,
8380 ) -> Result<ResolutionFixture, Box<dyn Error>> {
8381 let project = store
8382 .project_instance_id()?
8383 .ok_or_else(|| io::Error::other("bound fixture identity is missing"))?;
8384 let graph = graph_fixture(project, IndexGeneration::new(1))?;
8385 let resolved = declaration_key(
8386 project,
8387 "verifyToken",
8388 GraphRelationKind::Legacy(RelationKind::Calls),
8389 )?;
8390 let ambiguous = declaration_key(
8391 project,
8392 "Session",
8393 GraphRelationKind::Extended(ExtendedRelationKind::References),
8394 )?;
8395 let unresolved = declaration_key(
8396 project,
8397 "AUTH_KEY",
8398 GraphRelationKind::Extended(ExtendedRelationKind::Configures),
8399 )?;
8400 let export = EntityResolutionKey::new(graph.entities[4].key().clone(), resolved.clone())?;
8401 let exports = vec![export.clone(), export];
8402 let dependencies = vec![
8403 RelationDependencyKey::new(graph.relations[0].key().clone(), resolved.clone())?,
8404 RelationDependencyKey::new(graph.relations[1].key().clone(), ambiguous.clone())?,
8405 RelationDependencyKey::new(graph.relations[2].key().clone(), unresolved.clone())?,
8406 ];
8407 let mut publication = store.begin_index_publication(fingerprint)?;
8408 publication.begin_scan_replacement()?;
8409 publication.upsert_scan_node_batch(&[
8410 graph_node(".", NodeKind::Folder, None),
8411 graph_node("src", NodeKind::Folder, Some(".")),
8412 graph_node("src/Äuth.rs", NodeKind::File, Some("src")),
8413 graph_node("Cargo.toml", NodeKind::File, Some(".")),
8414 ])?;
8415 publication.finish_scan_replacement()?;
8416 publication.replace_repository_graph_with_resolution_keys(
8417 graph.project,
8418 &graph.entities,
8419 &graph.relations,
8420 &graph.occurrences,
8421 &graph.coverage,
8422 &exports,
8423 &dependencies,
8424 )?;
8425 publication.complete()?;
8426 Ok(ResolutionFixture {
8427 graph,
8428 resolved,
8429 ambiguous,
8430 unresolved,
8431 })
8432 }
8433
8434 fn replace_fixture_symbol_rows(
8436 store: &mut AtlasStore,
8437 path: &str,
8438 symbol_count: usize,
8439 ) -> Result<(), Box<dyn Error>> {
8440 let symbols = (0..symbol_count)
8441 .map(|index| CodeSymbol {
8442 path: path.to_string(),
8443 language: Some("rust".to_string()),
8444 name: format!("symbol_{index}"),
8445 kind: SymbolKind::Function,
8446 signature: format!("fn symbol_{index}()"),
8447 exported: index == 0,
8448 documentation: None,
8449 line_start: index + 1,
8450 line_end: index + 1,
8451 source_selector: None,
8452 parent: None,
8453 parser: ParserKind::TreeSitter,
8454 detail: Some("function_item".to_string()),
8455 })
8456 .collect();
8457 store.replace_symbol_graph(&SymbolGraph {
8458 path: path.to_string(),
8459 language: Some("rust".to_string()),
8460 parser: ParserKind::TreeSitter,
8461 symbols,
8462 relations: vec![SymbolRelation {
8463 path: path.to_string(),
8464 source_name: "symbol_0".to_string(),
8465 target_name: "dependency".to_string(),
8466 kind: RelationKind::Calls,
8467 line: 1,
8468 context: "dependency()".to_string(),
8469 parser: ParserKind::TreeSitter,
8470 }],
8471 })?;
8472 Ok(())
8473 }
8474
8475 fn incomplete_coverage(
8477 relation: GraphRelationKind,
8478 state: CoverageState,
8479 reason: &str,
8480 generation: IndexGeneration,
8481 ) -> Result<CoverageRecord, Box<dyn Error>> {
8482 Ok(CoverageRecord::new(
8483 CoverageScope::Project,
8484 Some(relation),
8485 state,
8486 0,
8487 1,
8488 generation,
8489 Some(GraphIdentityText::new(reason)?),
8490 None,
8491 )?)
8492 }
8493
8494 fn graph_node(path: &str, kind: NodeKind, parent_path: Option<&str>) -> Node {
8496 Node {
8497 path: path.to_string(),
8498 kind,
8499 parent_path: parent_path.map(str::to_string),
8500 extension: None,
8501 language: None,
8502 size_bytes: None,
8503 mtime_ns: None,
8504 content_hash: None,
8505 }
8506 }
8507
8508 struct NavigationFixture {
8510 project: ProjectInstanceId,
8512 api_path: String,
8514 manifest_path: String,
8516 }
8517
8518 fn publish_navigation_fixture(
8520 store: &mut AtlasStore,
8521 fingerprint: &str,
8522 ) -> Result<NavigationFixture, Box<dyn Error>> {
8523 let project = store
8524 .project_instance_id()?
8525 .ok_or_else(|| io::Error::other("navigation fixture identity is missing"))?;
8526 let generation = IndexGeneration::new(1);
8527 let api_path = "src/auth/api.rs".to_string();
8528 let manifest_path = "Cargo.toml".to_string();
8529 let mut nodes = vec![
8530 graph_node(".", NodeKind::Folder, None),
8531 graph_node("src", NodeKind::Folder, Some(".")),
8532 graph_node("src/auth", NodeKind::Folder, Some("src")),
8533 graph_node(&api_path, NodeKind::File, Some("src/auth")),
8534 graph_node("src/auth/caller.rs", NodeKind::File, Some("src/auth")),
8535 graph_node("src/other.rs", NodeKind::File, Some("src")),
8536 graph_node("src/authz.rs", NodeKind::File, Some("src")),
8537 graph_node("tests", NodeKind::Folder, Some(".")),
8538 graph_node("tests/api_test.rs", NodeKind::File, Some("tests")),
8539 graph_node("clients", NodeKind::Folder, Some(".")),
8540 graph_node(&manifest_path, NodeKind::File, Some(".")),
8541 ];
8542 let api = GraphEntity::new(
8543 project,
8544 EntitySelector::File {
8545 path: RepositoryFilePath::new(Path::new(&api_path))?,
8546 },
8547 generation,
8548 )?;
8549 let internal_caller = GraphEntity::new(
8550 project,
8551 EntitySelector::File {
8552 path: RepositoryFilePath::new(Path::new("src/auth/caller.rs"))?,
8553 },
8554 generation,
8555 )?;
8556 let other = GraphEntity::new(
8557 project,
8558 EntitySelector::File {
8559 path: RepositoryFilePath::new(Path::new("src/other.rs"))?,
8560 },
8561 generation,
8562 )?;
8563 let sibling = GraphEntity::new(
8564 project,
8565 EntitySelector::File {
8566 path: RepositoryFilePath::new(Path::new("src/authz.rs"))?,
8567 },
8568 generation,
8569 )?;
8570 let test = GraphEntity::new(
8571 project,
8572 EntitySelector::File {
8573 path: RepositoryFilePath::new(Path::new("tests/api_test.rs"))?,
8574 },
8575 generation,
8576 )?;
8577 let package = GraphEntity::new(
8578 project,
8579 EntitySelector::Package {
8580 package: PackageSelector {
8581 manager: GraphIdentityText::new("cargo")?,
8582 name: GraphIdentityText::new("projectatlas-navigation")?,
8583 manifest: RepositoryFilePath::new(Path::new(&manifest_path))?,
8584 },
8585 },
8586 generation,
8587 )?;
8588 let mut entities = vec![
8589 api.clone(),
8590 internal_caller.clone(),
8591 other.clone(),
8592 sibling.clone(),
8593 test.clone(),
8594 package.clone(),
8595 ];
8596 let mut relations = vec![
8597 LogicalRelation::new(
8598 &api,
8599 GraphRelationKind::Legacy(RelationKind::DependsOn),
8600 RelationResolution::resolved(&package)?,
8601 ConfidenceClass::Exact,
8602 Completeness::Complete,
8603 generation,
8604 )?,
8605 LogicalRelation::new(
8606 &api,
8607 GraphRelationKind::Legacy(RelationKind::Imports),
8608 RelationResolution::resolved(&other)?,
8609 ConfidenceClass::Exact,
8610 Completeness::Complete,
8611 generation,
8612 )?,
8613 LogicalRelation::new(
8614 &api,
8615 GraphRelationKind::Legacy(RelationKind::Calls),
8616 RelationResolution::resolved(&other)?,
8617 ConfidenceClass::Exact,
8618 Completeness::Complete,
8619 generation,
8620 )?,
8621 LogicalRelation::new(
8622 &api,
8623 GraphRelationKind::Extended(ExtendedRelationKind::References),
8624 RelationResolution::Unresolved {
8625 reference: GraphIdentityText::new("SessionStore")?,
8626 },
8627 ConfidenceClass::High,
8628 Completeness::Partial,
8629 generation,
8630 )?,
8631 LogicalRelation::new(
8632 &test,
8633 GraphRelationKind::Extended(ExtendedRelationKind::Tests),
8634 RelationResolution::resolved(&api)?,
8635 ConfidenceClass::Exact,
8636 Completeness::Complete,
8637 generation,
8638 )?,
8639 LogicalRelation::new(
8640 &api,
8641 GraphRelationKind::Extended(ExtendedRelationKind::RoutesTo),
8642 RelationResolution::resolved(&other)?,
8643 ConfidenceClass::High,
8644 Completeness::Complete,
8645 generation,
8646 )?,
8647 LogicalRelation::new(
8648 &api,
8649 GraphRelationKind::Extended(ExtendedRelationKind::Configures),
8650 RelationResolution::Unresolved {
8651 reference: GraphIdentityText::new("AUTH_MODE")?,
8652 },
8653 ConfidenceClass::High,
8654 Completeness::Partial,
8655 generation,
8656 )?,
8657 LogicalRelation::new(
8658 &internal_caller,
8659 GraphRelationKind::Legacy(RelationKind::Calls),
8660 RelationResolution::resolved(&api)?,
8661 ConfidenceClass::Exact,
8662 Completeness::Complete,
8663 generation,
8664 )?,
8665 LogicalRelation::new(
8666 &sibling,
8667 GraphRelationKind::Legacy(RelationKind::Imports),
8668 RelationResolution::resolved(&other)?,
8669 ConfidenceClass::Exact,
8670 Completeness::Complete,
8671 generation,
8672 )?,
8673 ];
8674 for index in 0..4 {
8675 let path = format!("clients/caller-{index}.rs");
8676 nodes.push(graph_node(&path, NodeKind::File, Some("clients")));
8677 let caller = GraphEntity::new(
8678 project,
8679 EntitySelector::File {
8680 path: RepositoryFilePath::new(Path::new(&path))?,
8681 },
8682 generation,
8683 )?;
8684 relations.push(LogicalRelation::new(
8685 &caller,
8686 GraphRelationKind::Legacy(RelationKind::Calls),
8687 RelationResolution::resolved(&api)?,
8688 ConfidenceClass::Exact,
8689 Completeness::Complete,
8690 generation,
8691 )?);
8692 entities.push(caller);
8693 }
8694
8695 let mut publication = store.begin_index_publication(fingerprint)?;
8696 publication.begin_scan_replacement()?;
8697 publication.upsert_scan_node_batch(&nodes)?;
8698 publication.finish_scan_replacement()?;
8699 publication.replace_repository_graph(project, &entities, &relations, &[], &[])?;
8700 publication.complete()?;
8701 Ok(NavigationFixture {
8702 project,
8703 api_path,
8704 manifest_path,
8705 })
8706 }
8707
8708 fn require(condition: bool, message: &str) -> Result<(), Box<dyn Error>> {
8710 if condition {
8711 Ok(())
8712 } else {
8713 Err(io::Error::other(message.to_string()).into())
8714 }
8715 }
8716
8717 fn require_eq<T: Debug + PartialEq>(
8719 actual: &T,
8720 expected: &T,
8721 context: &str,
8722 ) -> Result<(), Box<dyn Error>> {
8723 require(
8724 actual == expected,
8725 &format!("{context}: expected {expected:?}, found {actual:?}"),
8726 )
8727 }
8728
8729 fn require_db_error<T>(result: DbResult<T>, message: &str) -> Result<DbError, Box<dyn Error>> {
8731 let Err(error) = result else {
8732 return Err(io::Error::other(message.to_string()).into());
8733 };
8734 Ok(error)
8735 }
8736
8737 fn assert_cursor_hydration_indexes(store: &AtlasStore) -> Result<(), Box<dyn Error>> {
8739 let project = store
8740 .project_instance_id()?
8741 .ok_or_else(|| io::Error::other("bound fixture identity is missing"))?;
8742 let cases = [
8743 (
8744 "entity cursor hydration",
8745 graph_entity_hydration_sql(2),
8746 vec![
8747 Value::Blob(vec![0; 32]),
8748 Value::Blob(vec![1; 32]),
8749 Value::Blob(project.as_bytes().to_vec()),
8750 ],
8751 "project_instance_id=? AND entity_key=?",
8752 "SCAN entity",
8753 false,
8754 ),
8755 (
8756 "relation cursor hydration",
8757 graph_relation_hydration_sql(2),
8758 vec![
8759 Value::Blob(vec![0; 32]),
8760 Value::Blob(vec![1; 32]),
8761 Value::Blob(project.as_bytes().to_vec()),
8762 ],
8763 "idx_graph_relations_project_key",
8764 "SCAN relation",
8765 false,
8766 ),
8767 (
8768 "batched occurrence hydration",
8769 occurrence_pages_sql(2),
8770 vec![
8771 Value::Blob(vec![0; 32]),
8772 Value::Integer(2),
8773 Value::Blob(vec![1; 32]),
8774 Value::Integer(2),
8775 ],
8776 "sqlite_autoindex_graph_relation_occurrences_1",
8777 "SCAN graph_relation_occurrences",
8778 true,
8779 ),
8780 (
8781 "batched path coverage hydration",
8782 path_coverage_sql(2),
8783 vec![
8784 Value::Blob(project.as_bytes().to_vec()),
8785 Value::Text("path".to_string()),
8786 Value::Text("Cargo.toml".to_string()),
8787 Value::Text("src/Äuth.rs".to_string()),
8788 Value::Integer(i64::from(GraphLimits::MAX_ROWS) + 1),
8789 ],
8790 "idx_graph_coverage_scope_order",
8791 "SCAN graph_coverage",
8792 false,
8793 ),
8794 ];
8795 for (context, sql, bindings, required_plan, forbidden_scan, allow_bounded_sort) in cases {
8796 let mut statement = store
8797 .connection
8798 .prepare(&format!("EXPLAIN QUERY PLAN {sql}"))?;
8799 let details = statement
8800 .query_map(params_from_iter(bindings.iter()), |row| {
8801 row.get::<_, String>(3)
8802 })?
8803 .collect::<Result<Vec<_>, _>>()?;
8804 require(
8805 details.iter().any(|detail| detail.contains(required_plan))
8806 && details.iter().all(|detail| {
8807 !detail.contains(forbidden_scan)
8808 && (allow_bounded_sort || !detail.contains("USE TEMP B-TREE"))
8809 }),
8810 &format!(
8811 "{context} did not use {required_plan} without a scan or sort: {details:?}"
8812 ),
8813 )?;
8814 }
8815 Ok(())
8816 }
8817
8818 fn assert_query_indexes(store: &AtlasStore) -> Result<(), Box<dyn Error>> {
8820 let cases: &[(&str, &str, &[&str])] = &[
8821 (
8822 "entity path lookup",
8823 "EXPLAIN QUERY PLAN
8824 SELECT entity_key FROM graph_entities
8825 WHERE project_instance_id = zeroblob(16)
8826 AND repository_path = 'src/Äuth.rs'
8827 ORDER BY entity_kind, entity_key
8828 LIMIT 11",
8829 &["idx_graph_entities_path"],
8830 ),
8831 (
8832 "outbound relation lookup",
8833 "EXPLAIN QUERY PLAN
8834 SELECT relation_key FROM graph_relations
8835 WHERE source_entity_key = zeroblob(32)
8836 ORDER BY relation_scope, relation_kind, relation_key
8837 LIMIT 11",
8838 &["idx_graph_relations_source_kind"],
8839 ),
8840 (
8841 "inbound relation lookup",
8842 "EXPLAIN QUERY PLAN
8843 SELECT relation_key FROM graph_relations
8844 WHERE target_entity_key = zeroblob(32)
8845 ORDER BY relation_scope, relation_kind, relation_key
8846 LIMIT 11",
8847 &["idx_graph_relations_target_kind"],
8848 ),
8849 (
8850 "inbound documents lookup",
8851 "EXPLAIN QUERY PLAN
8852 SELECT relation_key FROM graph_relations
8853 WHERE target_entity_key = zeroblob(32)
8854 AND relation_scope = 'extended'
8855 AND relation_kind = 'documents'
8856 ORDER BY relation_scope, relation_kind, relation_key
8857 LIMIT 11",
8858 &["idx_graph_relations_target_kind"],
8859 ),
8860 (
8861 "relation family lookup",
8862 "EXPLAIN QUERY PLAN
8863 SELECT relation_key FROM graph_relations
8864 WHERE project_instance_id = zeroblob(16)
8865 AND relation_scope = 'legacy'
8866 AND relation_kind = 'calls'
8867 ORDER BY relation_key
8868 LIMIT 11",
8869 &["idx_graph_relations_kind_order"],
8870 ),
8871 (
8872 "relation occurrence lookup",
8873 "EXPLAIN QUERY PLAN
8874 SELECT file_path FROM graph_relation_occurrences
8875 WHERE relation_key = zeroblob(32)
8876 ORDER BY file_path, start_line, start_column, end_line, end_column
8877 LIMIT 11",
8878 &["sqlite_autoindex_graph_relation_occurrences_1"],
8879 ),
8880 (
8881 "occurrence path invalidation",
8882 "EXPLAIN QUERY PLAN
8883 SELECT relation_key FROM graph_relation_occurrences
8884 WHERE file_path = 'src'
8885 OR (file_path >= 'src/' AND file_path < 'src0')",
8886 &["idx_graph_occurrences_file_span"],
8887 ),
8888 (
8889 "coverage path invalidation",
8890 "EXPLAIN QUERY PLAN
8891 SELECT id FROM graph_coverage
8892 INDEXED BY idx_graph_coverage_path
8893 WHERE scope_kind = 'path'
8894 AND (scope_path = 'src'
8895 OR (scope_path >= 'src/' AND scope_path < 'src0'))",
8896 &["idx_graph_coverage_path"],
8897 ),
8898 (
8899 "entity repository-path invalidation",
8900 "EXPLAIN QUERY PLAN
8901 SELECT entity_key FROM graph_entities
8902 WHERE repository_path = 'src'
8903 OR (repository_path >= 'src/' AND repository_path < 'src0')",
8904 &["idx_graph_entities_path"],
8905 ),
8906 (
8907 "entity manifest-path invalidation",
8908 "EXPLAIN QUERY PLAN
8909 SELECT entity_key FROM graph_entities
8910 WHERE manifest_path = 'src'
8911 OR (manifest_path >= 'src/' AND manifest_path < 'src0')",
8912 &["idx_graph_entities_manifest_path"],
8913 ),
8914 (
8915 "outbound external cleanup candidate",
8916 "EXPLAIN QUERY PLAN
8917 SELECT relation.target_entity_key
8918 FROM graph_relations AS relation
8919 INDEXED BY idx_graph_relations_source_kind
8920 JOIN graph_entities AS external
8921 ON external.entity_key = relation.target_entity_key
8922 WHERE relation.source_entity_key = zeroblob(32)
8923 AND external.entity_kind = 'external'",
8924 &["idx_graph_relations_source_kind"],
8925 ),
8926 (
8927 "inbound external cleanup candidate",
8928 "EXPLAIN QUERY PLAN
8929 SELECT relation.source_entity_key
8930 FROM graph_relations AS relation
8931 INDEXED BY idx_graph_relations_target_kind
8932 JOIN graph_entities AS external
8933 ON external.entity_key = relation.source_entity_key
8934 WHERE relation.target_entity_key = zeroblob(32)
8935 AND external.entity_kind = 'external'",
8936 &["idx_graph_relations_target_kind"],
8937 ),
8938 (
8939 "candidate-bounded external cleanup",
8940 "EXPLAIN QUERY PLAN
8941 DELETE FROM graph_entities
8942 WHERE entity_key = zeroblob(32) AND entity_kind = 'external'
8943 AND NOT EXISTS (
8944 SELECT 1 FROM graph_relations
8945 INDEXED BY idx_graph_relations_source_kind
8946 WHERE source_entity_key = zeroblob(32)
8947 )
8948 AND NOT EXISTS (
8949 SELECT 1 FROM graph_relations
8950 INDEXED BY idx_graph_relations_target_kind
8951 WHERE target_entity_key = zeroblob(32)
8952 )",
8953 &[
8954 "sqlite_autoindex_graph_entities_1",
8955 "idx_graph_relations_source_kind",
8956 "idx_graph_relations_target_kind",
8957 ],
8958 ),
8959 (
8960 "coverage scope lookup",
8961 "EXPLAIN QUERY PLAN
8962 SELECT id FROM graph_coverage
8963 WHERE project_instance_id = zeroblob(16)
8964 AND scope_kind = 'path'
8965 AND scope_path IS 'src'
8966 ORDER BY relation_scope, relation_kind, state, id
8967 LIMIT 11",
8968 &["idx_graph_coverage_scope_order"],
8969 ),
8970 (
8971 "resolution witness lookup",
8972 "EXPLAIN QUERY PLAN
8973 SELECT canonical_identity FROM graph_resolution_keys
8974 WHERE project_instance_id = zeroblob(16)
8975 AND resolution_domain = 'declaration'
8976 AND key_digest = zeroblob(32)",
8977 &["sqlite_autoindex_graph_resolution_keys_1"],
8978 ),
8979 (
8980 "resolution export lookup",
8981 "EXPLAIN QUERY PLAN
8982 SELECT entity_key FROM graph_entity_exports
8983 WHERE project_instance_id = zeroblob(16)
8984 AND resolution_domain = 'declaration'
8985 AND key_digest = zeroblob(32)
8986 ORDER BY entity_key",
8987 &["idx_graph_entity_exports_key"],
8988 ),
8989 (
8990 "resolution export owner lookup",
8991 "EXPLAIN QUERY PLAN
8992 SELECT resolution_domain, key_digest, entity_key
8993 FROM graph_entity_exports
8994 WHERE project_instance_id = zeroblob(16)
8995 AND owner_path = 'src/Äuth.rs'
8996 ORDER BY resolution_domain, key_digest, entity_key",
8997 &["idx_graph_entity_exports_owner"],
8998 ),
8999 (
9000 "resolution dependency lookup",
9001 "EXPLAIN QUERY PLAN
9002 SELECT owner_path, relation_key FROM graph_relation_dependencies
9003 WHERE project_instance_id = zeroblob(16)
9004 AND resolution_domain = 'declaration'
9005 AND key_digest = zeroblob(32)
9006 ORDER BY owner_path, relation_key",
9007 &["idx_graph_relation_dependencies_key"],
9008 ),
9009 (
9010 "resolution dependency owner lookup",
9011 "EXPLAIN QUERY PLAN
9012 SELECT resolution_domain, key_digest, relation_key
9013 FROM graph_relation_dependencies
9014 WHERE project_instance_id = zeroblob(16)
9015 AND owner_path = 'src/Äuth.rs'
9016 ORDER BY resolution_domain, key_digest, relation_key",
9017 &["idx_graph_relation_dependencies_owner"],
9018 ),
9019 (
9020 "relation composite integrity lookup",
9021 "EXPLAIN QUERY PLAN
9022 SELECT relation_key FROM graph_relations
9023 WHERE project_instance_id = zeroblob(16)
9024 AND relation_key = zeroblob(32)",
9025 &["idx_graph_relations_project_key"],
9026 ),
9027 (
9028 "document unresolved reason validation",
9029 "EXPLAIN QUERY PLAN
9030 SELECT EXISTS(
9031 SELECT 1
9032 FROM graph_relations
9033 INDEXED BY idx_graph_relations_project_key
9034 WHERE relation_key = zeroblob(32)
9035 AND project_instance_id = zeroblob(16)
9036 AND relation_scope = 'extended'
9037 AND relation_kind = 'documents'
9038 AND resolution_status = 'unresolved'
9039 )",
9040 &["idx_graph_relations_project_key"],
9041 ),
9042 ];
9043
9044 for (context, sql, required_indexes) in cases {
9045 let mut statement = store.connection.prepare(sql)?;
9046 let details = statement
9047 .query_map([], |row| row.get::<_, String>(3))?
9048 .collect::<Result<Vec<_>, _>>()?;
9049 require(
9050 required_indexes
9051 .iter()
9052 .all(|index| details.iter().any(|detail| detail.contains(index))),
9053 &format!("{context} did not use {required_indexes:?}; query plan was {details:?}"),
9054 )?;
9055 require(
9056 details.iter().all(|detail| {
9057 !detail.contains("SCAN graph_") && !detail.contains("USE TEMP B-TREE")
9058 }),
9059 &format!("{context} was not bounded by index order: {details:?}"),
9060 )?;
9061 }
9062
9063 for key_count in [2, EXTERNAL_CANDIDATE_KEYS_PER_QUERY] {
9064 let sql = format!(
9065 "EXPLAIN QUERY PLAN {}",
9066 external_candidate_batch_sql(key_count)
9067 );
9068 let bindings = (0..key_count)
9069 .map(|_| Value::Blob(vec![0_u8; 32]))
9070 .collect::<Vec<_>>();
9071 let mut statement = store.connection.prepare(&sql)?;
9072 let details = statement
9073 .query_map(params_from_iter(bindings.iter()), |row| {
9074 row.get::<_, String>(3)
9075 })?
9076 .collect::<Result<Vec<_>, _>>()?;
9077 require(
9078 [
9079 "SEARCH relation USING INDEX idx_graph_relations_source_kind (source_entity_key=?)",
9080 "SEARCH relation USING INDEX idx_graph_relations_target_kind (target_entity_key=?)",
9081 ]
9082 .iter()
9083 .all(|seek| details.iter().any(|detail| detail.contains(seek)))
9084 && details.iter().all(|detail| {
9085 !detail.contains("SCAN relation")
9086 && !detail.contains("SCAN graph_relations")
9087 && !detail.contains("USE TEMP B-TREE")
9088 }),
9089 &format!(
9090 "{key_count}-key external cleanup batch was not driven by indexed point seeks: {details:?}"
9091 ),
9092 )?;
9093 }
9094 Ok(())
9095 }
9096
9097 fn assert_coverage_discovery_plan(
9099 connection: &Connection,
9100 sql: &str,
9101 values: &[Value],
9102 required_indexes: &[&str],
9103 allow_bounded_partial_sort: bool,
9104 context: &str,
9105 ) -> Result<(), Box<dyn Error>> {
9106 let mut statement = connection.prepare(sql)?;
9107 let details = statement
9108 .query_map(params_from_iter(values.iter()), |row| {
9109 row.get::<_, String>(3)
9110 })?
9111 .collect::<Result<Vec<_>, _>>()?;
9112 require(
9113 required_indexes
9114 .iter()
9115 .all(|index| details.iter().any(|detail| detail.contains(index))),
9116 &format!("{context} did not use {required_indexes:?}; query plan was {details:?}"),
9117 )?;
9118 require(
9119 details.iter().all(|detail| {
9120 let uses_temporary_sort = detail.contains("USE TEMP B-TREE");
9121 let bounded_partial_sort = detail.contains("USE TEMP B-TREE FOR LAST");
9122 !detail.contains("SCAN coverage")
9123 && (!uses_temporary_sort
9124 || (allow_bounded_partial_sort && bounded_partial_sort))
9125 }),
9126 &format!("{context} used an unbounded scan or sort: {details:?}"),
9127 )?;
9128 Ok(())
9129 }
9130
9131 fn publish_detailed_relation_trace_fixture(
9133 store: &mut AtlasStore,
9134 fingerprint: &str,
9135 ) -> Result<DetailedRelationTraceFixture, Box<dyn Error>> {
9136 let project = store
9137 .project_instance_id()?
9138 .ok_or_else(|| io::Error::other("bound trace fixture identity is missing"))?;
9139 let generation = IndexGeneration::new(1);
9140 let source_path = RepositoryFilePath::new(Path::new("src/trace-source.rs"))?;
9141 let source = GraphEntity::new(
9142 project,
9143 EntitySelector::File {
9144 path: source_path.clone(),
9145 },
9146 generation,
9147 )?;
9148 let mut entities = Vec::with_capacity(MAX_REPOSITORY_GRAPH_FRONTIER + 1);
9149 let mut relations = Vec::with_capacity(MAX_REPOSITORY_GRAPH_FRONTIER);
9150 let mut occurrences = Vec::with_capacity(MAX_REPOSITORY_GRAPH_FRONTIER * 2);
9151 entities.push(source.clone());
9152 for index in 0..MAX_REPOSITORY_GRAPH_FRONTIER {
9153 let name = format!("trace_target_{index:03}");
9154 let target = GraphEntity::new(
9155 project,
9156 EntitySelector::Symbol {
9157 symbol: SymbolSelector {
9158 file: source_path.clone(),
9159 name: GraphIdentityText::new(&name)?,
9160 kind: SymbolKind::Function,
9161 parent: None,
9162 signature: GraphIdentityText::new(format!("{name}()"))?,
9163 },
9164 },
9165 generation,
9166 )?;
9167 let relation = LogicalRelation::new(
9168 &source,
9169 GraphRelationKind::Legacy(RelationKind::Calls),
9170 RelationResolution::resolved(&target)?,
9171 ConfidenceClass::Exact,
9172 Completeness::Complete,
9173 generation,
9174 )?;
9175 let first_line = u32::try_from(index * 2 + 1)?;
9176 let second_line = first_line + 1;
9177 occurrences.push(RelationOccurrence::new(
9178 &relation,
9179 source_path.clone(),
9180 SourceSpan::new(first_line, 1, first_line, 2)?,
9181 generation,
9182 )?);
9183 occurrences.push(RelationOccurrence::new(
9184 &relation,
9185 source_path.clone(),
9186 SourceSpan::new(second_line, 1, second_line, 2)?,
9187 generation,
9188 )?);
9189 entities.push(target);
9190 relations.push(relation);
9191 }
9192
9193 let mut publication = store.begin_index_publication(fingerprint)?;
9194 publication.begin_scan_replacement()?;
9195 publication.upsert_scan_node_batch(&[
9196 graph_node(".", NodeKind::Folder, None),
9197 graph_node("src", NodeKind::Folder, Some(".")),
9198 graph_node("src/trace-source.rs", NodeKind::File, Some("src")),
9199 ])?;
9200 publication.finish_scan_replacement()?;
9201 publication.replace_repository_graph(project, &entities, &relations, &occurrences, &[])?;
9202 publication.complete()?;
9203 Ok(DetailedRelationTraceFixture {
9204 project,
9205 source,
9206 relations,
9207 })
9208 }
9209
9210 fn publish_fixture(
9212 store: &mut AtlasStore,
9213 fingerprint: &str,
9214 ) -> Result<GraphFixture, Box<dyn Error>> {
9215 let project = store
9216 .project_instance_id()?
9217 .ok_or_else(|| io::Error::other("bound fixture identity is missing"))?;
9218 let mut fixture = graph_fixture(project, IndexGeneration::new(1))?;
9219 fixture.coverage.push(CoverageRecord::new(
9220 CoverageScope::Path {
9221 path: RepositoryNodePath::new(Path::new("src/Äuth.rs"))?,
9222 },
9223 None,
9224 CoverageState::Complete,
9225 1,
9226 0,
9227 IndexGeneration::new(1),
9228 None,
9229 None,
9230 )?);
9231 let mut occurrences = fixture.occurrences.clone();
9232 occurrences.push(fixture.occurrences[0].clone());
9233 let mut publication = store.begin_index_publication(fingerprint)?;
9234 publication.begin_scan_replacement()?;
9235 publication.upsert_scan_node_batch(&[
9236 graph_node(".", NodeKind::Folder, None),
9237 graph_node("src", NodeKind::Folder, Some(".")),
9238 graph_node("src/Äuth.rs", NodeKind::File, Some("src")),
9239 graph_node("Cargo.toml", NodeKind::File, Some(".")),
9240 ])?;
9241 publication.finish_scan_replacement()?;
9242 publication.replace_symbol_graph(&SymbolGraph {
9243 path: "src/Äuth.rs".to_string(),
9244 language: Some("rust".to_string()),
9245 parser: ParserKind::TreeSitter,
9246 symbols: Vec::new(),
9247 relations: vec![SymbolRelation {
9248 path: "src/Äuth.rs".to_string(),
9249 source_name: "verifyToken".to_string(),
9250 target_name: "legacyTarget".to_string(),
9251 kind: RelationKind::Calls,
9252 line: 10,
9253 context: "legacyTarget()".to_string(),
9254 parser: ParserKind::TreeSitter,
9255 }],
9256 })?;
9257 publication.replace_file_texts_for_paths(
9258 &["src/Äuth.rs".to_string()],
9259 &[IndexedFileText {
9260 path: "src/Äuth.rs".to_string(),
9261 content_hash: Some("hash-old".to_string()),
9262 byte_count: 16,
9263 line_count: 1,
9264 content: "fn verifyToken()".to_string(),
9265 }],
9266 )?;
9267 publication.replace_repository_graph(
9268 fixture.project,
9269 &fixture.entities,
9270 &fixture.relations,
9271 &occurrences,
9272 &fixture.coverage,
9273 )?;
9274 publication.complete()?;
9275 Ok(fixture)
9276 }
9277
9278 #[test]
9279 fn classified_relation_family_filters_before_limit_and_preserves_legacy_page()
9280 -> Result<(), Box<dyn Error>> {
9281 let temp = tempfile::tempdir()?;
9282 let root = temp.path().join("classified-relation-family");
9283 fs::create_dir_all(&root)?;
9284 let mut store = AtlasStore::open_for_project(&root.join("projectatlas.db"), &root)?;
9285 let project = store
9286 .project_instance_id()?
9287 .ok_or_else(|| io::Error::other("classified fixture identity is missing"))?;
9288 let generation = IndexGeneration::new(1);
9289 let paths = (0..6)
9290 .map(|index| format!("src/source-{index}.rs"))
9291 .collect::<Vec<_>>();
9292 let sources = paths
9293 .iter()
9294 .map(|path| {
9295 GraphEntity::new(
9296 project,
9297 EntitySelector::File {
9298 path: RepositoryFilePath::new(Path::new(path))?,
9299 },
9300 generation,
9301 )
9302 .map_err(Into::into)
9303 })
9304 .collect::<Result<Vec<_>, Box<dyn Error>>>()?;
9305 let external = GraphEntity::new(
9306 project,
9307 EntitySelector::External {
9308 external: ExternalSelector {
9309 system: GraphIdentityText::new("classified-family")?,
9310 identity: GraphIdentityText::new("shared-target")?,
9311 },
9312 },
9313 generation,
9314 )?;
9315 let calls = GraphRelationKind::Legacy(RelationKind::Calls);
9316 let mut relations = sources
9317 .iter()
9318 .map(|source| {
9319 LogicalRelation::new(
9320 source,
9321 calls,
9322 RelationResolution::external(&external)?,
9323 ConfidenceClass::Exact,
9324 Completeness::Complete,
9325 generation,
9326 )
9327 .map_err(Into::into)
9328 })
9329 .collect::<Result<Vec<_>, Box<dyn Error>>>()?;
9330 relations.sort_by(|left, right| left.key().digest().cmp(right.key().digest()));
9331 let classifications = [
9332 ContentClassification::Documentation,
9333 ContentClassification::ConfigurationData,
9334 ContentClassification::OtherText,
9335 ContentClassification::Opaque,
9336 ContentClassification::Source,
9337 ContentClassification::Documentation,
9338 ];
9339 let rows = relations
9340 .iter()
9341 .zip(classifications)
9342 .map(|(relation, classification)| {
9343 let source = sources
9344 .iter()
9345 .find(|source| source.key() == relation.source())
9346 .ok_or_else(|| io::Error::other("relation source fixture is missing"))?;
9347 let EntitySelector::File { path } = source.selector() else {
9348 return Err(io::Error::other("relation source fixture is not a file"));
9349 };
9350 Ok(crate::FileContentClassification {
9351 path: path.as_str().to_string(),
9352 classification,
9353 })
9354 })
9355 .collect::<Result<Vec<_>, io::Error>>()?;
9356 let mut nodes = vec![
9357 graph_node(".", NodeKind::Folder, None),
9358 graph_node("src", NodeKind::Folder, Some(".")),
9359 ];
9360 nodes.extend(
9361 paths
9362 .iter()
9363 .map(|path| graph_node(path, NodeKind::File, Some("src"))),
9364 );
9365 let mut entities = sources;
9366 entities.push(external);
9367 let mut publication = store.begin_index_publication("classified-relation-family")?;
9368 publication.begin_scan_replacement()?;
9369 publication.upsert_scan_node_batch(&nodes)?;
9370 publication.finish_scan_replacement()?;
9371 publication.upsert_file_content_classification_batch(&rows)?;
9372 publication.replace_repository_graph(project, &entities, &relations, &[], &[])?;
9373 publication.complete()?;
9374
9375 let legacy = store.repository_graph_relation_rows(
9376 RepositoryGraphRelationQuery::Family { relation: calls },
9377 2,
9378 None,
9379 )?;
9380 let classified_legacy = store.repository_graph_classified_relation_family_rows(
9381 calls,
9382 ContentSelection::UnspecifiedLegacy,
9383 2,
9384 None,
9385 )?;
9386 require_eq(
9387 &classified_legacy
9388 .rows
9389 .iter()
9390 .map(|row| row.detail.clone())
9391 .collect::<Vec<_>>(),
9392 &legacy.rows,
9393 "omitted-selection relation rows and order",
9394 )?;
9395 require_eq(
9396 &classified_legacy.truncated,
9397 &legacy.truncated,
9398 "omitted-selection truncation",
9399 )?;
9400 require_eq(
9401 &classified_legacy
9402 .rows
9403 .iter()
9404 .map(|row| row.source_classification)
9405 .collect::<Vec<_>>(),
9406 &vec![
9407 Some(ContentClassification::Documentation),
9408 Some(ContentClassification::ConfigurationData),
9409 ],
9410 "omitted-selection additive classifications",
9411 )?;
9412
9413 let (source, source_statements) = trace_statements(&mut store, |store| {
9414 store.repository_graph_classified_relation_family_rows(
9415 calls,
9416 ContentSelection::Source,
9417 1,
9418 None,
9419 )
9420 })?;
9421 require(
9422 source.rows.len() == 1
9423 && !source.truncated
9424 && source.rows[0].detail.relation == relations[4]
9425 && source.rows[0].source_classification == Some(ContentClassification::Source),
9426 "source selection filtered only after a bounded mixed page",
9427 )?;
9428 require_eq(
9429 &source_statements
9430 .iter()
9431 .filter(|statement| statement.contains("file_content_classifications"))
9432 .count(),
9433 &1,
9434 "source classification statement count",
9435 )?;
9436 let documentation = store.repository_graph_classified_relation_family_rows(
9437 calls,
9438 ContentSelection::Documentation,
9439 1,
9440 None,
9441 )?;
9442 require(
9443 documentation.rows.len() == 1
9444 && documentation.truncated
9445 && documentation.rows[0].detail.relation == relations[0]
9446 && documentation.rows[0].source_classification
9447 == Some(ContentClassification::Documentation),
9448 "documentation selection did not preserve pre-limit ordering",
9449 )?;
9450 let both = store.repository_graph_classified_relation_family_rows(
9451 calls,
9452 ContentSelection::Both,
9453 2,
9454 None,
9455 )?;
9456 require(
9457 both.rows.len() == 2
9458 && both.truncated
9459 && both.rows[0].detail.relation == relations[0]
9460 && both.rows[1].detail.relation == relations[4]
9461 && both.rows.iter().all(|row| {
9462 row.source_classification.is_some_and(|classification| {
9463 ContentSelection::Both.includes(classification)
9464 })
9465 }),
9466 "combined selection admitted an excluded class or filtered after limit",
9467 )?;
9468
9469 for selection in [
9470 ContentSelection::UnspecifiedLegacy,
9471 ContentSelection::Source,
9472 ContentSelection::Both,
9473 ] {
9474 let mut bindings = vec![
9475 Value::Blob(project.as_bytes().to_vec()),
9476 Value::Text("legacy".to_string()),
9477 Value::Text("calls".to_string()),
9478 ];
9479 match selection {
9480 ContentSelection::UnspecifiedLegacy => {}
9481 ContentSelection::Source => bindings.push(Value::Text("source".to_string())),
9482 ContentSelection::Documentation => {
9483 bindings.push(Value::Text("documentation".to_string()));
9484 }
9485 ContentSelection::Both => bindings.extend([
9486 Value::Text("source".to_string()),
9487 Value::Text("documentation".to_string()),
9488 ]),
9489 }
9490 bindings.push(Value::Integer(3));
9491 let sql = format!(
9492 "EXPLAIN QUERY PLAN {}",
9493 classified_relation_family_sql(selection)
9494 );
9495 let mut statement = store.connection.prepare(&sql)?;
9496 let details = statement
9497 .query_map(params_from_iter(bindings.iter()), |row| {
9498 row.get::<_, String>(3)
9499 })?
9500 .collect::<Result<Vec<_>, _>>()?;
9501 require(
9502 details
9503 .iter()
9504 .any(|detail| detail.contains("idx_graph_relations_kind_order"))
9505 && details
9506 .iter()
9507 .any(|detail| detail.contains("file_content_classifications"))
9508 && details
9509 .iter()
9510 .all(|detail| !detail.contains("SCAN relation"))
9511 && details
9512 .iter()
9513 .all(|detail| !detail.contains("USE TEMP B-TREE")),
9514 &format!("classified family query did not retain indexed ordering: {details:?}"),
9515 )?;
9516 }
9517
9518 let invalid = require_db_error(
9519 store.repository_graph_classified_relation_family_rows(
9520 calls,
9521 ContentSelection::Source,
9522 0,
9523 None,
9524 ),
9525 "zero classified family limit succeeded",
9526 )?;
9527 require(
9528 matches!(
9529 invalid,
9530 DbError::GraphContract(GraphContractError::InvalidLimits { .. })
9531 ),
9532 "zero classified family limit returned the wrong error",
9533 )?;
9534 Ok(())
9535 }
9536
9537 #[test]
9538 fn filtered_terminal_probe_classifies_the_directional_endpoint() -> Result<(), Box<dyn Error>> {
9539 let temp = tempfile::tempdir()?;
9540 let root = temp.path().join("filtered-terminal-probe");
9541 fs::create_dir_all(&root)?;
9542 let mut store = AtlasStore::open_for_project(&root.join("projectatlas.db"), &root)?;
9543 let project = store
9544 .project_instance_id()?
9545 .ok_or_else(|| io::Error::other("filtered probe fixture identity is missing"))?;
9546 let generation = IndexGeneration::new(1);
9547 let doc_source = GraphEntity::new(
9548 project,
9549 EntitySelector::File {
9550 path: RepositoryFilePath::new(Path::new("docs/source.md"))?,
9551 },
9552 generation,
9553 )?;
9554 let source_target = GraphEntity::new(
9555 project,
9556 EntitySelector::File {
9557 path: RepositoryFilePath::new(Path::new("src/target.rs"))?,
9558 },
9559 generation,
9560 )?;
9561 let source_source = GraphEntity::new(
9562 project,
9563 EntitySelector::File {
9564 path: RepositoryFilePath::new(Path::new("src/source.rs"))?,
9565 },
9566 generation,
9567 )?;
9568 let doc_target = GraphEntity::new(
9569 project,
9570 EntitySelector::File {
9571 path: RepositoryFilePath::new(Path::new("docs/target.md"))?,
9572 },
9573 generation,
9574 )?;
9575 let external_document_target = GraphEntity::new(
9576 project,
9577 EntitySelector::External {
9578 external: ExternalSelector {
9579 system: GraphIdentityText::new("docs.example")?,
9580 identity: GraphIdentityText::new("external-page")?,
9581 },
9582 },
9583 generation,
9584 )?;
9585 let calls = GraphRelationKind::Legacy(RelationKind::Calls);
9586 let documents = GraphRelationKind::Extended(ExtendedRelationKind::Documents);
9587 let relations = vec![
9588 LogicalRelation::new(
9589 &doc_source,
9590 calls,
9591 RelationResolution::resolved(&source_target)?,
9592 ConfidenceClass::Exact,
9593 Completeness::Complete,
9594 generation,
9595 )?,
9596 LogicalRelation::new(
9597 &source_source,
9598 calls,
9599 RelationResolution::resolved(&doc_target)?,
9600 ConfidenceClass::Exact,
9601 Completeness::Complete,
9602 generation,
9603 )?,
9604 LogicalRelation::new(
9605 &doc_source,
9606 documents,
9607 RelationResolution::external(&external_document_target)?,
9608 ConfidenceClass::Exact,
9609 Completeness::Complete,
9610 generation,
9611 )?,
9612 LogicalRelation::new(
9613 &source_source,
9614 calls,
9615 RelationResolution::external(&external_document_target)?,
9616 ConfidenceClass::Exact,
9617 Completeness::Complete,
9618 generation,
9619 )?,
9620 ];
9621 let mut publication = store.begin_index_publication("filtered-terminal-probe")?;
9622 publication.begin_scan_replacement()?;
9623 publication.upsert_scan_node_batch(&[
9624 graph_node(".", NodeKind::Folder, None),
9625 graph_node("src", NodeKind::Folder, Some(".")),
9626 graph_node("docs", NodeKind::Folder, Some(".")),
9627 graph_node("src/target.rs", NodeKind::File, Some("src")),
9628 graph_node("src/source.rs", NodeKind::File, Some("src")),
9629 graph_node("docs/source.md", NodeKind::File, Some("docs")),
9630 graph_node("docs/target.md", NodeKind::File, Some("docs")),
9631 ])?;
9632 publication.finish_scan_replacement()?;
9633 publication.upsert_file_content_classification_batch(&[
9634 crate::FileContentClassification {
9635 path: "docs/source.md".to_string(),
9636 classification: ContentClassification::Documentation,
9637 },
9638 crate::FileContentClassification {
9639 path: "src/target.rs".to_string(),
9640 classification: ContentClassification::Source,
9641 },
9642 crate::FileContentClassification {
9643 path: "src/source.rs".to_string(),
9644 classification: ContentClassification::Source,
9645 },
9646 crate::FileContentClassification {
9647 path: "docs/target.md".to_string(),
9648 classification: ContentClassification::Documentation,
9649 },
9650 ])?;
9651 publication.replace_repository_graph(
9652 project,
9653 &[
9654 doc_source.clone(),
9655 source_target.clone(),
9656 source_source.clone(),
9657 doc_target.clone(),
9658 external_document_target,
9659 ],
9660 &relations,
9661 &[],
9662 &[],
9663 )?;
9664 publication.complete()?;
9665
9666 require(
9667 store.repository_graph_adjacency_is_empty_filtered(
9668 source_target.key(),
9669 RepositoryGraphDirection::Inbound,
9670 calls,
9671 ConfidenceClass::Exact,
9672 ContentSelection::Source,
9673 None,
9674 )?,
9675 "inbound documentation source was admitted under source selection",
9676 )?;
9677 require(
9678 !store.repository_graph_adjacency_is_empty_filtered(
9679 doc_target.key(),
9680 RepositoryGraphDirection::Inbound,
9681 calls,
9682 ConfidenceClass::Exact,
9683 ContentSelection::Source,
9684 None,
9685 )?,
9686 "inbound source endpoint was excluded under source selection",
9687 )?;
9688 require(
9689 !store.repository_graph_adjacency_is_empty_filtered(
9690 doc_source.key(),
9691 RepositoryGraphDirection::Outbound,
9692 calls,
9693 ConfidenceClass::Exact,
9694 ContentSelection::Source,
9695 None,
9696 )?,
9697 "outbound source-target endpoint was excluded under source selection",
9698 )?;
9699 require(
9700 !store.repository_graph_adjacency_is_empty_filtered(
9701 doc_source.key(),
9702 RepositoryGraphDirection::Outbound,
9703 documents,
9704 ConfidenceClass::Exact,
9705 ContentSelection::Source,
9706 None,
9707 )?,
9708 "external document endpoint was hidden under source selection",
9709 )?;
9710 require(
9711 !store.repository_graph_adjacency_is_empty_filtered(
9712 source_source.key(),
9713 RepositoryGraphDirection::Outbound,
9714 calls,
9715 ConfidenceClass::Exact,
9716 ContentSelection::Source,
9717 None,
9718 )?,
9719 "external non-document endpoint was hidden under source selection",
9720 )?;
9721 Ok(())
9722 }
9723
9724 #[test]
9725 fn resolved_relation_hubs_rank_the_complete_family_through_the_resolution_index()
9726 -> Result<(), Box<dyn Error>> {
9727 let temp = tempfile::tempdir()?;
9728 let root = temp.path().join("resolved-relation-hubs");
9729 fs::create_dir_all(&root)?;
9730 let mut store = AtlasStore::open_for_project(&root.join("projectatlas.db"), &root)?;
9731 let fixture = publish_fixture(&mut store, "resolved-relation-hubs")?;
9732
9733 let calls = store.repository_graph_resolved_relation_hubs(
9734 GraphRelationKind::Legacy(RelationKind::Calls),
9735 2,
9736 None,
9737 )?;
9738 let expected = BTreeSet::from([
9739 fixture.relations[0].source().digest().to_string(),
9740 fixture.relations[0]
9741 .resolution()
9742 .resolved_target()
9743 .ok_or_else(|| io::Error::other("resolved fixture target is missing"))?
9744 .digest()
9745 .to_string(),
9746 ]);
9747 let actual = calls
9748 .rows
9749 .iter()
9750 .map(|entity| entity.key().digest().to_string())
9751 .collect::<BTreeSet<_>>();
9752 require_eq(&actual, &expected, "resolved relation hubs")?;
9753 require_eq(&calls.truncated, &false, "resolved hub truncation")?;
9754
9755 let one = store.repository_graph_resolved_relation_hubs(
9756 GraphRelationKind::Legacy(RelationKind::Calls),
9757 1,
9758 None,
9759 )?;
9760 require_eq(&one.rows.len(), &1, "bounded resolved hub count")?;
9761 require_eq(&one.truncated, &true, "bounded resolved hub overflow")?;
9762 let unresolved = store.repository_graph_resolved_relation_hubs(
9763 GraphRelationKind::Extended(ExtendedRelationKind::Configures),
9764 2,
9765 None,
9766 )?;
9767 require_eq(&unresolved.rows.len(), &0, "unresolved hub exclusion")?;
9768 let external = store.repository_graph_resolved_relation_hubs(
9769 GraphRelationKind::Legacy(RelationKind::DependsOn),
9770 2,
9771 None,
9772 )?;
9773 require_eq(&external.rows.len(), &0, "external hub exclusion")?;
9774
9775 let invalid = require_db_error(
9776 store.repository_graph_resolved_relation_hubs(
9777 GraphRelationKind::Legacy(RelationKind::Calls),
9778 0,
9779 None,
9780 ),
9781 "zero resolved hub limit succeeded",
9782 )?;
9783 require(
9784 matches!(
9785 invalid,
9786 DbError::GraphContract(GraphContractError::InvalidLimits { .. })
9787 ),
9788 &format!("zero resolved hub limit returned {invalid}"),
9789 )?;
9790
9791 let mut statement = store.connection.prepare(&format!(
9792 "EXPLAIN QUERY PLAN {}",
9793 resolved_relation_hub_keys_sql()
9794 ))?;
9795 let details = statement
9796 .query_map(
9797 params![
9798 &fixture.project.as_bytes()[..],
9799 "legacy",
9800 "calls",
9801 RESOLUTION_STATUS_RESOLVED,
9802 3_i64,
9803 ],
9804 |row| row.get::<_, String>(3),
9805 )?
9806 .collect::<Result<Vec<_>, _>>()?;
9807 require(
9808 details
9809 .iter()
9810 .filter(|detail| detail.contains("idx_graph_relations_kind_resolution"))
9811 .count()
9812 >= 2
9813 && details
9814 .iter()
9815 .all(|detail| !detail.contains("SCAN graph_relations"))
9816 && details
9817 .iter()
9818 .all(|detail| !detail.contains("graph_entities")),
9819 &format!(
9820 "resolved hub ranking did not limit indexed endpoint keys before hydration: {details:?}"
9821 ),
9822 )?;
9823 let cancellation = projectatlas_core::IndexCancellation::new();
9824 cancellation.cancel();
9825 let cancelled = IndexWorkControl::new(cancellation, None);
9826 let error = require_db_error(
9827 store.repository_graph_resolved_relation_hubs(
9828 GraphRelationKind::Legacy(RelationKind::Calls),
9829 2,
9830 Some(&cancelled),
9831 ),
9832 "cancelled resolved hub ranking succeeded",
9833 )?;
9834 require(
9835 matches!(
9836 error,
9837 DbError::IndexWork(projectatlas_core::IndexWorkFailure::Cancelled {
9838 stage: IndexWorkStage::RepositoryTraversal
9839 })
9840 ),
9841 &format!("resolved hub cancellation was not typed: {error}"),
9842 )?;
9843 Ok(())
9844 }
9845
9846 #[test]
9847 fn resolved_preview_reads_bound_work_and_reject_corruption_when_paged()
9848 -> Result<(), Box<dyn Error>> {
9849 let temp = tempfile::tempdir()?;
9850 let root = temp.path().join("resolved-preview-bounds");
9851 fs::create_dir_all(&root)?;
9852 let mut store = AtlasStore::open_for_project(&root.join("projectatlas.db"), &root)?;
9853 let fixture =
9854 publish_detailed_relation_trace_fixture(&mut store, "resolved-preview-bounds")?;
9855 let source = fixture.source;
9856 let calls = GraphRelationKind::Legacy(RelationKind::Calls);
9857 let (_, hub_statements) = trace_statements(&mut store, |store| {
9858 store.repository_graph_resolved_relation_hubs(calls, 1, None)
9859 })?;
9860 require(
9861 hub_statements.iter().all(|statement| {
9862 !statement.contains("ORDER BY relation.relation_key")
9863 && !statement.contains("relation.canonical_identity")
9864 }),
9865 &format!("resolved hub lookup reconstructed omitted relation rows: {hub_statements:?}"),
9866 )?;
9867
9868 let budget = RepositoryGraphReadBudget::new(
9869 1,
9870 1,
9871 RepositoryGraphReadBudget::MAX_DECODED_BYTES,
9872 3,
9873 2,
9874 )?;
9875 let (first, adjacency_statements) = trace_statements(&mut store, |store| {
9876 store.repository_graph_adjacency_page_filtered_by_resolution_bounded(
9877 &[source.key().clone()],
9878 RepositoryGraphDirection::Outbound,
9879 Some(calls),
9880 true,
9881 false,
9882 None,
9883 1,
9884 budget,
9885 None,
9886 )
9887 })?;
9888 require(
9889 first.page.truncated
9890 && first.page.rows.len() == 1
9891 && first.page.continuation.is_some()
9892 && first.work.requested_rows == 1
9893 && first.work.returned_rows == 1
9894 && first.work.hydrated_entities <= 3
9895 && adjacency_statements.iter().all(|statement| {
9896 !statement.contains("ORDER BY relation.relation_key")
9897 || statement.contains("UNION ALL")
9898 }),
9899 &format!(
9900 "resolved adjacency exceeded its bounded evidence query: work={:?}, statements={adjacency_statements:?}",
9901 first.work
9902 ),
9903 )?;
9904
9905 let expired = IndexWorkControl::with_deadline(
9906 projectatlas_core::IndexCancellation::new(),
9907 Instant::now(),
9908 );
9909 for error in [
9910 require_db_error(
9911 store.repository_graph_resolved_relation_hubs(calls, 1, Some(&expired)),
9912 "expired resolved hub lookup succeeded",
9913 )?,
9914 require_db_error(
9915 store.repository_graph_resolved_adjacency_page(
9916 &[source.key().clone()],
9917 RepositoryGraphDirection::Outbound,
9918 calls,
9919 None,
9920 1,
9921 Some(&expired),
9922 ),
9923 "expired resolved adjacency lookup succeeded",
9924 )?,
9925 ] {
9926 require(
9927 matches!(
9928 error,
9929 DbError::IndexWork(projectatlas_core::IndexWorkFailure::DeadlineExceeded {
9930 stage: IndexWorkStage::RepositoryTraversal
9931 })
9932 ),
9933 &format!("resolved preview deadline was not typed: {error}"),
9934 )?;
9935 }
9936
9937 let continuation = first
9938 .page
9939 .continuation
9940 .ok_or_else(|| io::Error::other("resolved preview continuation is missing"))?;
9941 let mut relations = fixture.relations;
9942 relations.sort_by(|left, right| left.key().digest().cmp(right.key().digest()));
9943 let corrupt = relations
9944 .get(2)
9945 .ok_or_else(|| io::Error::other("late-page corruption relation is missing"))?;
9946 let corrupt_key = corrupt.key().digest_bytes()?;
9947 store
9948 .connection
9949 .execute_batch("PRAGMA ignore_check_constraints = ON")?;
9950 store.connection.execute(
9951 "UPDATE graph_relations
9952 SET canonical_identity = 'contradictory late-page relation'
9953 WHERE relation_key = ?1",
9954 [&corrupt_key[..]],
9955 )?;
9956 let healthy_first = store.repository_graph_resolved_adjacency_page(
9957 &[source.key().clone()],
9958 RepositoryGraphDirection::Outbound,
9959 calls,
9960 None,
9961 1,
9962 None,
9963 )?;
9964 require(
9965 healthy_first.rows.len() == 1 && healthy_first.continuation.is_some(),
9966 "corruption outside the bounded evidence page hid the healthy first page",
9967 )?;
9968 let error = require_db_error(
9969 store.repository_graph_resolved_adjacency_page(
9970 &[source.key().clone()],
9971 RepositoryGraphDirection::Outbound,
9972 calls,
9973 Some(&continuation),
9974 1,
9975 None,
9976 ),
9977 "late-page corrupt relation returned a partial adjacency page",
9978 )?;
9979 require(
9980 matches!(
9981 error,
9982 DbError::GraphContract(GraphContractError::StableKeyCollision { .. })
9983 ),
9984 &format!("late-page corrupt relation returned {error}"),
9985 )?;
9986 Ok(())
9987 }
9988
9989 #[test]
9990 fn resolved_preview_reads_reject_selected_relation_and_endpoint_corruption()
9991 -> Result<(), Box<dyn Error>> {
9992 let temp = tempfile::tempdir()?;
9993 let root = temp.path().join("resolved-preview-corruption");
9994 fs::create_dir_all(&root)?;
9995 let mut store = AtlasStore::open_for_project(&root.join("projectatlas.db"), &root)?;
9996 let fixture =
9997 publish_detailed_relation_trace_fixture(&mut store, "resolved-preview-corruption")?;
9998 let source = fixture.source;
9999 let mut relations = fixture.relations;
10000
10001 let calls = GraphRelationKind::Legacy(RelationKind::Calls);
10002 let healthy_hubs = store.repository_graph_resolved_relation_hubs(calls, 1, None)?;
10003 require(
10004 healthy_hubs.truncated
10005 && healthy_hubs.rows.len() == 1
10006 && healthy_hubs.rows[0].key() == source.key(),
10007 "healthy corruption fixture did not rank its source hub first",
10008 )?;
10009 let healthy_adjacency = store.repository_graph_resolved_adjacency_page(
10010 &[source.key().clone()],
10011 RepositoryGraphDirection::Outbound,
10012 calls,
10013 None,
10014 1,
10015 None,
10016 )?;
10017 require(
10018 healthy_adjacency.truncated && healthy_adjacency.rows.len() == 1,
10019 "healthy corruption fixture did not retain its adjacency sentinel",
10020 )?;
10021
10022 relations.sort_by(|left, right| left.key().digest().cmp(right.key().digest()));
10023 let top = relations
10024 .first()
10025 .ok_or_else(|| io::Error::other("top corruption relation is missing"))?;
10026 let source_key = source.key().digest_bytes()?;
10027 let top_key = top.key().digest_bytes()?;
10028 let top_target_key = top
10029 .resolution()
10030 .resolved_target()
10031 .ok_or_else(|| io::Error::other("top corruption target is missing"))?;
10032 let top_target = top_target_key.digest_bytes()?;
10033 store.connection.execute_batch(
10034 "PRAGMA foreign_keys = OFF;
10035 PRAGMA ignore_check_constraints = ON;",
10036 )?;
10037 store.connection.execute(
10038 "UPDATE graph_relations
10039 SET canonical_identity = 'contradictory visible relation identity'
10040 WHERE relation_key = ?1",
10041 [&top_key[..]],
10042 )?;
10043 let visible_identity = require_db_error(
10044 store.repository_graph_resolved_adjacency_page(
10045 &[source.key().clone()],
10046 RepositoryGraphDirection::Outbound,
10047 calls,
10048 None,
10049 1,
10050 None,
10051 ),
10052 "visible canonical-identity corruption returned an adjacency page",
10053 )?;
10054 require(
10055 matches!(
10056 visible_identity,
10057 DbError::GraphContract(GraphContractError::StableKeyCollision { .. })
10058 ),
10059 &format!("visible canonical-identity corruption returned {visible_identity}"),
10060 )?;
10061 store.connection.execute(
10062 "UPDATE graph_relations SET canonical_identity = ?1 WHERE relation_key = ?2",
10063 params![top.key().canonical_identity(), &top_key[..]],
10064 )?;
10065
10066 let visible_wrong_digest = [0_u8; 32];
10067 store.connection.execute(
10068 "UPDATE graph_relations SET relation_key = ?1 WHERE relation_key = ?2",
10069 params![&visible_wrong_digest[..], &top_key[..]],
10070 )?;
10071 let visible_digest = require_db_error(
10072 store.repository_graph_resolved_adjacency_page(
10073 &[source.key().clone()],
10074 RepositoryGraphDirection::Outbound,
10075 calls,
10076 None,
10077 1,
10078 None,
10079 ),
10080 "visible well-typed wrong digest returned an adjacency page",
10081 )?;
10082 require(
10083 matches!(
10084 visible_digest,
10085 DbError::GraphContract(GraphContractError::InvalidStableKeyDigest)
10086 ),
10087 &format!("visible well-typed wrong digest returned {visible_digest}"),
10088 )?;
10089 store.connection.execute(
10090 "UPDATE graph_relations SET relation_key = ?1 WHERE canonical_identity = ?2",
10091 params![&top_key[..], top.key().canonical_identity()],
10092 )?;
10093
10094 store.connection.execute(
10095 "UPDATE graph_entities SET canonical_identity = '' WHERE entity_key = ?1",
10096 [&source_key[..]],
10097 )?;
10098 let top_hub_endpoint = require_db_error(
10099 store.repository_graph_resolved_relation_hubs(calls, 1, None),
10100 "top-ranked corrupt hub endpoint returned a partial page",
10101 )?;
10102 require(
10103 matches!(top_hub_endpoint, DbError::GraphContract(_)),
10104 &format!("top-ranked corrupt hub endpoint returned {top_hub_endpoint}"),
10105 )?;
10106 store.connection.execute(
10107 "UPDATE graph_entities SET canonical_identity = ?1 WHERE entity_key = ?2",
10108 params![source.key().canonical_identity(), &source_key[..]],
10109 )?;
10110
10111 store.connection.execute(
10112 "UPDATE graph_entities SET canonical_identity = '' WHERE entity_key = ?1",
10113 [&top_target[..]],
10114 )?;
10115 let top_adjacency_endpoint = require_db_error(
10116 store.repository_graph_resolved_adjacency_page(
10117 &[source.key().clone()],
10118 RepositoryGraphDirection::Outbound,
10119 calls,
10120 None,
10121 1,
10122 None,
10123 ),
10124 "top-page corrupt adjacency endpoint returned a partial page",
10125 )?;
10126 require(
10127 matches!(top_adjacency_endpoint, DbError::GraphContract(_)),
10128 &format!("top-page corrupt adjacency endpoint returned {top_adjacency_endpoint}"),
10129 )?;
10130 store.connection.execute(
10131 "UPDATE graph_entities SET canonical_identity = ?1 WHERE entity_key = ?2",
10132 params![top_target_key.canonical_identity(), &top_target[..]],
10133 )?;
10134
10135 Ok(())
10136 }
10137
10138 #[test]
10139 fn resolved_adjacency_filters_self_edges_before_limits_and_binds_its_cursor()
10140 -> Result<(), Box<dyn Error>> {
10141 let temp = tempfile::tempdir()?;
10142 let root = temp.path().join("resolved-adjacency-self-edge");
10143 fs::create_dir_all(&root)?;
10144 let mut store = AtlasStore::open_for_project(&root.join("projectatlas.db"), &root)?;
10145 let fixture = publish_fixture(&mut store, "resolved-adjacency-self-edge")?;
10146 let generation = IndexGeneration::new(1);
10147 let calls = GraphRelationKind::Legacy(RelationKind::Calls);
10148 let mut selected = None;
10149 for index in 0..1_000 {
10150 let source_name = format!("self_edge_source_{index}");
10151 let target_name = format!("self_edge_target_{index}");
10152 let symbol = |name: &str| -> Result<GraphEntity, Box<dyn Error>> {
10153 Ok(GraphEntity::new(
10154 fixture.project,
10155 EntitySelector::Symbol {
10156 symbol: SymbolSelector {
10157 file: RepositoryFilePath::new(Path::new("src/Äuth.rs"))?,
10158 name: GraphIdentityText::new(name)?,
10159 kind: SymbolKind::Function,
10160 parent: None,
10161 signature: GraphIdentityText::new(format!("{name}()"))?,
10162 },
10163 },
10164 generation,
10165 )?)
10166 };
10167 let source = symbol(&source_name)?;
10168 let target = symbol(&target_name)?;
10169 let self_relation = LogicalRelation::new(
10170 &source,
10171 calls,
10172 RelationResolution::resolved(&source)?,
10173 ConfidenceClass::Exact,
10174 Completeness::Complete,
10175 generation,
10176 )?;
10177 let useful = LogicalRelation::new(
10178 &source,
10179 calls,
10180 RelationResolution::resolved(&target)?,
10181 ConfidenceClass::Exact,
10182 Completeness::Complete,
10183 generation,
10184 )?;
10185 if self_relation.key().digest() < useful.key().digest() {
10186 selected = Some((source, target, self_relation, useful));
10187 break;
10188 }
10189 }
10190 let (source, target, self_relation, useful) = selected
10191 .ok_or_else(|| io::Error::other("could not order a self edge before a useful edge"))?;
10192 insert_entities(&store.connection, fixture.project, [&source, &target])?;
10193 insert_relations(
10194 &store.connection,
10195 fixture.project,
10196 &[self_relation.clone(), useful.clone()],
10197 )?;
10198
10199 let frontier = [source.key().clone()];
10200 let first = store.repository_graph_resolved_adjacency_page(
10201 &frontier,
10202 RepositoryGraphDirection::Outbound,
10203 calls,
10204 None,
10205 1,
10206 None,
10207 )?;
10208 require(
10209 first.rows.len() == 1
10210 && first.rows[0].detail.relation.key() == useful.key()
10211 && first.rows[0].detail.relation.key() != self_relation.key(),
10212 "self relation consumed the resolved adjacency limit before a useful edge",
10213 )?;
10214
10215 let cancellation = projectatlas_core::IndexCancellation::new();
10216 cancellation.cancel();
10217 let cancelled = IndexWorkControl::new(cancellation, None);
10218 let cancellation_error = require_db_error(
10219 store.repository_graph_resolved_adjacency_page(
10220 &frontier,
10221 RepositoryGraphDirection::Outbound,
10222 calls,
10223 None,
10224 1,
10225 Some(&cancelled),
10226 ),
10227 "cancelled resolved adjacency succeeded",
10228 )?;
10229 require(
10230 matches!(
10231 cancellation_error,
10232 DbError::IndexWork(projectatlas_core::IndexWorkFailure::Cancelled {
10233 stage: IndexWorkStage::RepositoryTraversal
10234 })
10235 ),
10236 &format!("resolved adjacency cancellation was not typed: {cancellation_error}"),
10237 )?;
10238 Ok(())
10239 }
10240
10241 #[test]
10242 fn repository_graph_staging_checkpoints_ownership_before_graph_writes()
10243 -> Result<(), Box<dyn Error>> {
10244 let temp = tempfile::tempdir()?;
10245 let project_root = temp.path().join("staging-ownership");
10246 let stage = project_root.join(".projectatlas").join("graph-stage-test");
10247 fs::create_dir_all(&stage)?;
10248 let database = stage.join("projectatlas.db");
10249 let project = ProjectInstanceId::from_bytes([7; 16])?;
10250 let _store =
10251 AtlasStore::create_repository_graph_staging(&database, &project_root, project)?;
10252
10253 let main_database_only = temp.path().join("staging-main-only.db");
10254 fs::copy(&database, &main_database_only)?;
10255 require(
10256 AtlasStore::repository_graph_staging_belongs_to(
10257 &main_database_only,
10258 &project_root,
10259 project,
10260 )?,
10261 "staging ownership depended on WAL sidecars before graph writes",
10262 )
10263 }
10264
10265 #[cfg(windows)]
10266 #[test]
10267 fn repository_graph_staging_accepts_case_only_root_rename() -> Result<(), Box<dyn Error>> {
10268 let temp = tempfile::tempdir()?;
10269 let original_root = temp.path().join("CaseOnlyStageRoot");
10270 let intermediate_root = temp.path().join("CaseOnlyStageRoot-intermediate");
10271 let renamed_root = temp.path().join("caseonlystageroot");
10272 let stage = original_root.join(".projectatlas").join("graph-stage-case");
10273 fs::create_dir_all(&stage)?;
10274 let database = stage.join("projectatlas.db");
10275 let project = ProjectInstanceId::from_bytes([8; 16])?;
10276 drop(AtlasStore::create_repository_graph_staging(
10277 &database,
10278 &original_root,
10279 project,
10280 )?);
10281
10282 fs::rename(&original_root, &intermediate_root)?;
10283 fs::rename(&intermediate_root, &renamed_root)?;
10284 let renamed_database = renamed_root
10285 .join(".projectatlas")
10286 .join("graph-stage-case")
10287 .join("projectatlas.db");
10288 require(
10289 AtlasStore::repository_graph_staging_belongs_to(
10290 &renamed_database,
10291 &renamed_root,
10292 project,
10293 )?,
10294 "case-only renamed staging root was not re-canonicalized",
10295 )
10296 }
10297
10298 #[test]
10299 fn repository_graph_staging_persists_document_reasons_for_bounded_copy()
10300 -> Result<(), Box<dyn Error>> {
10301 let temp = tempfile::tempdir()?;
10302 let project_root = temp.path().join("staging-document-reasons");
10303 fs::create_dir(&project_root)?;
10304 let main_database = project_root.join("projectatlas.db");
10305 let mut main = AtlasStore::open_for_project(&main_database, &project_root)?;
10306 let project = main
10307 .project_instance_id()?
10308 .ok_or_else(|| io::Error::other("staging fixture identity is missing"))?;
10309 let generation = IndexGeneration::new(1);
10310 let stage_database = project_root.join("graph-stage.db");
10311 let mut stage =
10312 AtlasStore::create_repository_graph_staging(&stage_database, &project_root, project)?;
10313 let nodes = [
10314 graph_node(".", NodeKind::Folder, None),
10315 graph_node("docs", NodeKind::Folder, Some(".")),
10316 graph_node("docs/guide.md", NodeKind::File, Some("docs")),
10317 ];
10318 stage.replace_scan(&nodes)?;
10319 let document = GraphEntity::new(
10320 project,
10321 EntitySelector::File {
10322 path: RepositoryFilePath::new(Path::new("docs/guide.md"))?,
10323 },
10324 generation,
10325 )?;
10326 let unresolved_document = LogicalRelation::new(
10327 &document,
10328 GraphRelationKind::Extended(ExtendedRelationKind::Documents),
10329 RelationResolution::Unresolved {
10330 reference: GraphIdentityText::new("missing.md")?,
10331 },
10332 ConfidenceClass::Exact,
10333 Completeness::Complete,
10334 generation,
10335 )?;
10336 let unrelated = LogicalRelation::new(
10337 &document,
10338 GraphRelationKind::Extended(ExtendedRelationKind::Configures),
10339 RelationResolution::Unresolved {
10340 reference: GraphIdentityText::new("setting")?,
10341 },
10342 ConfidenceClass::Low,
10343 Completeness::Partial,
10344 generation,
10345 )?;
10346 let mut staging = stage.begin_repository_graph_staging(project, generation)?;
10347 staging.append_batch(
10348 std::slice::from_ref(&document),
10349 &[unresolved_document.clone(), unrelated.clone()],
10350 &[],
10351 &[],
10352 &[],
10353 &[],
10354 )?;
10355 let mixed = require_db_error(
10356 staging.set_document_unresolved_reasons(&[
10357 (
10358 unresolved_document.key().clone(),
10359 DocumentTargetUnresolvedReason::Missing,
10360 ),
10361 (
10362 unrelated.key().clone(),
10363 DocumentTargetUnresolvedReason::Unsupported,
10364 ),
10365 ]),
10366 "staging accepted a reason for a non-document relation",
10367 )?;
10368 require(
10369 matches!(mixed, DbError::GraphRowShape { .. }),
10370 "staging mixed reason batch returned the wrong error",
10371 )?;
10372 let relation_key = unresolved_document.key().digest_bytes()?;
10373 let staged_reason = staging.transaction.query_row(
10374 "SELECT document_unresolved_reason FROM graph_relations WHERE relation_key = ?1",
10375 [&relation_key[..]],
10376 |row| row.get::<_, Option<String>>(0),
10377 )?;
10378 require(
10379 staged_reason.is_none(),
10380 "failed staged reason batch exposed partial mutation",
10381 )?;
10382 staging.set_document_unresolved_reasons(&[(
10383 unresolved_document.key().clone(),
10384 DocumentTargetUnresolvedReason::Missing,
10385 )])?;
10386 staging.complete()?;
10387 stage.checkpoint_repository_graph_staging()?;
10388
10389 let cancellation = projectatlas_core::IndexCancellation::new();
10390 cancellation.cancel();
10391 let canceled = IndexWorkControl::new(cancellation, None);
10392 let mut canceled_publication =
10393 main.begin_index_publication("canceled-staged-document-reasons")?;
10394 canceled_publication.begin_scan_replacement()?;
10395 canceled_publication.upsert_scan_node_batch(&nodes)?;
10396 canceled_publication.finish_scan_replacement()?;
10397 let cancellation_error = require_db_error(
10398 canceled_publication.replace_repository_graph_from_staging(
10399 project,
10400 &stage,
10401 Some(&canceled),
10402 ),
10403 "canceled stage copy committed",
10404 )?;
10405 require(
10406 matches!(
10407 cancellation_error,
10408 DbError::IndexWork(projectatlas_core::IndexWorkFailure::Cancelled {
10409 stage: IndexWorkStage::Publication
10410 })
10411 ),
10412 "canceled stage copy returned the wrong error",
10413 )?;
10414 drop(canceled_publication);
10415 require(
10416 main.repository_graph_generation()?.is_none(),
10417 "canceled stage copy exposed a graph generation",
10418 )?;
10419
10420 let mut publication = main.begin_index_publication("staged-document-reasons")?;
10421 publication.begin_scan_replacement()?;
10422 publication.upsert_scan_node_batch(&nodes)?;
10423 publication.finish_scan_replacement()?;
10424 publication.replace_repository_graph_from_staging(project, &stage, None)?;
10425 publication.complete()?;
10426
10427 let copied = main.repository_graph_relation_rows(
10428 RepositoryGraphRelationQuery::Family {
10429 relation: GraphRelationKind::Extended(ExtendedRelationKind::Documents),
10430 },
10431 10,
10432 None,
10433 )?;
10434 require(
10435 copied.rows.len() == 1
10436 && copied.rows[0].document_unresolved_reason
10437 == Some(DocumentTargetUnresolvedReason::Missing),
10438 "stage-to-publication copy lost the closed document reason",
10439 )?;
10440 Ok(())
10441 }
10442
10443 #[test]
10444 fn resolution_batch_reads_admit_long_witnesses_before_decoding_or_cloning()
10445 -> Result<(), Box<dyn Error>> {
10446 let temp = tempfile::tempdir()?;
10447 let root = temp.path();
10448 let db = root.join("atlas.db");
10449 let mut store = AtlasStore::open_for_project(&db, root)?;
10450 let project = store
10451 .project_instance_id()?
10452 .ok_or("missing read-budget fixture identity")?;
10453 let graph = graph_fixture(project, IndexGeneration::new(1))?;
10454 let key = declaration_key(
10455 project,
10456 &"n".repeat(3_000),
10457 GraphRelationKind::Legacy(RelationKind::Calls),
10458 )?;
10459 let export = EntityResolutionKey::new(graph.entities[4].key().clone(), key.clone())?;
10460 let dependency = RelationDependencyKey::new(graph.relations[0].key().clone(), key.clone())?;
10461 {
10462 let mut publication = store.begin_index_publication("resolution-read-budget")?;
10463 publication.begin_scan_replacement()?;
10464 publication.upsert_scan_node_batch(&[
10465 graph_node(".", NodeKind::Folder, None),
10466 graph_node("src", NodeKind::Folder, Some(".")),
10467 graph_node("src/Äuth.rs", NodeKind::File, Some("src")),
10468 graph_node("Cargo.toml", NodeKind::File, Some(".")),
10469 ])?;
10470 publication.finish_scan_replacement()?;
10471 publication.replace_repository_graph_with_resolution_keys(
10472 project,
10473 &graph.entities,
10474 &graph.relations,
10475 &graph.occurrences,
10476 &graph.coverage,
10477 &[export],
10478 &[dependency],
10479 )?;
10480 publication.complete()?;
10481 }
10482 drop(store);
10483 let reader = AtlasStore::open_read_only_for_project(&db, root)?;
10484 let before = reader.index_publication()?;
10485 let paths = vec!["src/Äuth.rs".to_string()];
10486 let enough = 1_024 * 1_024;
10487 let exports =
10488 reader.repository_export_keys_for_paths_with_byte_limit(project, &paths, 10, enough)?;
10489 require_eq(
10490 &exports.page.rows,
10491 &vec![key.clone()],
10492 "bounded long export witness",
10493 )?;
10494 let candidates = reader.repository_resolution_candidates_for_keys_with_byte_limit(
10495 project,
10496 std::slice::from_ref(&key),
10497 10,
10498 enough,
10499 )?;
10500 require(
10501 candidates.page.rows.len() == 1,
10502 "bounded read lost its candidate",
10503 )?;
10504 let affected = reader.repository_affected_source_paths_with_byte_limit(
10505 project,
10506 std::slice::from_ref(&key),
10507 10,
10508 enough,
10509 )?;
10510 require(
10511 !affected.page.rows.is_empty(),
10512 "bounded read lost its dependent path",
10513 )?;
10514 require_eq(
10515 &reader
10516 .repository_export_keys_for_paths(project, &paths, 10)?
10517 .rows,
10518 &exports.page.rows,
10519 "legacy export reader changed output",
10520 )?;
10521 require_eq(
10522 &reader
10523 .repository_resolution_candidates_for_keys(
10524 project,
10525 &[key.clone(), key.clone()],
10526 10,
10527 )?
10528 .rows,
10529 &candidates.page.rows,
10530 "legacy/duplicate candidate reader changed output",
10531 )?;
10532 require(
10533 exports.retained_bytes > 0
10534 && candidates.retained_bytes > 0
10535 && affected.retained_bytes > 0,
10536 "bounded reader dropped returned-page ownership",
10537 )?;
10538 for result in [
10539 reader
10540 .repository_export_keys_for_paths_with_byte_limit(project, &paths, 10, 1_024)
10541 .map(|read| read.page.rows.len()),
10542 reader
10543 .repository_resolution_candidates_for_keys_with_byte_limit(
10544 project,
10545 std::slice::from_ref(&key),
10546 10,
10547 1_024,
10548 )
10549 .map(|read| read.page.rows.len()),
10550 reader
10551 .repository_affected_source_paths_with_byte_limit(
10552 project,
10553 std::slice::from_ref(&key),
10554 10,
10555 1_024,
10556 )
10557 .map(|read| read.page.rows.len()),
10558 ] {
10559 require(
10560 matches!(
10561 result,
10562 Err(DbError::IndexWork(
10563 projectatlas_core::IndexWorkFailure::ResourceLimitExceeded {
10564 resource: projectatlas_core::IndexWorkResource::OutputBytes,
10565 ..
10566 }
10567 ))
10568 ),
10569 "long resolution batch decoded or cloned past its allowance",
10570 )?;
10571 }
10572 require_eq(
10573 &reader.index_publication()?,
10574 &before,
10575 "bounded reads changed publication state",
10576 )?;
10577 Ok(())
10578 }
10579
10580 #[test]
10581 fn resolution_keys_round_trip_reopen_and_preserve_all_dependency_states()
10582 -> Result<(), Box<dyn Error>> {
10583 let temp = tempfile::tempdir()?;
10584 let project_root = temp.path().join("resolution-round-trip");
10585 let atlas_dir = project_root.join(".projectatlas");
10586 fs::create_dir_all(&atlas_dir)?;
10587 let db_path = atlas_dir.join("projectatlas.db");
10588 let mut writer = AtlasStore::open_for_project(&db_path, &project_root)?;
10589 let fixture = publish_resolution_fixture(&mut writer, "resolution-round-trip")?;
10590 assert_query_indexes(&writer)?;
10591
10592 let counts = writer.connection.query_row(
10593 "SELECT
10594 (SELECT COUNT(*) FROM graph_resolution_keys),
10595 (SELECT COUNT(*) FROM graph_entity_exports),
10596 (SELECT COUNT(*) FROM graph_relation_dependencies)",
10597 [],
10598 |row| {
10599 Ok((
10600 row.get::<_, i64>(0)?,
10601 row.get::<_, i64>(1)?,
10602 row.get::<_, i64>(2)?,
10603 ))
10604 },
10605 )?;
10606 require_eq(&counts, &(3, 1, 3), "deduplicated resolution-key rows")?;
10607 let states = writer
10608 .connection
10609 .prepare(
10610 "SELECT relation.resolution_status, COUNT(*)
10611 FROM graph_relation_dependencies AS dependency
10612 JOIN graph_relations AS relation
10613 ON relation.project_instance_id = dependency.project_instance_id
10614 AND relation.relation_key = dependency.relation_key
10615 GROUP BY relation.resolution_status
10616 ORDER BY relation.resolution_status",
10617 )?
10618 .query_map([], |row| {
10619 Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
10620 })?
10621 .collect::<Result<Vec<_>, _>>()?;
10622 require_eq(
10623 &states,
10624 &vec![
10625 ("ambiguous".to_string(), 1),
10626 ("resolved".to_string(), 1),
10627 ("unresolved".to_string(), 1),
10628 ],
10629 "dependency resolution states",
10630 )?;
10631 drop(writer);
10632
10633 let reader = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
10634 let exports = reader.repository_export_keys_for_paths(
10635 fixture.graph.project,
10636 &["src/Äuth.rs".to_string()],
10637 10,
10638 )?;
10639 require_eq(&exports.truncated, &false, "export key truncation")?;
10640 require_eq(
10641 &exports.rows,
10642 &vec![fixture.resolved.clone()],
10643 "export key round trip",
10644 )?;
10645 let candidates = reader.repository_resolution_candidates(&fixture.resolved, 10)?;
10646 require_eq(&candidates.truncated, &false, "candidate truncation")?;
10647 require_eq(
10648 &candidates.rows,
10649 &vec![fixture.graph.entities[4].clone()],
10650 "candidate export round trip",
10651 )?;
10652 let batch_candidates = reader.repository_resolution_candidates_for_keys(
10653 fixture.graph.project,
10654 &[
10655 fixture.unresolved.clone(),
10656 fixture.resolved.clone(),
10657 fixture.ambiguous.clone(),
10658 fixture.resolved.clone(),
10659 ],
10660 10,
10661 )?;
10662 require_eq(
10663 &batch_candidates.truncated,
10664 &false,
10665 "batch candidate truncation",
10666 )?;
10667 require_eq(
10668 &batch_candidates.rows.len(),
10669 &1,
10670 "batch candidate deduplication",
10671 )?;
10672 require_eq(
10673 batch_candidates.rows[0].key(),
10674 &fixture.resolved,
10675 "batch candidate selecting key",
10676 )?;
10677 require_eq(
10678 batch_candidates.rows[0].entity(),
10679 &fixture.graph.entities[4],
10680 "batch candidate entity",
10681 )?;
10682 let affected = reader.repository_affected_source_paths(
10683 fixture.graph.project,
10684 &[
10685 fixture.resolved.clone(),
10686 fixture.resolved,
10687 fixture.ambiguous,
10688 fixture.unresolved,
10689 ],
10690 10,
10691 )?;
10692 require_eq(&affected.truncated, &false, "affected path truncation")?;
10693 require_eq(
10694 &affected.rows,
10695 &vec![RepositoryFilePath::new(Path::new("src/Äuth.rs"))?],
10696 "resolved ambiguous and unresolved dependency owners",
10697 )?;
10698 reader.finish_index_read_snapshot()?;
10699 Ok(())
10700 }
10701
10702 #[test]
10703 fn high_degree_dependency_closure_is_unique_and_reports_overflow_before_mutation()
10704 -> Result<(), Box<dyn Error>> {
10705 let temp = tempfile::tempdir()?;
10706 let project_root = temp.path().join("high-degree-resolution");
10707 fs::create_dir_all(&project_root)?;
10708 let db_path = project_root.join("projectatlas.db");
10709 let mut store = AtlasStore::open_for_project(&db_path, &project_root)?;
10710 let project = store
10711 .project_instance_id()?
10712 .ok_or_else(|| io::Error::other("bound identity is missing"))?;
10713 let generation = IndexGeneration::new(1);
10714 let dependency_key = declaration_key(
10715 project,
10716 "sharedTarget",
10717 GraphRelationKind::Extended(ExtendedRelationKind::References),
10718 )?;
10719 let mut nodes = vec![
10720 graph_node(".", NodeKind::Folder, None),
10721 graph_node("src", NodeKind::Folder, Some(".")),
10722 ];
10723 let mut entities = Vec::new();
10724 let mut relations = Vec::new();
10725 let mut dependencies = Vec::new();
10726 for index in 0..5 {
10727 let path = format!("src/caller-{index}.rs");
10728 nodes.push(graph_node(&path, NodeKind::File, Some("src")));
10729 let entity = GraphEntity::new(
10730 project,
10731 EntitySelector::File {
10732 path: RepositoryFilePath::new(Path::new(&path))?,
10733 },
10734 generation,
10735 )?;
10736 let relation = LogicalRelation::new(
10737 &entity,
10738 GraphRelationKind::Extended(ExtendedRelationKind::References),
10739 RelationResolution::Unresolved {
10740 reference: GraphIdentityText::new("sharedTarget")?,
10741 },
10742 ConfidenceClass::High,
10743 Completeness::Complete,
10744 generation,
10745 )?;
10746 dependencies.push(RelationDependencyKey::new(
10747 relation.key().clone(),
10748 dependency_key.clone(),
10749 )?);
10750 entities.push(entity);
10751 relations.push(relation);
10752 }
10753 dependencies.push(dependencies[0].clone());
10754 let mut publication = store.begin_index_publication("high-degree-resolution")?;
10755 publication.begin_scan_replacement()?;
10756 publication.upsert_scan_node_batch(&nodes)?;
10757 publication.finish_scan_replacement()?;
10758 publication.replace_repository_graph_with_resolution_keys(
10759 project,
10760 &entities,
10761 &relations,
10762 &[],
10763 &[],
10764 &[],
10765 &dependencies,
10766 )?;
10767 publication.complete()?;
10768
10769 let before = store.index_publication()?;
10770 let bounded = store.repository_affected_source_paths(
10771 project,
10772 std::slice::from_ref(&dependency_key),
10773 2,
10774 )?;
10775 require_eq(&bounded.rows.len(), &2, "bounded affected path count")?;
10776 require_eq(&bounded.truncated, &true, "bounded affected path overflow")?;
10777 require_eq(
10778 &store.index_publication()?,
10779 &before,
10780 "overflow lookup mutated publication state",
10781 )?;
10782 let complete = store.repository_affected_source_paths(project, &[dependency_key], 10)?;
10783 require_eq(&complete.rows.len(), &5, "complete affected path count")?;
10784 require_eq(
10785 &complete.truncated,
10786 &false,
10787 "complete affected path overflow",
10788 )?;
10789 let unique = complete.rows.iter().collect::<BTreeSet<_>>();
10790 require_eq(&unique.len(), &5, "affected paths are unique")?;
10791 Ok(())
10792 }
10793
10794 #[test]
10795 fn affected_source_footprint_accounts_exact_owned_rows_and_uses_path_indexes()
10796 -> Result<(), Box<dyn Error>> {
10797 let temp = tempfile::tempdir()?;
10798 let project_root = temp.path().join("affected-source-footprint");
10799 fs::create_dir_all(&project_root)?;
10800 let db_path = project_root.join("projectatlas.db");
10801 let mut store = AtlasStore::open_for_project(&db_path, &project_root)?;
10802 let fixture = publish_resolution_fixture(&mut store, "affected-source-footprint")?;
10803 replace_fixture_symbol_rows(&mut store, "src/Äuth.rs", 1)?;
10804 store.connection.execute(
10805 "INSERT INTO graph_coverage(
10806 project_instance_id, scope_kind, scope_path, relation_scope,
10807 relation_kind, state, total, covered, omitted, reason, reached_limit
10808 ) VALUES(?1, 'path', 'src/Äuth.rs', NULL, NULL,
10809 'complete', 1, 1, 0, NULL, NULL)",
10810 [&fixture.graph.project.as_bytes()[..]],
10811 )?;
10812
10813 let mut paths = (0..=RESOLUTION_PATHS_PER_QUERY)
10814 .map(|index| format!("missing/{index:04}.rs"))
10815 .collect::<Vec<_>>();
10816 paths.extend(["src/Äuth.rs".to_string(), "src/Äuth.rs".to_string()]);
10817 let footprint =
10818 store.repository_affected_source_footprint(fixture.graph.project, &paths, 100)?;
10819 require_eq(&footprint.rows, &20, "exact affected persisted rows")?;
10820 require(
10821 footprint.retained_bytes > footprint.rows,
10822 "affected footprint omitted decoded bytes",
10823 )?;
10824 require_eq(&footprint.truncated, &false, "exact footprint truncation")?;
10825
10826 let sql = format!("EXPLAIN QUERY PLAN {}", affected_source_footprint_sql(1));
10827 let values = [
10828 Value::Blob(fixture.graph.project.as_bytes().to_vec()),
10829 Value::Text("src/Äuth.rs".to_string()),
10830 Value::Integer(101),
10831 ];
10832 let mut statement = store.connection.prepare(&sql)?;
10833 let details = statement
10834 .query_map(params_from_iter(values.iter()), |row| {
10835 row.get::<_, String>(3)
10836 })?
10837 .collect::<Result<Vec<_>, _>>()?;
10838 for index in [
10839 "sqlite_autoindex_source_parse_metadata_1",
10840 "idx_symbols_path",
10841 "idx_symbol_relations_path",
10842 "idx_graph_entities_path",
10843 "idx_graph_entities_manifest_path",
10844 "idx_graph_relations_source_kind",
10845 "idx_graph_occurrences_file_span",
10846 "idx_graph_coverage_path",
10847 "idx_graph_entity_exports_owner",
10848 "idx_graph_relation_dependencies_owner",
10849 "sqlite_autoindex_graph_resolution_keys_1",
10850 ] {
10851 require(
10852 details.iter().any(|detail| detail.contains(index)),
10853 &format!("affected footprint missed {index}; plan was {details:?}"),
10854 )?;
10855 }
10856 require(
10857 details.iter().all(|detail| !detail.contains("SCAN graph_")),
10858 &format!("affected footprint scanned graph storage: {details:?}"),
10859 )?;
10860 Ok(())
10861 }
10862
10863 #[test]
10864 fn affected_source_footprint_reports_high_degree_overflow_before_mutation()
10865 -> Result<(), Box<dyn Error>> {
10866 let temp = tempfile::tempdir()?;
10867 let project_root = temp.path().join("affected-source-degree");
10868 fs::create_dir_all(&project_root)?;
10869 let db_path = project_root.join("projectatlas.db");
10870 let mut store = AtlasStore::open_for_project(&db_path, &project_root)?;
10871 let fixture = publish_resolution_fixture(&mut store, "affected-source-degree")?;
10872 replace_fixture_symbol_rows(&mut store, "src/Äuth.rs", 25)?;
10873 let before = store.index_publication()?;
10874
10875 let bounded = store.repository_affected_source_footprint(
10876 fixture.graph.project,
10877 &["src/Äuth.rs".to_string()],
10878 5,
10879 )?;
10880 require_eq(&bounded.rows, &6, "footprint limit plus one sentinel")?;
10881 require_eq(&bounded.truncated, &true, "high-degree footprint overflow")?;
10882 require(
10883 bounded.retained_bytes > 0,
10884 "bounded footprint lost retained bytes",
10885 )?;
10886 require_eq(
10887 &store.index_publication()?,
10888 &before,
10889 "footprint overflow mutated publication",
10890 )?;
10891 Ok(())
10892 }
10893
10894 #[test]
10895 fn affected_source_footprint_validates_inputs_identity_and_graph_availability()
10896 -> Result<(), Box<dyn Error>> {
10897 let temp = tempfile::tempdir()?;
10898 let selected_root = temp.path().join("selected");
10899 let other_root = temp.path().join("other");
10900 fs::create_dir_all(&selected_root)?;
10901 fs::create_dir_all(&other_root)?;
10902 let mut selected =
10903 AtlasStore::open_for_project(&selected_root.join("projectatlas.db"), &selected_root)?;
10904 let fixture = publish_resolution_fixture(&mut selected, "footprint-validation")?;
10905 let other = AtlasStore::open_for_project(&other_root.join("projectatlas.db"), &other_root)?;
10906 let other_project = other
10907 .project_instance_id()?
10908 .ok_or_else(|| io::Error::other("other bound identity is missing"))?;
10909
10910 let invalid_limit = require_db_error(
10911 selected.repository_affected_source_footprint(
10912 fixture.graph.project,
10913 &["src/Äuth.rs".to_string()],
10914 0,
10915 ),
10916 "zero footprint limit was accepted",
10917 )?;
10918 require(
10919 matches!(invalid_limit, DbError::GraphContract(_)),
10920 &format!("invalid footprint limit returned {invalid_limit}"),
10921 )?;
10922 let invalid_path = require_db_error(
10923 selected.repository_affected_source_footprint(
10924 fixture.graph.project,
10925 &["../outside.rs".to_string()],
10926 10,
10927 ),
10928 "escaping footprint path was accepted",
10929 )?;
10930 require(
10931 matches!(invalid_path, DbError::GraphContract(_)),
10932 &format!("invalid footprint path returned {invalid_path}"),
10933 )?;
10934 let mismatched = require_db_error(
10935 selected.repository_affected_source_footprint(
10936 other_project,
10937 &["src/Äuth.rs".to_string()],
10938 10,
10939 ),
10940 "mismatched project footprint was accepted",
10941 )?;
10942 require(
10943 matches!(mismatched, DbError::GraphProjectIdentityMismatch { .. }),
10944 &format!("mismatched footprint returned {mismatched}"),
10945 )?;
10946 require_eq(
10947 &other.repository_affected_source_footprint(
10948 other_project,
10949 &["src/Äuth.rs".to_string()],
10950 10,
10951 )?,
10952 &empty_affected_source_footprint(),
10953 "unpublished graph footprint",
10954 )?;
10955 Ok(())
10956 }
10957
10958 #[test]
10959 fn affected_source_footprint_rejects_missing_resolution_witnesses() -> Result<(), Box<dyn Error>>
10960 {
10961 let temp = tempfile::tempdir()?;
10962 let project_root = temp.path().join("affected-source-corruption");
10963 fs::create_dir_all(&project_root)?;
10964 let db_path = project_root.join("projectatlas.db");
10965 let mut store = AtlasStore::open_for_project(&db_path, &project_root)?;
10966 let fixture = publish_resolution_fixture(&mut store, "affected-source-corruption")?;
10967 store
10968 .connection
10969 .execute_batch("PRAGMA foreign_keys = OFF")?;
10970 store.connection.execute(
10971 "DELETE FROM graph_resolution_keys
10972 WHERE project_instance_id = ?1
10973 AND resolution_domain = ?2
10974 AND key_digest = ?3",
10975 params![
10976 &fixture.graph.project.as_bytes()[..],
10977 fixture.resolved.domain().as_str(),
10978 &fixture.resolved.digest_bytes()[..],
10979 ],
10980 )?;
10981 let error = require_db_error(
10982 store.repository_affected_source_footprint(
10983 fixture.graph.project,
10984 &["src/Äuth.rs".to_string()],
10985 100,
10986 ),
10987 "missing resolution witness returned a partial footprint",
10988 )?;
10989 require(
10990 matches!(error, DbError::Sqlite(_)),
10991 &format!("missing witness returned the wrong error: {error}"),
10992 )?;
10993 Ok(())
10994 }
10995
10996 #[test]
10997 fn resolution_key_failures_roll_back_and_owner_foreign_keys_cascade()
10998 -> Result<(), Box<dyn Error>> {
10999 let temp = tempfile::tempdir()?;
11000 let project_root = temp.path().join("resolution-failures");
11001 fs::create_dir_all(&project_root)?;
11002 let db_path = project_root.join("projectatlas.db");
11003 let mut store = AtlasStore::open_for_project(&db_path, &project_root)?;
11004 let fixture = publish_resolution_fixture(&mut store, "resolution-failures")?;
11005 let before_publication = store.index_publication()?;
11006 let before_counts = store.connection.query_row(
11007 "SELECT
11008 (SELECT COUNT(*) FROM graph_resolution_keys),
11009 (SELECT COUNT(*) FROM graph_entity_exports),
11010 (SELECT COUNT(*) FROM graph_relation_dependencies)",
11011 [],
11012 |row| {
11013 Ok((
11014 row.get::<_, i64>(0)?,
11015 row.get::<_, i64>(1)?,
11016 row.get::<_, i64>(2)?,
11017 ))
11018 },
11019 )?;
11020
11021 let replacement = graph_fixture(fixture.graph.project, IndexGeneration::new(2))?;
11022 let invalid_export = EntityResolutionKey::new(
11023 replacement.entities[0].key().clone(),
11024 fixture.resolved.clone(),
11025 )?;
11026 {
11027 let mut publication = store.begin_index_publication("resolution-failures")?;
11028 let error = require_db_error(
11029 publication.replace_repository_graph_with_resolution_keys(
11030 replacement.project,
11031 &replacement.entities,
11032 &replacement.relations,
11033 &replacement.occurrences,
11034 &replacement.coverage,
11035 &[invalid_export],
11036 &[],
11037 ),
11038 "source-less export owner unexpectedly published",
11039 )?;
11040 require(
11041 matches!(error, DbError::GraphRowShape { .. }),
11042 &format!("invalid export owner returned the wrong error: {error}"),
11043 )?;
11044 }
11045 require_eq(
11046 &store.index_publication()?,
11047 &before_publication,
11048 "failed key publication generation",
11049 )?;
11050 let after_counts = store.connection.query_row(
11051 "SELECT
11052 (SELECT COUNT(*) FROM graph_resolution_keys),
11053 (SELECT COUNT(*) FROM graph_entity_exports),
11054 (SELECT COUNT(*) FROM graph_relation_dependencies)",
11055 [],
11056 |row| {
11057 Ok((
11058 row.get::<_, i64>(0)?,
11059 row.get::<_, i64>(1)?,
11060 row.get::<_, i64>(2)?,
11061 ))
11062 },
11063 )?;
11064 require_eq(
11065 &after_counts,
11066 &before_counts,
11067 "failed key publication durable rows",
11068 )?;
11069 require_eq(
11070 &store
11071 .repository_resolution_candidates(&fixture.resolved, 10)?
11072 .rows,
11073 &vec![fixture.graph.entities[4].clone()],
11074 "failed key publication previous candidates",
11075 )?;
11076
11077 store.connection.execute(
11078 "UPDATE graph_resolution_keys
11079 SET canonical_identity = 'conflicting collision witness'
11080 WHERE project_instance_id = ?1
11081 AND resolution_domain = ?2
11082 AND key_digest = ?3",
11083 params![
11084 &fixture.graph.project.as_bytes()[..],
11085 fixture.resolved.domain().as_str(),
11086 &fixture.resolved.digest_bytes()[..],
11087 ],
11088 )?;
11089 let collision = require_db_error(
11090 store.repository_resolution_candidates(&fixture.resolved, 10),
11091 "conflicting resolution witness was accepted",
11092 )?;
11093 require(
11094 matches!(collision, DbError::ResolutionKeyCollision { .. }),
11095 &format!("conflicting witness returned the wrong error: {collision}"),
11096 )?;
11097 let corrupt_page = require_db_error(
11098 store.repository_export_keys_for_paths(
11099 fixture.graph.project,
11100 &["src/Äuth.rs".to_string()],
11101 10,
11102 ),
11103 "corrupt witness returned a partial key page",
11104 )?;
11105 require(
11106 matches!(corrupt_page, DbError::GraphContract(_)),
11107 &format!("corrupt witness returned the wrong page error: {corrupt_page}"),
11108 )?;
11109 store.connection.execute(
11110 "UPDATE graph_resolution_keys
11111 SET canonical_identity = ?1
11112 WHERE project_instance_id = ?2
11113 AND resolution_domain = ?3
11114 AND key_digest = ?4",
11115 params![
11116 fixture.resolved.canonical_identity(),
11117 &fixture.graph.project.as_bytes()[..],
11118 fixture.resolved.domain().as_str(),
11119 &fixture.resolved.digest_bytes()[..],
11120 ],
11121 )?;
11122
11123 let relation_digest = fixture.graph.relations[2].key().digest_bytes()?;
11124 store.connection.execute(
11125 "DELETE FROM graph_relations
11126 WHERE project_instance_id = ?1 AND relation_key = ?2",
11127 params![&fixture.graph.project.as_bytes()[..], &relation_digest[..]],
11128 )?;
11129 let remaining_dependencies = store.connection.query_row(
11130 "SELECT COUNT(*) FROM graph_relation_dependencies",
11131 [],
11132 |row| row.get::<_, i64>(0),
11133 )?;
11134 require_eq(
11135 &remaining_dependencies,
11136 &(before_counts.2 - 1),
11137 "relation-owner dependency cascade",
11138 )?;
11139 let entity_digest = fixture.graph.entities[4].key().digest_bytes()?;
11140 store.connection.execute(
11141 "DELETE FROM graph_entities
11142 WHERE project_instance_id = ?1 AND entity_key = ?2",
11143 params![&fixture.graph.project.as_bytes()[..], &entity_digest[..]],
11144 )?;
11145 let remaining_exports =
11146 store
11147 .connection
11148 .query_row("SELECT COUNT(*) FROM graph_entity_exports", [], |row| {
11149 row.get::<_, i64>(0)
11150 })?;
11151 require_eq(&remaining_exports, &0, "entity-owner export cascade")?;
11152 Ok(())
11153 }
11154
11155 #[test]
11156 fn affected_replacement_swaps_resolution_keys_and_collects_only_touched_orphans()
11157 -> Result<(), Box<dyn Error>> {
11158 let temp = tempfile::tempdir()?;
11159 let project_root = temp.path().join("resolution-replacement");
11160 fs::create_dir_all(&project_root)?;
11161 let db_path = project_root.join("projectatlas.db");
11162 let mut store = AtlasStore::open_for_project(&db_path, &project_root)?;
11163 let first = publish_resolution_fixture(&mut store, "resolution-replacement")?;
11164 let replacement = graph_fixture(first.graph.project, IndexGeneration::new(2))?;
11165 let renamed = declaration_key(
11166 first.graph.project,
11167 "verifySession",
11168 GraphRelationKind::Legacy(RelationKind::Calls),
11169 )?;
11170 let exports = vec![EntityResolutionKey::new(
11171 replacement.entities[4].key().clone(),
11172 renamed.clone(),
11173 )?];
11174 let dependencies = vec![
11175 RelationDependencyKey::new(replacement.relations[0].key().clone(), renamed.clone())?,
11176 RelationDependencyKey::new(
11177 replacement.relations[1].key().clone(),
11178 first.ambiguous.clone(),
11179 )?,
11180 RelationDependencyKey::new(
11181 replacement.relations[2].key().clone(),
11182 first.unresolved.clone(),
11183 )?,
11184 ];
11185 let mut publication = store.begin_index_publication("resolution-replacement")?;
11186 publication.replace_repository_graph_for_paths_with_resolution_keys(
11187 replacement.project,
11188 &["src/Äuth.rs".to_string()],
11189 &replacement.entities,
11190 &replacement.relations,
11191 &replacement.occurrences,
11192 &replacement.coverage,
11193 &exports,
11194 &dependencies,
11195 )?;
11196 publication.complete()?;
11197
11198 require_eq(
11199 &store
11200 .repository_resolution_candidates(&first.resolved, 10)?
11201 .rows
11202 .len(),
11203 &0,
11204 "removed export candidates",
11205 )?;
11206 require_eq(
11207 &store
11208 .repository_affected_source_paths(
11209 first.graph.project,
11210 std::slice::from_ref(&first.resolved),
11211 10,
11212 )?
11213 .rows
11214 .len(),
11215 &0,
11216 "removed dependency owners",
11217 )?;
11218 require_eq(
11219 &store.repository_resolution_candidates(&renamed, 10)?.rows,
11220 &vec![replacement.entities[4].clone()],
11221 "renamed export candidates",
11222 )?;
11223 let exports = store.repository_export_keys_for_paths(
11224 first.graph.project,
11225 &["src/Äuth.rs".to_string()],
11226 10,
11227 )?;
11228 require_eq(&exports.rows, &vec![renamed], "replacement export keys")?;
11229 let registry_rows = store.connection.query_row(
11230 "SELECT COUNT(*) FROM graph_resolution_keys",
11231 [],
11232 |row| row.get::<_, i64>(0),
11233 )?;
11234 require_eq(®istry_rows, &3, "touched orphan witness cleanup")?;
11235 require_eq(
11236 &store
11237 .index_publication()?
11238 .ok_or_else(|| io::Error::other("replacement publication is missing"))?
11239 .generation,
11240 &IndexGeneration::new(2),
11241 "replacement generation",
11242 )?;
11243 Ok(())
11244 }
11245
11246 #[test]
11247 fn affected_graph_replacement_preserves_only_the_unaffected_closure()
11248 -> Result<(), Box<dyn Error>> {
11249 let temp = tempfile::tempdir()?;
11250 let project_root = temp.path().join("affected-closure");
11251 let atlas_dir = project_root.join(".projectatlas");
11252 fs::create_dir_all(&atlas_dir)?;
11253 let db_path = atlas_dir.join("projectatlas.db");
11254 let mut store = AtlasStore::open_for_project(&db_path, &project_root)?;
11255
11256 let project = store
11257 .project_instance_id()?
11258 .ok_or_else(|| io::Error::other("bound affected-closure identity is missing"))?;
11259 let generation_one = IndexGeneration::new(1);
11260 let project_entity = GraphEntity::new(project, EntitySelector::Project, generation_one)?;
11261 let affected_folder = GraphEntity::new(
11262 project,
11263 EntitySelector::Folder {
11264 path: RepositoryNodePath::new(Path::new("src/a"))?,
11265 },
11266 generation_one,
11267 )?;
11268 let affected_file = GraphEntity::new(
11269 project,
11270 EntitySelector::File {
11271 path: RepositoryFilePath::new(Path::new("src/a/local.rs"))?,
11272 },
11273 generation_one,
11274 )?;
11275 let case_distinct_file = GraphEntity::new(
11276 project,
11277 EntitySelector::File {
11278 path: RepositoryFilePath::new(Path::new("src/A/keep.rs"))?,
11279 },
11280 generation_one,
11281 )?;
11282 let package = GraphEntity::new(
11283 project,
11284 EntitySelector::Package {
11285 package: PackageSelector {
11286 manager: GraphIdentityText::new("cargo")?,
11287 name: GraphIdentityText::new("api")?,
11288 manifest: RepositoryFilePath::new(Path::new("packages/api/Cargo.toml"))?,
11289 },
11290 },
11291 generation_one,
11292 )?;
11293 let orphan_external = GraphEntity::new(
11294 project,
11295 EntitySelector::External {
11296 external: ExternalSelector {
11297 system: GraphIdentityText::new("crates.io")?,
11298 identity: GraphIdentityText::new("orphan@1")?,
11299 },
11300 },
11301 generation_one,
11302 )?;
11303 let retained_external = GraphEntity::new(
11304 project,
11305 EntitySelector::External {
11306 external: ExternalSelector {
11307 system: GraphIdentityText::new("crates.io")?,
11308 identity: GraphIdentityText::new("retained@1")?,
11309 },
11310 },
11311 generation_one,
11312 )?;
11313 let occurrence_owned_external = GraphEntity::new(
11314 project,
11315 EntitySelector::External {
11316 external: ExternalSelector {
11317 system: GraphIdentityText::new("crates.io")?,
11318 identity: GraphIdentityText::new("occurrence-owned@1")?,
11319 },
11320 },
11321 generation_one,
11322 )?;
11323 let affected_relation = LogicalRelation::new(
11324 &affected_file,
11325 GraphRelationKind::Legacy(RelationKind::DependsOn),
11326 RelationResolution::external(&orphan_external)?,
11327 ConfidenceClass::Exact,
11328 Completeness::Complete,
11329 generation_one,
11330 )?;
11331 let package_relation = LogicalRelation::new(
11332 &package,
11333 GraphRelationKind::Legacy(RelationKind::DependsOn),
11334 RelationResolution::external(&orphan_external)?,
11335 ConfidenceClass::Exact,
11336 Completeness::Complete,
11337 generation_one,
11338 )?;
11339 let retained_relation = LogicalRelation::new(
11340 &case_distinct_file,
11341 GraphRelationKind::Legacy(RelationKind::DependsOn),
11342 RelationResolution::external(&retained_external)?,
11343 ConfidenceClass::Exact,
11344 Completeness::Complete,
11345 generation_one,
11346 )?;
11347 let project_external_relation = LogicalRelation::new(
11348 &project_entity,
11349 GraphRelationKind::Legacy(RelationKind::DependsOn),
11350 RelationResolution::external(&retained_external)?,
11351 ConfidenceClass::Exact,
11352 Completeness::Complete,
11353 generation_one,
11354 )?;
11355 let occurrence_backed_project_relation = LogicalRelation::new(
11356 &project_entity,
11357 GraphRelationKind::Legacy(RelationKind::DependsOn),
11358 RelationResolution::external(&occurrence_owned_external)?,
11359 ConfidenceClass::Exact,
11360 Completeness::Complete,
11361 generation_one,
11362 )?;
11363 let affected_occurrence = RelationOccurrence::new(
11364 &affected_relation,
11365 RepositoryFilePath::new(Path::new("src/a/local.rs"))?,
11366 SourceSpan::new(3, 0, 3, 12)?,
11367 generation_one,
11368 )?;
11369 let retained_occurrence = RelationOccurrence::new(
11370 &retained_relation,
11371 RepositoryFilePath::new(Path::new("src/A/keep.rs"))?,
11372 SourceSpan::new(5, 0, 5, 14)?,
11373 generation_one,
11374 )?;
11375 let retained_relation_affected_occurrence = RelationOccurrence::new(
11376 &retained_relation,
11377 RepositoryFilePath::new(Path::new("src/a/local.rs"))?,
11378 SourceSpan::new(6, 0, 6, 14)?,
11379 generation_one,
11380 )?;
11381 let project_relation_occurrence = RelationOccurrence::new(
11382 &occurrence_backed_project_relation,
11383 RepositoryFilePath::new(Path::new("src/a/local.rs"))?,
11384 SourceSpan::new(9, 0, 9, 16)?,
11385 generation_one,
11386 )?;
11387 let initial_coverage = vec![
11388 CoverageRecord::new(
11389 CoverageScope::Project,
11390 None,
11391 CoverageState::Complete,
11392 4,
11393 0,
11394 generation_one,
11395 None,
11396 None,
11397 )?,
11398 CoverageRecord::new(
11399 CoverageScope::Path {
11400 path: RepositoryNodePath::new(Path::new("src/a"))?,
11401 },
11402 None,
11403 CoverageState::Partial,
11404 1,
11405 1,
11406 generation_one,
11407 Some(GraphIdentityText::new("affected coverage")?),
11408 Some(GraphLimitKind::Rows),
11409 )?,
11410 CoverageRecord::new(
11411 CoverageScope::Path {
11412 path: RepositoryNodePath::new(Path::new("src/A"))?,
11413 },
11414 None,
11415 CoverageState::Complete,
11416 1,
11417 0,
11418 generation_one,
11419 None,
11420 None,
11421 )?,
11422 ];
11423 {
11424 let mut publication = store.begin_index_publication("affected-closure")?;
11425 publication.begin_scan_replacement()?;
11426 publication.upsert_scan_node_batch(&[
11427 graph_node(".", NodeKind::Folder, None),
11428 graph_node("src", NodeKind::Folder, Some(".")),
11429 graph_node("src/a", NodeKind::Folder, Some("src")),
11430 graph_node("src/a/local.rs", NodeKind::File, Some("src/a")),
11431 graph_node("src/a/new.rs", NodeKind::File, Some("src/a")),
11432 graph_node("src/A", NodeKind::Folder, Some("src")),
11433 graph_node("src/A/keep.rs", NodeKind::File, Some("src/A")),
11434 graph_node("packages", NodeKind::Folder, Some(".")),
11435 graph_node("packages/api", NodeKind::Folder, Some("packages")),
11436 graph_node(
11437 "packages/api/Cargo.toml",
11438 NodeKind::File,
11439 Some("packages/api"),
11440 ),
11441 graph_node("README.md", NodeKind::File, Some(".")),
11442 ])?;
11443 publication.finish_scan_replacement()?;
11444 publication.replace_repository_graph(
11445 project,
11446 &[
11447 project_entity.clone(),
11448 affected_folder,
11449 affected_file.clone(),
11450 case_distinct_file.clone(),
11451 package.clone(),
11452 orphan_external.clone(),
11453 retained_external.clone(),
11454 occurrence_owned_external.clone(),
11455 ],
11456 &[
11457 affected_relation,
11458 package_relation,
11459 retained_relation,
11460 project_external_relation,
11461 occurrence_backed_project_relation,
11462 ],
11463 &[
11464 affected_occurrence,
11465 retained_occurrence,
11466 retained_relation_affected_occurrence,
11467 project_relation_occurrence,
11468 ],
11469 &initial_coverage,
11470 )?;
11471 publication.complete()?;
11472 }
11473
11474 let affected_paths = [
11475 RepositoryNodePath::new(Path::new("src/a"))?,
11476 RepositoryNodePath::new(Path::new("packages/api/Cargo.toml"))?,
11477 ];
11478 let (candidates, statements) = trace_statements(&mut store, |store| {
11479 affected_external_candidates(&store.connection, &affected_paths)
11480 })?;
11481 require(
11482 candidates.contains(&orphan_external.key().digest_bytes()?),
11483 "batched external cleanup omitted an affected candidate",
11484 )?;
11485 require_eq(
11486 &statements
11487 .iter()
11488 .filter(|statement| {
11489 statement.contains("WITH affected(entity_key)")
11490 && statement.contains("idx_graph_relations_source_kind")
11491 && statement.contains("idx_graph_relations_target_kind")
11492 })
11493 .count(),
11494 &1,
11495 "bounded external candidate adjacency batches",
11496 )?;
11497
11498 let generation_two = IndexGeneration::new(2);
11499 let replacement_folder = GraphEntity::new(
11500 project,
11501 EntitySelector::Folder {
11502 path: RepositoryNodePath::new(Path::new("src/a"))?,
11503 },
11504 generation_two,
11505 )?;
11506 let replacement_file = GraphEntity::new(
11507 project,
11508 EntitySelector::File {
11509 path: RepositoryFilePath::new(Path::new("src/a/new.rs"))?,
11510 },
11511 generation_two,
11512 )?;
11513 let retained_external_for_relation = GraphEntity::new(
11514 project,
11515 retained_external.selector().clone(),
11516 generation_two,
11517 )?;
11518 let replacement_relation = LogicalRelation::new(
11519 &replacement_file,
11520 GraphRelationKind::Legacy(RelationKind::DependsOn),
11521 RelationResolution::external(&retained_external_for_relation)?,
11522 ConfidenceClass::Exact,
11523 Completeness::Complete,
11524 generation_two,
11525 )?;
11526 let replacement_occurrence = RelationOccurrence::new(
11527 &replacement_relation,
11528 RepositoryFilePath::new(Path::new("src/a/new.rs"))?,
11529 SourceSpan::new(7, 0, 7, 10)?,
11530 generation_two,
11531 )?;
11532 let replacement_coverage = CoverageRecord::new(
11533 CoverageScope::Path {
11534 path: RepositoryNodePath::new(Path::new("src/a"))?,
11535 },
11536 None,
11537 CoverageState::Complete,
11538 1,
11539 0,
11540 generation_two,
11541 None,
11542 None,
11543 )?;
11544 {
11545 let mut publication = store.begin_index_publication("affected-closure")?;
11546 publication.replace_repository_graph_for_paths(
11547 project,
11548 &["src/a".to_string(), "packages/api/Cargo.toml".to_string()],
11549 &[replacement_folder, replacement_file.clone()],
11550 &[replacement_relation],
11551 &[replacement_occurrence],
11552 &[replacement_coverage],
11553 )?;
11554 publication.complete()?;
11555 }
11556
11557 drop(store);
11558 let store = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
11559
11560 require_eq(
11561 &store.repository_graph_entity(affected_file.key())?,
11562 &None,
11563 "affected descendant removal",
11564 )?;
11565 require_eq(
11566 &store.repository_graph_entity(package.key())?,
11567 &None,
11568 "manifest-owned package removal",
11569 )?;
11570 require_eq(
11571 &store.repository_graph_entity(orphan_external.key())?,
11572 &None,
11573 "candidate-bounded orphan external cleanup",
11574 )?;
11575 require_eq(
11576 &store.repository_graph_entity(occurrence_owned_external.key())?,
11577 &None,
11578 "final affected occurrence relation and external cleanup",
11579 )?;
11580 let preserved_case = store
11581 .repository_graph_entity(case_distinct_file.key())?
11582 .ok_or_else(|| io::Error::other("case-distinct sibling was removed"))?;
11583 require_eq(
11584 &preserved_case.generation(),
11585 &generation_two,
11586 "case-distinct sibling generation injection",
11587 )?;
11588 let preserved_external = store
11589 .repository_graph_entity(retained_external.key())?
11590 .ok_or_else(|| io::Error::other("referenced external entity was removed"))?;
11591 require_eq(
11592 &preserved_external.generation(),
11593 &generation_two,
11594 "unaffected external generation injection",
11595 )?;
11596 let retained_relations = store.repository_graph_relations(
11597 RepositoryGraphRelationQuery::Outbound {
11598 source: case_distinct_file.key().clone(),
11599 },
11600 10,
11601 )?;
11602 require_eq(
11603 &retained_relations.rows.len(),
11604 &1,
11605 "relation with one unaffected occurrence",
11606 )?;
11607 require_eq(
11608 &store
11609 .repository_graph_occurrences(&retained_relations.rows[0], 10)?
11610 .rows
11611 .len(),
11612 &1,
11613 "only the affected occurrence was removed",
11614 )?;
11615 let replacement_relations = store.repository_graph_relations(
11616 RepositoryGraphRelationQuery::Outbound {
11617 source: replacement_file.key().clone(),
11618 },
11619 10,
11620 )?;
11621 require_eq(
11622 &replacement_relations.rows.len(),
11623 &1,
11624 "replacement relation count",
11625 )?;
11626 require_eq(
11627 &store
11628 .repository_graph_occurrences(&replacement_relations.rows[0], 10)?
11629 .rows
11630 .len(),
11631 &1,
11632 "replacement source occurrence",
11633 )?;
11634 let affected_coverage = store.repository_graph_coverage(
11635 project,
11636 &CoverageScope::Path {
11637 path: RepositoryNodePath::new(Path::new("src/a"))?,
11638 },
11639 10,
11640 )?;
11641 require(
11642 affected_coverage.rows.len() == 1
11643 && affected_coverage.rows[0].state() == CoverageState::Complete,
11644 "affected path coverage was not replaced",
11645 )?;
11646 let case_coverage = store.repository_graph_coverage(
11647 project,
11648 &CoverageScope::Path {
11649 path: RepositoryNodePath::new(Path::new("src/A"))?,
11650 },
11651 10,
11652 )?;
11653 require_eq(
11654 &case_coverage.rows.len(),
11655 &1,
11656 "case-distinct coverage preservation",
11657 )?;
11658 require_eq(
11659 &store
11660 .repository_graph_coverage(project, &CoverageScope::Project, 10)?
11661 .rows
11662 .len(),
11663 &1,
11664 "unaffected project coverage preservation",
11665 )?;
11666 require_eq(
11667 &store
11668 .repository_graph_relations(
11669 RepositoryGraphRelationQuery::Outbound {
11670 source: project_entity.key().clone(),
11671 },
11672 10,
11673 )?
11674 .rows
11675 .len(),
11676 &1,
11677 "project-to-external relation preservation",
11678 )?;
11679
11680 store.finish_index_read_snapshot()?;
11681 drop(store);
11682 let mut store = AtlasStore::open_for_project(&db_path, &project_root)?;
11683 let generation_three = IndexGeneration::new(3);
11684 let root_project = GraphEntity::new(project, EntitySelector::Project, generation_three)?;
11685 let readme = GraphEntity::new(
11686 project,
11687 EntitySelector::File {
11688 path: RepositoryFilePath::new(Path::new("README.md"))?,
11689 },
11690 generation_three,
11691 )?;
11692 let root_coverage = CoverageRecord::new(
11693 CoverageScope::Project,
11694 None,
11695 CoverageState::Complete,
11696 1,
11697 0,
11698 generation_three,
11699 None,
11700 None,
11701 )?;
11702 {
11703 let mut publication = store.begin_index_publication("affected-closure")?;
11704 publication.replace_repository_graph_for_paths(
11705 project,
11706 &[".".to_string()],
11707 &[root_project.clone(), readme],
11708 &[],
11709 &[],
11710 &[root_coverage],
11711 )?;
11712 publication.complete()?;
11713 }
11714 drop(store);
11715 let store = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
11716 require_eq(
11717 &store.repository_graph_entity(case_distinct_file.key())?,
11718 &None,
11719 "root replacement stale local removal",
11720 )?;
11721 require_eq(
11722 &store.repository_graph_entity(retained_external.key())?,
11723 &None,
11724 "root replacement external removal",
11725 )?;
11726 require_eq(
11727 &store
11728 .repository_graph_relations(
11729 RepositoryGraphRelationQuery::Outbound {
11730 source: root_project.key().clone(),
11731 },
11732 10,
11733 )?
11734 .rows
11735 .len(),
11736 &0,
11737 "root replacement project relation removal",
11738 )?;
11739 require_eq(
11740 &store
11741 .repository_graph_coverage(project, &CoverageScope::Project, 10)?
11742 .rows
11743 .len(),
11744 &1,
11745 "root replacement project coverage",
11746 )?;
11747 require_eq(
11748 &store
11749 .repository_graph_coverage(
11750 project,
11751 &CoverageScope::Path {
11752 path: RepositoryNodePath::new(Path::new("src/A"))?,
11753 },
11754 10,
11755 )?
11756 .rows
11757 .len(),
11758 &0,
11759 "root replacement path coverage removal",
11760 )?;
11761
11762 store.finish_index_read_snapshot()?;
11763 drop(store);
11764 let mut store = AtlasStore::open_for_project(&db_path, &project_root)?;
11765 {
11766 let mut projection = store.begin_index_projection_refresh("affected-closure")?;
11767 projection.replace_file_texts_for_paths(
11768 &["README.md".to_string()],
11769 &[IndexedFileText {
11770 path: "README.md".to_string(),
11771 content_hash: Some("readme-hash".to_string()),
11772 byte_count: 7,
11773 line_count: 1,
11774 content: "# Atlas".to_string(),
11775 }],
11776 )?;
11777 projection.complete()?;
11778 }
11779 drop(store);
11780 let store = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
11781 let error = require_db_error(
11782 store.repository_graph_entity(root_project.key()),
11783 "non-graph publication blessed stale graph rows",
11784 )?;
11785 require(
11786 matches!(
11787 error,
11788 DbError::GraphRowShape {
11789 table: "project_identity",
11790 ..
11791 }
11792 ),
11793 &format!("unexpected stale graph generation error: {error}"),
11794 )?;
11795 store.finish_index_read_snapshot()?;
11796 Ok(())
11797 }
11798
11799 fn require_graph_projection(
11801 store: &AtlasStore,
11802 fixture: &GraphFixture,
11803 generation: IndexGeneration,
11804 lexical_content: &str,
11805 ) -> Result<(), Box<dyn Error>> {
11806 let publication = store
11807 .index_publication()?
11808 .ok_or_else(|| io::Error::other("graph publication metadata missing"))?;
11809 require_eq(
11810 &publication.state,
11811 &IndexPublicationState::Complete,
11812 "graph publication state",
11813 )?;
11814 require_eq(
11815 &publication.generation,
11816 &generation,
11817 "graph publication generation",
11818 )?;
11819 let source = fixture
11820 .entities
11821 .iter()
11822 .find(|entity| matches!(entity.selector(), EntitySelector::File { .. }))
11823 .ok_or_else(|| io::Error::other("source file fixture missing"))?;
11824 let entity = store
11825 .repository_graph_entity(source.key())?
11826 .ok_or_else(|| io::Error::other("source graph entity missing"))?;
11827 require_eq(&entity.generation(), &generation, "graph entity generation")?;
11828 let relations = store.repository_graph_relations(
11829 RepositoryGraphRelationQuery::Outbound {
11830 source: source.key().clone(),
11831 },
11832 10,
11833 )?;
11834 require_eq(&relations.rows.len(), &4, "graph relation count")?;
11835 require(
11836 relations
11837 .rows
11838 .iter()
11839 .all(|relation| relation.generation() == generation),
11840 "graph relation generation mismatch",
11841 )?;
11842 let calls = relations
11843 .rows
11844 .iter()
11845 .find(|relation| relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls))
11846 .ok_or_else(|| io::Error::other("call relation missing"))?;
11847 let occurrences = store.repository_graph_occurrences(calls, 10)?;
11848 require_eq(&occurrences.rows.len(), &2, "graph occurrence count")?;
11849 require(
11850 occurrences
11851 .rows
11852 .iter()
11853 .all(|occurrence| occurrence.generation() == generation),
11854 "graph occurrence generation mismatch",
11855 )?;
11856 let occurrence_pages = store.repository_graph_occurrence_pages(&relations.rows, 1, None)?;
11857 require_eq(
11858 &occurrence_pages.len(),
11859 &relations.rows.len(),
11860 "batched occurrence page count",
11861 )?;
11862 for (relation, page) in relations.rows.iter().zip(&occurrence_pages) {
11863 let is_calls = relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls);
11864 require_eq(
11865 &page.rows.len(),
11866 &usize::from(is_calls),
11867 "batched occurrence rows",
11868 )?;
11869 require_eq(&page.truncated, &is_calls, "batched occurrence truncation")?;
11870 }
11871 let coverage =
11872 store.repository_graph_coverage(fixture.project, &CoverageScope::Project, 10)?;
11873 let expected_coverage = fixture
11874 .coverage
11875 .iter()
11876 .filter(|record| matches!(record.scope(), CoverageScope::Project))
11877 .count();
11878 require_eq(
11879 &coverage.rows.len(),
11880 &expected_coverage,
11881 "graph coverage count",
11882 )?;
11883 require(
11884 coverage
11885 .rows
11886 .iter()
11887 .all(|record| record.generation() == generation),
11888 "graph coverage generation mismatch",
11889 )?;
11890 let lexical = store
11891 .load_file_text("src/Äuth.rs")?
11892 .ok_or_else(|| io::Error::other("lexical source row missing"))?;
11893 require_eq(
11894 &lexical.content.as_str(),
11895 &lexical_content,
11896 "lexical source generation",
11897 )?;
11898 require_eq(
11899 &store.symbol_relation_count()?,
11900 &1,
11901 "legacy symbol relation compatibility",
11902 )?;
11903 Ok(())
11904 }
11905
11906 #[test]
11907 fn document_relations_require_closed_reasons_and_reuse_indexed_inbound_reads()
11908 -> Result<(), Box<dyn Error>> {
11909 let temp = tempfile::tempdir()?;
11910 let root = temp.path().join("document-graph");
11911 fs::create_dir(&root)?;
11912 let database = temp.path().join("projectatlas.db");
11913 let mut store = AtlasStore::open_for_project(&database, &root)?;
11914 let project = store
11915 .project_instance_id()?
11916 .ok_or_else(|| io::Error::other("document fixture identity is missing"))?;
11917 let generation = IndexGeneration::new(1);
11918 let document_path = RepositoryFilePath::new(Path::new("docs/guide.md"))?;
11919 let document = GraphEntity::new(
11920 project,
11921 EntitySelector::File {
11922 path: document_path.clone(),
11923 },
11924 generation,
11925 )?;
11926 let heading = GraphEntity::new(
11927 project,
11928 EntitySelector::Symbol {
11929 symbol: SymbolSelector {
11930 file: document_path,
11931 name: GraphIdentityText::new("Installation")?,
11932 kind: SymbolKind::Heading,
11933 parent: None,
11934 signature: GraphIdentityText::new("# Installation")?,
11935 },
11936 },
11937 generation,
11938 )?;
11939 let unresolved_document = LogicalRelation::new(
11940 &document,
11941 GraphRelationKind::Extended(ExtendedRelationKind::Documents),
11942 RelationResolution::Unresolved {
11943 reference: GraphIdentityText::new("missing.md#setup")?,
11944 },
11945 ConfidenceClass::Exact,
11946 Completeness::Complete,
11947 generation,
11948 )?;
11949 let resolved_document = LogicalRelation::new(
11950 &document,
11951 GraphRelationKind::Extended(ExtendedRelationKind::Documents),
11952 RelationResolution::resolved(&heading)?,
11953 ConfidenceClass::Exact,
11954 Completeness::Complete,
11955 generation,
11956 )?;
11957 let unrelated = LogicalRelation::new(
11958 &document,
11959 GraphRelationKind::Extended(ExtendedRelationKind::Configures),
11960 RelationResolution::Unresolved {
11961 reference: GraphIdentityText::new("setting")?,
11962 },
11963 ConfidenceClass::Low,
11964 Completeness::Partial,
11965 generation,
11966 )?;
11967
11968 let mut publication = store.begin_index_publication("document-reasons")?;
11969 publication.begin_scan_replacement()?;
11970 publication.upsert_scan_node_batch(&[
11971 graph_node(".", NodeKind::Folder, None),
11972 graph_node("docs", NodeKind::Folder, Some(".")),
11973 graph_node("docs/guide.md", NodeKind::File, Some("docs")),
11974 ])?;
11975 publication.finish_scan_replacement()?;
11976 publication.replace_repository_graph(
11977 project,
11978 &[document, heading.clone()],
11979 &[
11980 unresolved_document.clone(),
11981 resolved_document.clone(),
11982 unrelated.clone(),
11983 ],
11984 &[],
11985 &[],
11986 )?;
11987 let incomplete = require_db_error(
11988 validate_complete_document_unresolved_reasons(&publication.connection),
11989 "unresolved document relation omitted its reason",
11990 )?;
11991 require(
11992 matches!(incomplete, DbError::GraphRowShape { .. }),
11993 "missing document reason returned the wrong error",
11994 )?;
11995 let mixed = require_db_error(
11996 publication.set_document_unresolved_reasons(&[
11997 (
11998 unresolved_document.key().clone(),
11999 DocumentTargetUnresolvedReason::Missing,
12000 ),
12001 (
12002 unrelated.key().clone(),
12003 DocumentTargetUnresolvedReason::Unsupported,
12004 ),
12005 ]),
12006 "non-document relation accepted a document reason",
12007 )?;
12008 require(
12009 matches!(mixed, DbError::GraphRowShape { .. }),
12010 "mixed document-reason batch returned the wrong error",
12011 )?;
12012 let unresolved_key = unresolved_document.key().digest_bytes()?;
12013 let retained_reason = publication.connection.query_row(
12014 "SELECT document_unresolved_reason FROM graph_relations WHERE relation_key = ?1",
12015 [&unresolved_key[..]],
12016 |row| row.get::<_, Option<String>>(0),
12017 )?;
12018 require(
12019 retained_reason.is_none(),
12020 "failed document-reason batch exposed partial mutation",
12021 )?;
12022 publication.set_document_unresolved_reasons(&[(
12023 unresolved_document.key().clone(),
12024 DocumentTargetUnresolvedReason::Missing,
12025 )])?;
12026 publication.complete()?;
12027
12028 let family = store.repository_graph_relation_rows(
12029 RepositoryGraphRelationQuery::Family {
12030 relation: GraphRelationKind::Extended(ExtendedRelationKind::Documents),
12031 },
12032 10,
12033 None,
12034 )?;
12035 require_eq(&family.rows.len(), &2, "document relation family")?;
12036 require(
12037 family.rows.iter().any(|row| {
12038 matches!(
12039 row.relation.resolution(),
12040 RelationResolution::Unresolved { .. }
12041 ) && row.document_unresolved_reason == Some(DocumentTargetUnresolvedReason::Missing)
12042 }) && family.rows.iter().any(|row| {
12043 matches!(
12044 row.relation.resolution(),
12045 RelationResolution::Resolved { .. }
12046 ) && row.document_unresolved_reason.is_none()
12047 }),
12048 "document reason or resolved relation did not round-trip",
12049 )?;
12050 let inbound = store.repository_graph_relation_rows(
12051 RepositoryGraphRelationQuery::Inbound {
12052 target: heading.key().clone(),
12053 },
12054 10,
12055 None,
12056 )?;
12057 require(
12058 inbound.rows.len() == 1
12059 && inbound.rows[0].relation.kind()
12060 == GraphRelationKind::Extended(ExtendedRelationKind::Documents),
12061 "inbound heading read did not reuse document relation storage",
12062 )?;
12063 let frontier = [family.rows[0].source.key().clone()];
12064 let legacy = store.repository_graph_adjacency_page_filtered_bounded(
12065 &frontier,
12066 RepositoryGraphDirection::Outbound,
12067 None,
12068 None,
12069 10,
12070 maximum_repository_graph_read_budget()?,
12071 None,
12072 )?;
12073 require(
12074 legacy.page.rows.len() == 1
12075 && legacy.page.rows[0].detail.relation.kind()
12076 == GraphRelationKind::Extended(ExtendedRelationKind::Configures),
12077 "legacy unfiltered adjacency admitted documents before its limit",
12078 )?;
12079 let explicit = store.repository_graph_adjacency_page_filtered_bounded_with_documents(
12080 &frontier,
12081 RepositoryGraphDirection::Outbound,
12082 None,
12083 true,
12084 None,
12085 10,
12086 maximum_repository_graph_read_budget()?,
12087 None,
12088 )?;
12089 require_eq(&explicit.page.rows.len(), &3, "explicit document adjacency")?;
12090 let exact = store.repository_graph_adjacency_page_filtered_bounded(
12091 &frontier,
12092 RepositoryGraphDirection::Outbound,
12093 Some(GraphRelationKind::Extended(ExtendedRelationKind::Documents)),
12094 None,
12095 10,
12096 maximum_repository_graph_read_budget()?,
12097 None,
12098 )?;
12099 require_eq(&exact.page.rows.len(), &2, "exact document adjacency")?;
12100 let first = store.repository_graph_adjacency_page_filtered_bounded_with_documents(
12101 &frontier,
12102 RepositoryGraphDirection::Outbound,
12103 None,
12104 true,
12105 None,
12106 1,
12107 maximum_repository_graph_read_budget()?,
12108 None,
12109 )?;
12110 let cursor = first
12111 .page
12112 .continuation
12113 .as_ref()
12114 .ok_or_else(|| io::Error::other("document visibility cursor is missing"))?;
12115 let mismatch = require_db_error(
12116 store.repository_graph_adjacency_page_filtered_bounded_with_documents(
12117 &frontier,
12118 RepositoryGraphDirection::Outbound,
12119 None,
12120 false,
12121 Some(cursor),
12122 1,
12123 maximum_repository_graph_read_budget()?,
12124 None,
12125 ),
12126 "document visibility cursor was reused under legacy filtering",
12127 )?;
12128 require(
12129 matches!(mismatch, DbError::GraphContract(_)),
12130 "document visibility cursor mismatch returned the wrong error",
12131 )?;
12132 assert_query_indexes(&store)?;
12133
12134 let resolved_key = resolved_document.key().digest_bytes()?;
12135 let contradictory = store.connection.execute(
12136 "UPDATE graph_relations
12137 SET document_unresolved_reason = 'missing'
12138 WHERE relation_key = ?1",
12139 [&resolved_key[..]],
12140 );
12141 require(
12142 contradictory.is_err(),
12143 "resolved document relation accepted an unresolved reason",
12144 )?;
12145 let invalid = store.connection.execute(
12146 "UPDATE graph_relations
12147 SET document_unresolved_reason = 'network_error'
12148 WHERE relation_key = ?1",
12149 [&unresolved_key[..]],
12150 );
12151 require(invalid.is_err(), "open document reason value was accepted")?;
12152 drop(store);
12153
12154 let reopened = AtlasStore::open_read_only_for_project(&database, &root)?;
12155 let reopened_family = reopened.repository_graph_relation_rows(
12156 RepositoryGraphRelationQuery::Family {
12157 relation: GraphRelationKind::Extended(ExtendedRelationKind::Documents),
12158 },
12159 10,
12160 None,
12161 )?;
12162 require_eq(
12163 &reopened_family.rows.len(),
12164 &2,
12165 "reopened document relations",
12166 )?;
12167 Ok(())
12168 }
12169
12170 #[test]
12171 fn document_unresolved_reasons_publish_across_multiple_graph_row_chunks()
12172 -> Result<(), Box<dyn Error>> {
12173 let temp = tempfile::tempdir()?;
12174 let root = temp.path().join("document-reason-chunks");
12175 fs::create_dir(&root)?;
12176 let database = temp.path().join("projectatlas.db");
12177 let mut store = AtlasStore::open_for_project(&database, &root)?;
12178 let project = store
12179 .project_instance_id()?
12180 .ok_or_else(|| io::Error::other("document chunk fixture identity is missing"))?;
12181 let baseline_generation = IndexGeneration::new(1);
12182 let generation = IndexGeneration::new(2);
12183 let baseline_document = GraphEntity::new(
12184 project,
12185 EntitySelector::File {
12186 path: RepositoryFilePath::new(Path::new("docs/baseline.md"))?,
12187 },
12188 baseline_generation,
12189 )?;
12190 let baseline_relation = LogicalRelation::new(
12191 &baseline_document,
12192 GraphRelationKind::Extended(ExtendedRelationKind::Documents),
12193 RelationResolution::Unresolved {
12194 reference: GraphIdentityText::new("baseline-missing.md")?,
12195 },
12196 ConfidenceClass::Exact,
12197 Completeness::Complete,
12198 baseline_generation,
12199 )?;
12200 let mut baseline = store.begin_index_publication("document-reason-chunks")?;
12201 baseline.upsert_scan_node_batch(&[graph_node(
12202 "docs/baseline.md",
12203 NodeKind::File,
12204 Some("docs"),
12205 )])?;
12206 baseline.replace_repository_graph(
12207 project,
12208 std::slice::from_ref(&baseline_document),
12209 std::slice::from_ref(&baseline_relation),
12210 &[],
12211 &[],
12212 )?;
12213 baseline.set_document_unresolved_reasons(&[(
12214 baseline_relation.key().clone(),
12215 DocumentTargetUnresolvedReason::Missing,
12216 )])?;
12217 baseline.complete()?;
12218 require_eq(
12219 &store.repository_graph_generation()?,
12220 &Some(baseline_generation),
12221 "baseline generation was not complete",
12222 )?;
12223 let document_path = RepositoryFilePath::new(Path::new("docs/index.md"))?;
12224 let document = GraphEntity::new(
12225 project,
12226 EntitySelector::File {
12227 path: document_path,
12228 },
12229 generation,
12230 )?;
12231 let total = GraphLimits::MAX_ROWS as usize * 2 + 1;
12232 let mut relations = Vec::with_capacity(total);
12233 for index in 0..total {
12234 relations.push(LogicalRelation::new(
12235 &document,
12236 GraphRelationKind::Extended(ExtendedRelationKind::Documents),
12237 RelationResolution::Unresolved {
12238 reference: GraphIdentityText::new(format!("missing-{index:05}.md"))?,
12239 },
12240 ConfidenceClass::Exact,
12241 Completeness::Complete,
12242 generation,
12243 )?);
12244 }
12245 let reasons = relations
12246 .iter()
12247 .map(|relation| {
12248 (
12249 relation.key().clone(),
12250 DocumentTargetUnresolvedReason::Missing,
12251 )
12252 })
12253 .collect::<Vec<_>>();
12254 let mut duplicate_reasons = reasons.clone();
12255 duplicate_reasons.push(reasons[0].clone());
12256 let mut contradictory_reasons = reasons.clone();
12257 contradictory_reasons.push((
12258 reasons[0].0.clone(),
12259 DocumentTargetUnresolvedReason::Unsupported,
12260 ));
12261
12262 let populate = |publication: &mut IndexPublicationGuard<'_>| -> DbResult<()> {
12263 publication.upsert_scan_node_batch(&[graph_node(
12264 "docs/index.md",
12265 NodeKind::File,
12266 Some("docs"),
12267 )])?;
12268 publication.replace_repository_graph(
12269 project,
12270 std::slice::from_ref(&document),
12271 &relations,
12272 &[],
12273 &[],
12274 )
12275 };
12276
12277 let mut boundaries = store.begin_index_publication("document-reason-chunks")?;
12278 populate(&mut boundaries)?;
12279 let duplicate = boundaries.set_document_unresolved_reasons(&duplicate_reasons);
12280 require(
12281 matches!(duplicate, Err(DbError::GraphContract(_))),
12282 "duplicate document reason crossing chunks was accepted",
12283 )?;
12284 let untouched = boundaries.connection.query_row(
12285 "SELECT COUNT(*) FROM graph_relations
12286 WHERE document_unresolved_reason IS NOT NULL",
12287 [],
12288 |row| row.get::<_, i64>(0),
12289 )?;
12290 require_eq(&untouched, &0, "duplicate batch exposed a partial chunk")?;
12291 let contradictory = boundaries.set_document_unresolved_reasons(&contradictory_reasons);
12292 require(
12293 matches!(contradictory, Err(DbError::GraphContract(_))),
12294 "contradictory document reason crossing chunks was accepted",
12295 )?;
12296 let contradiction_untouched = boundaries.connection.query_row(
12297 "SELECT COUNT(*) FROM graph_relations
12298 WHERE document_unresolved_reason IS NOT NULL",
12299 [],
12300 |row| row.get::<_, i64>(0),
12301 )?;
12302 require_eq(
12303 &contradiction_untouched,
12304 &0,
12305 "contradictory batch exposed a partial chunk",
12306 )?;
12307 for count in [
12308 GraphLimits::MAX_ROWS as usize - 1,
12309 GraphLimits::MAX_ROWS as usize,
12310 GraphLimits::MAX_ROWS as usize + 1,
12311 ] {
12312 boundaries.set_document_unresolved_reasons(&reasons[..count])?;
12313 let changed = boundaries.connection.query_row(
12314 "SELECT COUNT(*) FROM graph_relations
12315 WHERE document_unresolved_reason IS NOT NULL",
12316 [],
12317 |row| row.get::<_, i64>(0),
12318 )?;
12319 require_eq(
12320 &changed,
12321 &i64::try_from(count)?,
12322 "document reason boundary count",
12323 )?;
12324 }
12325 drop(boundaries);
12326 require_eq(
12327 &store.repository_graph_generation()?,
12328 &Some(baseline_generation),
12329 "boundary publication changed the current generation",
12330 )?;
12331
12332 let mut preflight = store.begin_index_publication("document-reason-chunks")?;
12333 populate(&mut preflight)?;
12334 let preflight_control = IndexWorkControl::with_deadline(
12335 IndexCancellation::new(),
12336 Instant::now() + Duration::from_millis(10),
12337 );
12338 let preflight_error =
12339 preflight.set_document_unresolved_reasons_controlled(&reasons, &preflight_control);
12340 require(
12341 matches!(
12342 preflight_error,
12343 Err(DbError::IndexWork(
12344 projectatlas_core::IndexWorkFailure::DeadlineExceeded {
12345 stage: IndexWorkStage::Publication
12346 }
12347 ))
12348 ),
12349 "preflight deadline was not observed during document-reason validation",
12350 )?;
12351 let preflight_untouched = preflight.connection.query_row(
12352 "SELECT COUNT(*) FROM graph_relations
12353 WHERE document_unresolved_reason IS NOT NULL",
12354 [],
12355 |row| row.get::<_, i64>(0),
12356 )?;
12357 require_eq(
12358 &preflight_untouched,
12359 &0,
12360 "preflight deadline exposed a partial document-reason update",
12361 )?;
12362 drop(preflight);
12363 require_eq(
12364 &store.repository_graph_generation()?,
12365 &Some(baseline_generation),
12366 "preflight deadline changed the current generation",
12367 )?;
12368
12369 let mut preflight_retry = store.begin_index_publication("document-reason-chunks")?;
12370 populate(&mut preflight_retry)?;
12371 preflight_retry.set_document_unresolved_reasons(&reasons)?;
12372 let retried_count = preflight_retry.connection.query_row(
12373 "SELECT COUNT(*) FROM graph_relations
12374 WHERE document_unresolved_reason IS NOT NULL",
12375 [],
12376 |row| row.get::<_, i64>(0),
12377 )?;
12378 require_eq(
12379 &retried_count,
12380 &i64::try_from(total)?,
12381 "successful retry did not publish every document reason",
12382 )?;
12383 drop(preflight_retry);
12384 require_eq(
12385 &store.repository_graph_generation()?,
12386 &Some(baseline_generation),
12387 "successful preflight retry changed the current generation before completion",
12388 )?;
12389
12390 let mut canceled_publication = store.begin_index_publication("document-reason-chunks")?;
12391 populate(&mut canceled_publication)?;
12392 canceled_publication
12393 .set_document_unresolved_reasons(&reasons[..GraphLimits::MAX_ROWS as usize])?;
12394 let cancellation = projectatlas_core::IndexCancellation::new();
12395 cancellation.cancel();
12396 let canceled_control = IndexWorkControl::new(cancellation, None);
12397 let canceled = canceled_publication.set_document_unresolved_reasons_controlled(
12398 &reasons[GraphLimits::MAX_ROWS as usize..],
12399 &canceled_control,
12400 );
12401 require(
12402 matches!(
12403 canceled,
12404 Err(DbError::IndexWork(
12405 projectatlas_core::IndexWorkFailure::Cancelled {
12406 stage: IndexWorkStage::Publication
12407 }
12408 ))
12409 ),
12410 "post-first-chunk cancellation was accepted",
12411 )?;
12412 let canceled_first_chunk = canceled_publication.connection.query_row(
12413 "SELECT COUNT(*) FROM graph_relations
12414 WHERE document_unresolved_reason IS NOT NULL",
12415 [],
12416 |row| row.get::<_, i64>(0),
12417 )?;
12418 require_eq(
12419 &canceled_first_chunk,
12420 &i64::from(GraphLimits::MAX_ROWS),
12421 "cancellation did not leave the first chunk observable inside the parent transaction",
12422 )?;
12423 drop(canceled_publication);
12424 require_eq(
12425 &store.repository_graph_generation()?,
12426 &Some(baseline_generation),
12427 "canceled publication changed the current generation",
12428 )?;
12429
12430 let mut in_chunk_canceled = store.begin_index_publication("document-reason-chunks")?;
12431 populate(&mut in_chunk_canceled)?;
12432 let cancellation = IndexCancellation::new();
12433 let control = IndexWorkControl::new(cancellation.clone(), None);
12434 let attempted_updates = Arc::new(AtomicUsize::new(0));
12435 let attempted_updates_hook = Arc::clone(&attempted_updates);
12436 in_chunk_canceled.connection.update_hook(Some(
12437 move |action: rusqlite::hooks::Action, _database: &str, table: &str, _rowid: i64| {
12438 if action == rusqlite::hooks::Action::SQLITE_UPDATE
12439 && table == "graph_relations"
12440 && attempted_updates_hook.fetch_add(1, Ordering::Relaxed) == 0
12441 {
12442 cancellation.cancel();
12443 }
12444 },
12445 ))?;
12446 let in_chunk = in_chunk_canceled.set_document_unresolved_reasons_controlled(
12447 &reasons[..GraphLimits::MAX_ROWS as usize],
12448 &control,
12449 );
12450 in_chunk_canceled
12451 .connection
12452 .update_hook(None::<fn(rusqlite::hooks::Action, &str, &str, i64)>)?;
12453 require(
12454 matches!(
12455 in_chunk,
12456 Err(DbError::IndexWork(
12457 projectatlas_core::IndexWorkFailure::Cancelled {
12458 stage: IndexWorkStage::Publication
12459 }
12460 ))
12461 ),
12462 "in-chunk cancellation was not typed",
12463 )?;
12464 require(
12465 attempted_updates.load(Ordering::Relaxed) >= 1,
12466 "in-chunk cancellation did not follow an attempted update",
12467 )?;
12468 let in_chunk_untouched = in_chunk_canceled.connection.query_row(
12469 "SELECT COUNT(*) FROM graph_relations
12470 WHERE document_unresolved_reason IS NOT NULL",
12471 [],
12472 |row| row.get::<_, i64>(0),
12473 )?;
12474 require_eq(
12475 &in_chunk_untouched,
12476 &0,
12477 "in-chunk cancellation exposed a partial document-reason update",
12478 )?;
12479 drop(in_chunk_canceled);
12480 require_eq(
12481 &store.repository_graph_generation()?,
12482 &Some(baseline_generation),
12483 "in-chunk cancellation changed the current generation",
12484 )?;
12485
12486 let mut in_chunk_retry = store.begin_index_publication("document-reason-chunks")?;
12487 populate(&mut in_chunk_retry)?;
12488 in_chunk_retry
12489 .set_document_unresolved_reasons(&reasons[..GraphLimits::MAX_ROWS as usize])?;
12490 let in_chunk_retry_count = in_chunk_retry.connection.query_row(
12491 "SELECT COUNT(*) FROM graph_relations
12492 WHERE document_unresolved_reason IS NOT NULL",
12493 [],
12494 |row| row.get::<_, i64>(0),
12495 )?;
12496 require_eq(
12497 &in_chunk_retry_count,
12498 &i64::from(GraphLimits::MAX_ROWS),
12499 "writer reuse after in-chunk cancellation did not retry cleanly",
12500 )?;
12501 drop(in_chunk_retry);
12502 require_eq(
12503 &store.repository_graph_generation()?,
12504 &Some(baseline_generation),
12505 "in-chunk retry changed the current generation before completion",
12506 )?;
12507
12508 let mut publication = store.begin_index_publication("document-reason-chunks")?;
12509 populate(&mut publication)?;
12510 publication.connection.execute_batch(
12511 "CREATE TRIGGER fail_document_reason_chunk
12512 BEFORE UPDATE OF document_unresolved_reason ON graph_relations
12513 WHEN NEW.reference_text = 'missing-10000.md'
12514 BEGIN
12515 SELECT RAISE(ABORT, 'injected document reason failure');
12516 END;",
12517 )?;
12518 let fault = publication.set_document_unresolved_reasons(&reasons);
12519 require(
12520 matches!(fault, Err(DbError::Sqlite(_))),
12521 "fault between document-reason chunks was accepted",
12522 )?;
12523 let rolled_back = publication.connection.query_row(
12524 "SELECT COUNT(*) FROM graph_relations
12525 WHERE document_unresolved_reason IS NOT NULL",
12526 [],
12527 |row| row.get::<_, i64>(0),
12528 )?;
12529 require_eq(
12530 &rolled_back,
12531 &0,
12532 "fault between chunks exposed an earlier chunk",
12533 )?;
12534 drop(publication);
12535 require(
12536 store.repository_graph_generation()? == Some(baseline_generation),
12537 "fault between chunks replaced the current generation",
12538 )?;
12539
12540 let mut publication = store.begin_index_publication("document-reason-chunks")?;
12541 populate(&mut publication)?;
12542 let changes_before = publication.connection.total_changes();
12543 publication.set_document_unresolved_reasons(&reasons)?;
12544 let changes_after = publication.connection.total_changes();
12545 require_eq(
12546 &changes_after.saturating_sub(changes_before),
12547 &u64::try_from(total)?,
12548 "bounded reason update changed rows",
12549 )?;
12550 publication.complete()?;
12551 let (relation_count, reason_count): (i64, i64) = store.connection.query_row(
12552 "SELECT COUNT(*), COUNT(document_unresolved_reason)
12553 FROM graph_relations
12554 WHERE relation_scope = 'extended' AND relation_kind = 'documents'",
12555 [],
12556 |row| Ok((row.get(0)?, row.get(1)?)),
12557 )?;
12558 require_eq(
12559 &relation_count,
12560 &i64::try_from(total)?,
12561 "complete document relation count",
12562 )?;
12563 require_eq(
12564 &reason_count,
12565 &i64::try_from(total)?,
12566 "complete document reason count",
12567 )?;
12568 assert_query_indexes(&store)?;
12569 Ok(())
12570 }
12571
12572 #[test]
12573 fn high_fanout_document_refresh_has_bounded_sql_and_changed_rows() -> Result<(), Box<dyn Error>>
12574 {
12575 const FANOUT: usize = 256;
12576 const EXPECTED_STATEMENTS: usize = 2_083;
12577 const EXPECTED_CHANGED_ROWS: u64 = 1_031;
12578
12579 let temp = tempfile::tempdir()?;
12580 let root = temp.path().join("high-fanout-document");
12581 fs::create_dir(&root)?;
12582 let database = temp.path().join("projectatlas.db");
12583 let mut store = AtlasStore::open_for_project(&database, &root)?;
12584 let project = store
12585 .project_instance_id()?
12586 .ok_or_else(|| io::Error::other("high-fanout fixture identity is missing"))?;
12587 let document_path = RepositoryFilePath::new(Path::new("docs/high-fanout.md"))?;
12588
12589 let graph = |generation: IndexGeneration| -> DbResult<_> {
12590 let document = GraphEntity::new(
12591 project,
12592 EntitySelector::File {
12593 path: document_path.clone(),
12594 },
12595 generation,
12596 )?;
12597 let mut entities = vec![document.clone()];
12598 let mut relations = Vec::with_capacity(FANOUT);
12599 let mut occurrences = Vec::with_capacity(FANOUT);
12600 for (index, line) in (2_u32..).take(FANOUT).enumerate() {
12601 let target = GraphEntity::new(
12602 project,
12603 EntitySelector::File {
12604 path: RepositoryFilePath::new(Path::new(&format!(
12605 "src/target_{index:04}.rs"
12606 )))?,
12607 },
12608 generation,
12609 )?;
12610 let relation = LogicalRelation::new(
12611 &document,
12612 GraphRelationKind::Extended(ExtendedRelationKind::Documents),
12613 RelationResolution::resolved(&target)?,
12614 ConfidenceClass::Exact,
12615 Completeness::Complete,
12616 generation,
12617 )?;
12618 occurrences.push(RelationOccurrence::new(
12619 &relation,
12620 document_path.clone(),
12621 SourceSpan::new(line, 0, line, 20)?,
12622 generation,
12623 )?);
12624 entities.push(target);
12625 relations.push(relation);
12626 }
12627 Ok((entities, relations, occurrences))
12628 };
12629
12630 let mut nodes = vec![
12631 graph_node(".", NodeKind::Folder, None),
12632 graph_node("docs", NodeKind::Folder, Some(".")),
12633 graph_node("docs/high-fanout.md", NodeKind::File, Some("docs")),
12634 graph_node("src", NodeKind::Folder, Some(".")),
12635 ];
12636 nodes.extend((0..FANOUT).map(|index| {
12637 graph_node(
12638 &format!("src/target_{index:04}.rs"),
12639 NodeKind::File,
12640 Some("src"),
12641 )
12642 }));
12643 let (entities, relations, occurrences) = graph(IndexGeneration::new(1))?;
12644 {
12645 let mut publication = store.begin_index_publication("high-fanout-document")?;
12646 publication.begin_scan_replacement()?;
12647 for batch in nodes.chunks(128) {
12648 publication.upsert_scan_node_batch(batch)?;
12649 }
12650 publication.finish_scan_replacement()?;
12651 publication.replace_repository_graph(
12652 project,
12653 &entities,
12654 &relations,
12655 &occurrences,
12656 &[],
12657 )?;
12658 publication.complete()?;
12659 }
12660
12661 let (entities, relations, occurrences) = graph(IndexGeneration::new(2))?;
12662 TRACED_STATEMENTS.with(|statements| statements.borrow_mut().clear());
12663 store.connection.trace_v2(
12664 rusqlite::trace::TraceEventCodes::SQLITE_TRACE_STMT,
12665 Some(record_traced_event),
12666 );
12667 let changed_before = store.connection.total_changes();
12668 let refresh = (|| -> DbResult<()> {
12669 let mut publication = store.begin_index_publication("high-fanout-document")?;
12670 publication.replace_repository_graph_for_paths(
12671 project,
12672 &["docs/high-fanout.md".to_string()],
12673 &entities,
12674 &relations,
12675 &occurrences,
12676 &[],
12677 )?;
12678 publication.complete()
12679 })();
12680 store
12681 .connection
12682 .trace_v2(rusqlite::trace::TraceEventCodes::empty(), None);
12683 refresh?;
12684 let changed_rows = store.connection.total_changes() - changed_before;
12685 let statements =
12686 TRACED_STATEMENTS.with(|statements| std::mem::take(&mut *statements.borrow_mut()));
12687
12688 require_eq(
12689 &statements.len(),
12690 &EXPECTED_STATEMENTS,
12691 "high-fanout refresh statement count",
12692 )?;
12693 require_eq(
12694 &changed_rows,
12695 &EXPECTED_CHANGED_ROWS,
12696 "high-fanout refresh changed rows",
12697 )?;
12698 let relation_count = usize::try_from(store.connection.query_row(
12699 "SELECT COUNT(*) FROM graph_relations
12700 WHERE relation_scope = 'extended' AND relation_kind = 'documents'",
12701 [],
12702 |row| row.get::<_, i64>(0),
12703 )?)?;
12704 require_eq(&relation_count, &FANOUT, "retained document relation count")?;
12705 Ok(())
12706 }
12707
12708 #[test]
12709 fn every_graph_limit_kind_round_trips_and_unknown_spelling_rolls_back()
12710 -> Result<(), Box<dyn Error>> {
12711 let temp = tempfile::tempdir()?;
12712 let root = temp.path().join("graph-limit-kinds");
12713 fs::create_dir_all(&root)?;
12714 let database = root.join("projectatlas.db");
12715 let mut store = AtlasStore::open_for_project(&database, &root)?;
12716 let project = store
12717 .project_instance_id()?
12718 .ok_or_else(|| io::Error::other("graph-limit fixture identity is missing"))?;
12719 let generation = IndexGeneration::new(1);
12720 let mut coverage = Vec::with_capacity(GraphLimitKind::ALL.len());
12721 for kind in GraphLimitKind::ALL {
12722 coverage.push(CoverageRecord::new(
12723 CoverageScope::Path {
12724 path: RepositoryNodePath::new(Path::new(&format!(
12725 "limits/{}.rs",
12726 kind.as_str()
12727 )))?,
12728 },
12729 None,
12730 CoverageState::Partial,
12731 1,
12732 1,
12733 generation,
12734 Some(GraphIdentityText::new("graph limit reached")?),
12735 Some(kind),
12736 )?);
12737 }
12738 let mut publication = store.begin_index_publication("graph-limit-kinds")?;
12739 publication.replace_repository_graph(project, &[], &[], &[], &coverage)?;
12740 publication.complete()?;
12741
12742 let verify = |store: &AtlasStore| -> Result<(), Box<dyn Error>> {
12743 for kind in GraphLimitKind::ALL {
12744 let page = store.repository_graph_coverage(
12745 project,
12746 &CoverageScope::Path {
12747 path: RepositoryNodePath::new(Path::new(&format!(
12748 "limits/{}.rs",
12749 kind.as_str()
12750 )))?,
12751 },
12752 1,
12753 )?;
12754 require_eq(&page.rows.len(), &1, "graph-limit coverage row count")?;
12755 require_eq(
12756 &page.rows[0].reached_limit(),
12757 &Some(kind),
12758 "graph-limit coverage spelling",
12759 )?;
12760 }
12761 Ok(())
12762 };
12763 verify(&store)?;
12764
12765 store.connection.execute_batch("BEGIN IMMEDIATE")?;
12766 let invalid = (|| -> DbResult<()> {
12767 store.connection.execute("DELETE FROM graph_coverage", [])?;
12768 store.connection.execute(
12769 "INSERT INTO graph_coverage(
12770 project_instance_id, scope_kind, state, total, covered, omitted,
12771 reason, reached_limit
12772 ) VALUES(?1, 'project', 'partial', 2, 1, 1, 'invalid limit', 'rowz')",
12773 [&project.as_bytes()[..]],
12774 )?;
12775 Ok(())
12776 })();
12777 require(
12778 matches!(invalid, Err(DbError::Sqlite(_))),
12779 "unknown graph-limit spelling bypassed the SQLite constraint",
12780 )?;
12781 store.connection.execute_batch("ROLLBACK")?;
12782 verify(&store)?;
12783 drop(store);
12784
12785 let reopened = AtlasStore::open_read_only_for_project(&database, &root)?;
12786 verify(&reopened)?;
12787 Ok(())
12788 }
12789
12790 #[test]
12791 fn typed_graph_round_trips_through_bounded_indexed_queries() -> Result<(), Box<dyn Error>> {
12792 let temp = tempfile::tempdir()?;
12793 let project_root = temp.path().join("typed-graph");
12794 let atlas_dir = project_root.join(".projectatlas");
12795 fs::create_dir_all(&atlas_dir)?;
12796 let db_path = atlas_dir.join("projectatlas.db");
12797 let mut writer = AtlasStore::open_for_project(&db_path, &project_root)?;
12798 let fixture = publish_fixture(&mut writer, "typed-graph")?;
12799 drop(writer);
12800 let store = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
12801
12802 for expected in &fixture.entities {
12803 require_eq(
12804 &store.repository_graph_entity(expected.key())?,
12805 &Some(expected.clone()),
12806 "stable entity lookup",
12807 )?;
12808 }
12809
12810 let source_path = RepositoryNodePath::new(Path::new("src/Äuth.rs"))?;
12811 let truncated =
12812 store.repository_graph_entities_by_path(fixture.project, &source_path, 1)?;
12813 require(
12814 truncated.truncated && truncated.rows.len() == 1,
12815 "entity LIMIT + 1",
12816 )?;
12817 let path_rows =
12818 store.repository_graph_entities_by_path(fixture.project, &source_path, 10)?;
12819 require(
12820 !path_rows.truncated && path_rows.rows.len() == 2,
12821 "Unicode/case path lookup",
12822 )?;
12823 for (result, context) in [
12824 (
12825 store.repository_graph_entities_by_path(fixture.project, &source_path, 0),
12826 "zero entity page limit",
12827 ),
12828 (
12829 store.repository_graph_entities_by_path(
12830 fixture.project,
12831 &source_path,
12832 GraphLimits::MAX_ROWS + 1,
12833 ),
12834 "over-ceiling entity page limit",
12835 ),
12836 ] {
12837 let error = require_db_error(result, context)?;
12838 require(
12839 matches!(error, DbError::GraphContract(_)),
12840 &format!("unexpected {context} error: {error}"),
12841 )?;
12842 }
12843
12844 let source = fixture
12845 .entities
12846 .iter()
12847 .find(|entity| matches!(entity.selector(), EntitySelector::File { .. }))
12848 .ok_or_else(|| io::Error::other("source file fixture missing"))?;
12849 let outbound = store.repository_graph_relations(
12850 RepositoryGraphRelationQuery::Outbound {
12851 source: source.key().clone(),
12852 },
12853 10,
12854 )?;
12855 require_eq(&outbound.rows.len(), &4, "all resolution states")?;
12856 require(
12857 outbound.rows.iter().any(|relation| {
12858 matches!(relation.resolution(), RelationResolution::Resolved { .. })
12859 }) && outbound.rows.iter().any(|relation| {
12860 matches!(relation.resolution(), RelationResolution::Ambiguous { .. })
12861 }) && outbound.rows.iter().any(|relation| {
12862 matches!(relation.resolution(), RelationResolution::Unresolved { .. })
12863 }) && outbound.rows.iter().any(|relation| {
12864 matches!(relation.resolution(), RelationResolution::External { .. })
12865 }),
12866 "resolution variants did not round-trip",
12867 )?;
12868 let outbound_truncated = store.repository_graph_relations(
12869 RepositoryGraphRelationQuery::Outbound {
12870 source: source.key().clone(),
12871 },
12872 3,
12873 )?;
12874 require(
12875 outbound_truncated.truncated && outbound_truncated.rows.len() == 3,
12876 "relation LIMIT + 1",
12877 )?;
12878 for (limit, context) in [
12879 (0, "zero relation page limit"),
12880 (
12881 GraphLimits::MAX_ROWS + 1,
12882 "over-ceiling relation page limit",
12883 ),
12884 ] {
12885 let error = require_db_error(
12886 store.repository_graph_relations(
12887 RepositoryGraphRelationQuery::Outbound {
12888 source: source.key().clone(),
12889 },
12890 limit,
12891 ),
12892 context,
12893 )?;
12894 require(
12895 matches!(error, DbError::GraphContract(_)),
12896 &format!("unexpected {context} error: {error}"),
12897 )?;
12898 }
12899
12900 let symbol = fixture
12901 .entities
12902 .iter()
12903 .find(|entity| matches!(entity.selector(), EntitySelector::Symbol { .. }))
12904 .ok_or_else(|| io::Error::other("symbol fixture missing"))?;
12905 let inbound = store.repository_graph_relations(
12906 RepositoryGraphRelationQuery::Inbound {
12907 target: symbol.key().clone(),
12908 },
12909 10,
12910 )?;
12911 require_eq(&inbound.rows.len(), &1, "inbound relation lookup")?;
12912 let calls = store.repository_graph_relations(
12913 RepositoryGraphRelationQuery::Family {
12914 relation: GraphRelationKind::Legacy(RelationKind::Calls),
12915 },
12916 10,
12917 )?;
12918 require_eq(&calls.rows.len(), &1, "relation-family lookup")?;
12919 let occurrence_page = store.repository_graph_occurrences(&calls.rows[0], 1)?;
12920 require(
12921 occurrence_page.truncated && occurrence_page.rows.len() == 1,
12922 "occurrence LIMIT + 1",
12923 )?;
12924 let all_occurrences = store.repository_graph_occurrences(&calls.rows[0], 10)?;
12925 require_eq(
12926 &all_occurrences.rows.len(),
12927 &2,
12928 "logical relation occurrence retention",
12929 )?;
12930 let error = require_db_error(
12931 store.repository_graph_occurrences(&calls.rows[0], 0),
12932 "zero occurrence page limit was accepted",
12933 )?;
12934 require(
12935 matches!(error, DbError::GraphContract(_)),
12936 &format!("unexpected zero occurrence-limit error: {error}"),
12937 )?;
12938 let error = require_db_error(
12939 store.repository_graph_occurrences(&calls.rows[0], GraphLimits::MAX_OCCURRENCES + 1),
12940 "over-ceiling occurrence page was accepted",
12941 )?;
12942 require(
12943 matches!(error, DbError::GraphContract(_)),
12944 &format!("unexpected occurrence-limit error: {error}"),
12945 )?;
12946
12947 let project_coverage =
12948 store.repository_graph_coverage(fixture.project, &CoverageScope::Project, 10)?;
12949 let path_coverage = store.repository_graph_coverage(
12950 fixture.project,
12951 &CoverageScope::Path {
12952 path: RepositoryNodePath::new(Path::new("src"))?,
12953 },
12954 10,
12955 )?;
12956 require_eq(&project_coverage.rows.len(), &8, "project coverage states")?;
12957 require_eq(&path_coverage.rows.len(), &1, "path coverage state")?;
12958 require(
12959 project_coverage
12960 .rows
12961 .iter()
12962 .any(|row| row.state() == CoverageState::NoCandidates && row.total() == 0),
12963 "zero-candidate coverage did not round-trip",
12964 )?;
12965 require(
12966 project_coverage.rows.iter().any(|row| {
12967 row.state() == CoverageState::Complete
12968 && row.total() == 0
12969 && row.relation()
12970 == Some(GraphRelationKind::Extended(ExtendedRelationKind::Tests))
12971 }),
12972 "non-document complete-zero coverage changed public state",
12973 )?;
12974 require(
12975 path_coverage.rows[0].state() == CoverageState::Partial,
12976 "partial coverage did not round-trip",
12977 )?;
12978 for (limit, context) in [
12979 (0, "zero coverage page limit"),
12980 (
12981 GraphLimits::MAX_ROWS + 1,
12982 "over-ceiling coverage page limit",
12983 ),
12984 ] {
12985 let error = require_db_error(
12986 store.repository_graph_coverage(fixture.project, &CoverageScope::Project, limit),
12987 context,
12988 )?;
12989 require(
12990 matches!(error, DbError::GraphContract(_)),
12991 &format!("unexpected {context} error: {error}"),
12992 )?;
12993 }
12994
12995 let source_next = GraphEntity::new(
12996 fixture.project,
12997 source.selector().clone(),
12998 IndexGeneration::new(2),
12999 )?;
13000 let symbol_next = GraphEntity::new(
13001 fixture.project,
13002 symbol.selector().clone(),
13003 IndexGeneration::new(2),
13004 )?;
13005 let next_generation_call = LogicalRelation::new(
13006 &source_next,
13007 GraphRelationKind::Legacy(RelationKind::Calls),
13008 RelationResolution::resolved(&symbol_next)?,
13009 ConfidenceClass::Exact,
13010 Completeness::Complete,
13011 IndexGeneration::new(2),
13012 )?;
13013 let error = require_db_error(
13014 store.repository_graph_occurrences(&next_generation_call, 10),
13015 "generation-mismatched occurrence request was accepted",
13016 )?;
13017 require(
13018 matches!(error, DbError::GraphContract(_)),
13019 &format!("unexpected occurrence generation error: {error}"),
13020 )?;
13021
13022 let lexical = store
13023 .load_file_text("src/Äuth.rs")?
13024 .ok_or_else(|| io::Error::other("lexical source row missing"))?;
13025 require_eq(
13026 &lexical.content,
13027 &"fn verifyToken()".to_string(),
13028 "lexical owner",
13029 )?;
13030 require_eq(
13031 &store.symbol_relation_count()?,
13032 &1,
13033 "legacy relation projection changed",
13034 )?;
13035
13036 store.finish_index_read_snapshot()?;
13037 drop(store);
13038 let mut writer = AtlasStore::open_for_project(&db_path, &project_root)?;
13039 let mut publication = writer.begin_index_publication("typed-graph")?;
13040 publication.replace_repository_graph_for_paths(
13041 fixture.project,
13042 &["src/unrelated.rs".to_string()],
13043 &[],
13044 &[],
13045 &[],
13046 &[],
13047 )?;
13048 publication.complete()?;
13049 drop(writer);
13050 let store = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
13051 let reused = store
13052 .repository_graph_entity(source.key())?
13053 .ok_or_else(|| io::Error::other("unchanged entity disappeared"))?;
13054 require_eq(
13055 &reused.generation(),
13056 &IndexGeneration::new(2),
13057 "unchanged graph row generation injection",
13058 )?;
13059 for expected in &fixture.entities {
13060 let reused = store
13061 .repository_graph_entity(expected.key())?
13062 .ok_or_else(|| io::Error::other("unchanged graph entity disappeared"))?;
13063 require_eq(
13064 &reused.generation(),
13065 &IndexGeneration::new(2),
13066 "incremental graph row reuse",
13067 )?;
13068 }
13069 assert_query_indexes(&store)?;
13070 store.finish_index_read_snapshot()?;
13071 Ok(())
13072 }
13073
13074 #[test]
13075 fn batched_adjacency_uses_direction_owned_indexes_and_stable_keysets()
13076 -> Result<(), Box<dyn Error>> {
13077 let temp = tempfile::tempdir()?;
13078 let project_root = temp.path().join("batched-adjacency");
13079 let atlas_dir = project_root.join(".projectatlas");
13080 fs::create_dir_all(&atlas_dir)?;
13081 let db_path = atlas_dir.join("projectatlas.db");
13082 let mut writer = AtlasStore::open_for_project(&db_path, &project_root)?;
13083 let fixture = publish_fixture(&mut writer, "batched-adjacency")?;
13084 drop(writer);
13085 let store = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
13086
13087 let source = fixture
13088 .entities
13089 .iter()
13090 .find(|entity| matches!(entity.selector(), EntitySelector::File { .. }))
13091 .ok_or_else(|| io::Error::other("source file fixture missing"))?;
13092 let symbol = fixture
13093 .entities
13094 .iter()
13095 .find(|entity| matches!(entity.selector(), EntitySelector::Symbol { .. }))
13096 .ok_or_else(|| io::Error::other("symbol fixture missing"))?;
13097 let external = fixture
13098 .entities
13099 .iter()
13100 .find(|entity| matches!(entity.selector(), EntitySelector::External { .. }))
13101 .ok_or_else(|| io::Error::other("external fixture missing"))?;
13102 let outbound_frontier = vec![source.key().clone()];
13103
13104 let first = store.repository_graph_adjacency_page(
13105 &outbound_frontier,
13106 RepositoryGraphDirection::Outbound,
13107 None,
13108 2,
13109 None,
13110 )?;
13111 require(
13112 first.truncated && first.rows.len() == 2 && first.continuation.is_some(),
13113 "outbound adjacency did not retain its LIMIT + 1 keyset",
13114 )?;
13115 require(
13116 first.rows.iter().all(|row| {
13117 row.frontier_index == 0
13118 && row.frontier == source.key().clone()
13119 && row.direction == RepositoryGraphDirection::Outbound
13120 }),
13121 "outbound adjacency lost its selecting frontier",
13122 )?;
13123 let continuation = first
13124 .continuation
13125 .clone()
13126 .ok_or_else(|| io::Error::other("outbound continuation missing"))?;
13127 let second = store.repository_graph_adjacency_page(
13128 &outbound_frontier,
13129 RepositoryGraphDirection::Outbound,
13130 Some(&continuation),
13131 10,
13132 None,
13133 )?;
13134 require(
13135 !second.truncated && second.rows.len() == 2 && second.continuation.is_none(),
13136 "outbound continuation did not finish the stable relation order",
13137 )?;
13138 let ordinary = store.repository_graph_relations(
13139 RepositoryGraphRelationQuery::Outbound {
13140 source: source.key().clone(),
13141 },
13142 10,
13143 )?;
13144 let combined = first
13145 .rows
13146 .into_iter()
13147 .chain(second.rows)
13148 .map(|row| row.detail.relation)
13149 .collect::<Vec<_>>();
13150 require_eq(
13151 &combined,
13152 &ordinary.rows,
13153 "adjacency keyset order versus ordinary relation order",
13154 )?;
13155 let calls = GraphRelationKind::Legacy(RelationKind::Calls);
13156 let filtered = store.repository_graph_adjacency_page_filtered(
13157 &outbound_frontier,
13158 RepositoryGraphDirection::Outbound,
13159 Some(calls),
13160 None,
13161 10,
13162 None,
13163 )?;
13164 let expected_calls = ordinary
13165 .rows
13166 .iter()
13167 .filter(|relation| relation.kind() == calls)
13168 .cloned()
13169 .collect::<Vec<_>>();
13170 require_eq(
13171 &filtered
13172 .rows
13173 .iter()
13174 .map(|row| row.detail.relation.clone())
13175 .collect::<Vec<_>>(),
13176 &expected_calls,
13177 "filtered adjacency versus ordinary family selection",
13178 )?;
13179 require(
13180 !filtered.truncated && filtered.continuation.is_none(),
13181 "filtered adjacency unexpectedly truncated its complete family",
13182 )?;
13183 let resolved = store.repository_graph_resolved_adjacency_page(
13184 &outbound_frontier,
13185 RepositoryGraphDirection::Outbound,
13186 calls,
13187 None,
13188 10,
13189 None,
13190 )?;
13191 require_eq(
13192 &resolved
13193 .rows
13194 .iter()
13195 .map(|row| row.detail.relation.clone())
13196 .collect::<Vec<_>>(),
13197 &expected_calls,
13198 "resolved adjacency versus exact local family selection",
13199 )?;
13200 let unresolved = store.repository_graph_resolved_adjacency_page(
13201 &outbound_frontier,
13202 RepositoryGraphDirection::Outbound,
13203 GraphRelationKind::Extended(ExtendedRelationKind::Configures),
13204 None,
13205 10,
13206 None,
13207 )?;
13208 require_eq(
13209 &unresolved.rows.len(),
13210 &0,
13211 "resolved adjacency retained unresolved rows",
13212 )?;
13213 let external_only = store.repository_graph_resolved_adjacency_page(
13214 &outbound_frontier,
13215 RepositoryGraphDirection::Outbound,
13216 GraphRelationKind::Legacy(RelationKind::DependsOn),
13217 None,
13218 10,
13219 None,
13220 )?;
13221 require_eq(
13222 &external_only.rows.len(),
13223 &0,
13224 "resolved adjacency retained external rows",
13225 )?;
13226 let mut resolved_cursor = continuation.clone();
13227 resolved_cursor.relation = Some(calls);
13228 resolved_cursor.resolved_only = true;
13229 let encoded = serde_json::to_string(&resolved_cursor)?;
13230 let decoded: RepositoryGraphAdjacencyContinuation = serde_json::from_str(&encoded)?;
13231 require(
13232 encoded.contains("\"resolved_only\":true") && decoded == resolved_cursor,
13233 "resolved adjacency cursor did not preserve its mode through serde",
13234 )?;
13235 let resolved_mode_mismatch = require_db_error(
13236 store.repository_graph_adjacency_page_filtered(
13237 &outbound_frontier,
13238 RepositoryGraphDirection::Outbound,
13239 Some(calls),
13240 Some(&decoded),
13241 1,
13242 None,
13243 ),
13244 "resolved adjacency cursor was accepted by ordinary adjacency",
13245 )?;
13246 require(
13247 matches!(resolved_mode_mismatch, DbError::GraphContract(_)),
13248 &format!("resolved cursor mismatch returned {resolved_mode_mismatch}"),
13249 )?;
13250
13251 let inbound_frontier = vec![symbol.key().clone(), external.key().clone()];
13252 let inbound = store.repository_graph_adjacency_page(
13253 &inbound_frontier,
13254 RepositoryGraphDirection::Inbound,
13255 None,
13256 10,
13257 None,
13258 )?;
13259 require(
13260 !inbound.truncated
13261 && inbound.rows.len() == 2
13262 && inbound.rows[0].frontier_index == 0
13263 && inbound.rows[0].frontier == symbol.key().clone()
13264 && inbound.rows[1].frontier_index == 1
13265 && inbound.rows[1].frontier == external.key().clone()
13266 && inbound
13267 .rows
13268 .iter()
13269 .all(|row| row.direction == RepositoryGraphDirection::Inbound),
13270 "inbound adjacency did not preserve bounded frontier order",
13271 )?;
13272
13273 for (direction, expected_index) in [
13274 (
13275 RepositoryGraphDirection::Outbound,
13276 "idx_graph_relations_source_kind",
13277 ),
13278 (
13279 RepositoryGraphDirection::Inbound,
13280 "idx_graph_relations_target_kind",
13281 ),
13282 ] {
13283 for continuation_index in [None, Some(0)] {
13284 let sql = format!(
13285 "EXPLAIN QUERY PLAN {}",
13286 adjacency_relation_sql(2, direction, continuation_index, false, false, false,)
13287 );
13288 let mut statement = store.connection.prepare(&sql)?;
13289 let mut bindings = vec![Value::Blob(fixture.project.as_bytes().to_vec())];
13290 bindings.push(Value::Blob(source.key().digest_bytes()?.to_vec()));
13291 if continuation_index.is_some() {
13292 bindings.extend([
13293 Value::Text(String::new()),
13294 Value::Text(String::new()),
13295 Value::Blob(vec![0; 32]),
13296 ]);
13297 }
13298 bindings.extend([
13299 Value::Integer(11),
13300 Value::Blob(symbol.key().digest_bytes()?.to_vec()),
13301 Value::Integer(11),
13302 Value::Integer(11),
13303 ]);
13304 let details = statement
13305 .query_map(params_from_iter(bindings.iter()), |row| {
13306 row.get::<_, String>(3)
13307 })?
13308 .collect::<Result<Vec<_>, _>>()?;
13309 require(
13310 details.iter().any(|detail| detail.contains(expected_index))
13311 && details
13312 .iter()
13313 .all(|detail| !detail.contains("SCAN relation")),
13314 &format!(
13315 "{direction:?} adjacency plan (continuation_index={continuation_index:?}) did not own its index: {details:?}"
13316 ),
13317 )?;
13318 }
13319
13320 let (scope, kind) = relation_parts(calls);
13321 let filtered_sql = format!(
13322 "EXPLAIN QUERY PLAN {}",
13323 adjacency_relation_sql(2, direction, None, true, false, false)
13324 );
13325 let mut filtered_statement = store.connection.prepare(&filtered_sql)?;
13326 let filtered_bindings = [
13327 Value::Blob(fixture.project.as_bytes().to_vec()),
13328 Value::Blob(source.key().digest_bytes()?.to_vec()),
13329 Value::Text(scope.to_string()),
13330 Value::Text(kind.to_string()),
13331 Value::Integer(11),
13332 Value::Blob(symbol.key().digest_bytes()?.to_vec()),
13333 Value::Text(scope.to_string()),
13334 Value::Text(kind.to_string()),
13335 Value::Integer(11),
13336 Value::Integer(11),
13337 ];
13338 let filtered_details = filtered_statement
13339 .query_map(params_from_iter(filtered_bindings.iter()), |row| {
13340 row.get::<_, String>(3)
13341 })?
13342 .collect::<Result<Vec<_>, _>>()?;
13343 require(
13344 filtered_details
13345 .iter()
13346 .any(|detail| detail.contains(expected_index))
13347 && filtered_details
13348 .iter()
13349 .all(|detail| !detail.contains("SCAN relation")),
13350 &format!(
13351 "filtered {direction:?} adjacency plan did not own its index: {filtered_details:?}"
13352 ),
13353 )?;
13354
13355 let resolved_sql = format!(
13356 "EXPLAIN QUERY PLAN {}",
13357 adjacency_relation_sql(2, direction, None, true, true, false)
13358 );
13359 let mut resolved_statement = store.connection.prepare(&resolved_sql)?;
13360 let resolved_bindings = [
13361 Value::Blob(fixture.project.as_bytes().to_vec()),
13362 Value::Text(RESOLUTION_STATUS_RESOLVED.to_string()),
13363 Value::Blob(source.key().digest_bytes()?.to_vec()),
13364 Value::Text(scope.to_string()),
13365 Value::Text(kind.to_string()),
13366 Value::Integer(11),
13367 Value::Blob(symbol.key().digest_bytes()?.to_vec()),
13368 Value::Text(scope.to_string()),
13369 Value::Text(kind.to_string()),
13370 Value::Integer(11),
13371 Value::Integer(11),
13372 ];
13373 let resolved_details = resolved_statement
13374 .query_map(params_from_iter(resolved_bindings.iter()), |row| {
13375 row.get::<_, String>(3)
13376 })?
13377 .collect::<Result<Vec<_>, _>>()?;
13378 require(
13379 resolved_details
13380 .iter()
13381 .any(|detail| detail.contains(expected_index))
13382 && resolved_details
13383 .iter()
13384 .all(|detail| !detail.contains("SCAN relation")),
13385 &format!(
13386 "resolved {direction:?} adjacency plan did not own its index: {resolved_details:?}"
13387 ),
13388 )?;
13389 }
13390
13391 let duplicate_error = require_db_error(
13392 store.repository_graph_adjacency_page(
13393 &[source.key().clone(), source.key().clone()],
13394 RepositoryGraphDirection::Outbound,
13395 None,
13396 1,
13397 None,
13398 ),
13399 "duplicate adjacency frontier was accepted",
13400 )?;
13401 require(
13402 matches!(duplicate_error, DbError::GraphContract(_)),
13403 &format!("unexpected duplicate-frontier error: {duplicate_error}"),
13404 )?;
13405 let oversized = vec![source.key().clone(); MAX_REPOSITORY_GRAPH_FRONTIER + 1];
13406 let oversized_error = require_db_error(
13407 store.repository_graph_adjacency_page(
13408 &oversized,
13409 RepositoryGraphDirection::Outbound,
13410 None,
13411 1,
13412 None,
13413 ),
13414 "oversized adjacency frontier was accepted",
13415 )?;
13416 require(
13417 matches!(oversized_error, DbError::GraphContract(_)),
13418 &format!("unexpected oversized-frontier error: {oversized_error}"),
13419 )?;
13420 let foreign = GraphEntity::new(
13421 ProjectInstanceId::from_bytes([0x7f; 16])?,
13422 EntitySelector::Project,
13423 IndexGeneration::new(1),
13424 )?;
13425 let project_error = require_db_error(
13426 store.repository_graph_adjacency_page(
13427 &[source.key().clone(), foreign.key().clone()],
13428 RepositoryGraphDirection::Outbound,
13429 None,
13430 1,
13431 None,
13432 ),
13433 "mixed-project adjacency frontier was accepted",
13434 )?;
13435 require(
13436 matches!(project_error, DbError::GraphProjectIdentityMismatch { .. }),
13437 &format!("unexpected mixed-project error: {project_error}"),
13438 )?;
13439 let direction_error = require_db_error(
13440 store.repository_graph_adjacency_page(
13441 &outbound_frontier,
13442 RepositoryGraphDirection::Inbound,
13443 Some(&continuation),
13444 1,
13445 None,
13446 ),
13447 "cross-direction adjacency continuation was accepted",
13448 )?;
13449 require(
13450 matches!(direction_error, DbError::GraphContract(_)),
13451 &format!("unexpected continuation-direction error: {direction_error}"),
13452 )?;
13453 let mut filtered_continuation = continuation.clone();
13454 filtered_continuation.relation = Some(calls);
13455 let family_error = require_db_error(
13456 store.repository_graph_adjacency_page_filtered(
13457 &outbound_frontier,
13458 RepositoryGraphDirection::Outbound,
13459 Some(GraphRelationKind::Extended(
13460 ExtendedRelationKind::Configures,
13461 )),
13462 Some(&filtered_continuation),
13463 1,
13464 None,
13465 ),
13466 "cross-family adjacency continuation was accepted",
13467 )?;
13468 require(
13469 matches!(family_error, DbError::GraphContract(_)),
13470 &format!("unexpected continuation-family error: {family_error}"),
13471 )?;
13472 let frontier_error = require_db_error(
13473 store.repository_graph_adjacency_page(
13474 &[external.key().clone()],
13475 RepositoryGraphDirection::Outbound,
13476 Some(&continuation),
13477 1,
13478 None,
13479 ),
13480 "cross-frontier adjacency continuation was accepted",
13481 )?;
13482 require(
13483 matches!(frontier_error, DbError::GraphContract(_)),
13484 &format!("unexpected continuation-frontier error: {frontier_error}"),
13485 )?;
13486
13487 let inbound_first = store.repository_graph_adjacency_page(
13488 &inbound_frontier,
13489 RepositoryGraphDirection::Inbound,
13490 None,
13491 1,
13492 None,
13493 )?;
13494 let inbound_continuation = inbound_first
13495 .continuation
13496 .ok_or_else(|| io::Error::other("inbound continuation missing"))?;
13497 require(
13498 store.repository_graph_adjacency_continuation_has_filtered_rows(
13499 &inbound_continuation,
13500 ConfidenceClass::Low,
13501 ContentSelection::UnspecifiedLegacy,
13502 None,
13503 )?,
13504 "multi-frontier continuation lost its remaining admitted relation",
13505 )?;
13506 let mut exhausted_continuation = inbound_continuation.clone();
13507 exhausted_continuation.relation = Some(GraphRelationKind::Extended(
13508 ExtendedRelationKind::Configures,
13509 ));
13510 require(
13511 !store.repository_graph_adjacency_continuation_has_filtered_rows(
13512 &exhausted_continuation,
13513 ConfidenceClass::Low,
13514 ContentSelection::UnspecifiedLegacy,
13515 None,
13516 )?,
13517 "multi-frontier continuation retained an excluded relation",
13518 )?;
13519 let reordered_frontier = vec![external.key().clone(), symbol.key().clone()];
13520 let reordered_error = require_db_error(
13521 store.repository_graph_adjacency_page(
13522 &reordered_frontier,
13523 RepositoryGraphDirection::Inbound,
13524 Some(&inbound_continuation),
13525 1,
13526 None,
13527 ),
13528 "reordered adjacency continuation frontier was accepted",
13529 )?;
13530 require(
13531 matches!(reordered_error, DbError::GraphContract(_)),
13532 &format!("unexpected reordered-frontier error: {reordered_error}"),
13533 )?;
13534 let empty_frontier_error = require_db_error(
13535 store.repository_graph_adjacency_page(
13536 &[],
13537 RepositoryGraphDirection::Inbound,
13538 Some(&inbound_continuation),
13539 1,
13540 None,
13541 ),
13542 "adjacency continuation without a frontier was accepted",
13543 )?;
13544 require(
13545 matches!(empty_frontier_error, DbError::GraphContract(_)),
13546 &format!("unexpected empty-frontier error: {empty_frontier_error}"),
13547 )?;
13548
13549 let mut foreign_project_continuation = continuation.clone();
13550 foreign_project_continuation.project = foreign.key().project();
13551 let continuation_project_error = require_db_error(
13552 store.repository_graph_adjacency_page(
13553 &outbound_frontier,
13554 RepositoryGraphDirection::Outbound,
13555 Some(&foreign_project_continuation),
13556 1,
13557 None,
13558 ),
13559 "cross-project adjacency continuation was accepted",
13560 )?;
13561 require(
13562 matches!(continuation_project_error, DbError::GraphContract(_)),
13563 &format!("unexpected continuation-project error: {continuation_project_error}"),
13564 )?;
13565 let mut stale_generation_continuation = continuation.clone();
13566 stale_generation_continuation.generation = stale_generation_continuation
13567 .generation
13568 .checked_next()
13569 .ok_or_else(|| io::Error::other("fixture generation overflowed"))?;
13570 let continuation_generation_error = require_db_error(
13571 store.repository_graph_adjacency_page(
13572 &outbound_frontier,
13573 RepositoryGraphDirection::Outbound,
13574 Some(&stale_generation_continuation),
13575 1,
13576 None,
13577 ),
13578 "cross-generation adjacency continuation was accepted",
13579 )?;
13580 require(
13581 matches!(continuation_generation_error, DbError::GraphContract(_)),
13582 &format!("unexpected continuation-generation error: {continuation_generation_error}"),
13583 )?;
13584
13585 let mut maximum_frontier = Vec::with_capacity(MAX_REPOSITORY_GRAPH_FRONTIER);
13586 for index in 0..MAX_REPOSITORY_GRAPH_FRONTIER {
13587 let entity = GraphEntity::new(
13588 fixture.project,
13589 EntitySelector::External {
13590 external: ExternalSelector {
13591 system: GraphIdentityText::new("work-envelope")?,
13592 identity: GraphIdentityText::new(format!("candidate-{index}"))?,
13593 },
13594 },
13595 continuation.generation,
13596 )?;
13597 maximum_frontier.push(entity.key().clone());
13598 }
13599 let bounded_work = store.repository_graph_adjacency_page(
13600 &maximum_frontier,
13601 RepositoryGraphDirection::Outbound,
13602 None,
13603 38,
13604 None,
13605 )?;
13606 require(
13607 bounded_work.rows.is_empty() && !bounded_work.truncated,
13608 "maximum adjacency frontier rejected work below the intermediate ceiling",
13609 )?;
13610 let excessive_work_error = require_db_error(
13611 store.repository_graph_adjacency_page(
13612 &maximum_frontier,
13613 RepositoryGraphDirection::Outbound,
13614 None,
13615 39,
13616 None,
13617 ),
13618 "adjacency intermediate work above the ceiling was accepted",
13619 )?;
13620 require(
13621 matches!(excessive_work_error, DbError::GraphContract(_)),
13622 &format!("unexpected intermediate-work error: {excessive_work_error}"),
13623 )?;
13624
13625 let successful_cancellation = projectatlas_core::IndexCancellation::new();
13626 let successful_control = IndexWorkControl::new(successful_cancellation.clone(), None);
13627 store.repository_graph_adjacency_page(
13628 &outbound_frontier,
13629 RepositoryGraphDirection::Outbound,
13630 None,
13631 10,
13632 Some(&successful_control),
13633 )?;
13634 successful_cancellation.cancel();
13635 require_eq(
13636 &store
13637 .connection
13638 .query_row("SELECT 1", [], |row| row.get::<_, i64>(0))?,
13639 &1,
13640 "cleared adjacency progress handler",
13641 )?;
13642
13643 let cancellation = projectatlas_core::IndexCancellation::new();
13644 cancellation.cancel();
13645 let control = IndexWorkControl::new(cancellation, None);
13646 let cancelled = store.repository_graph_adjacency_page(
13647 &outbound_frontier,
13648 RepositoryGraphDirection::Outbound,
13649 None,
13650 10,
13651 Some(&control),
13652 );
13653 require(
13654 matches!(
13655 cancelled,
13656 Err(DbError::IndexWork(
13657 projectatlas_core::IndexWorkFailure::Cancelled {
13658 stage: IndexWorkStage::RepositoryTraversal
13659 }
13660 ))
13661 ),
13662 "adjacency cancellation was not typed",
13663 )?;
13664 store.finish_index_read_snapshot()?;
13665 Ok(())
13666 }
13667
13668 #[test]
13669 fn detailed_relation_storage_statements_are_indexed_and_batch_bounded()
13670 -> Result<(), Box<dyn Error>> {
13671 use DetailedRelationTraceStatement::{
13672 ActiveGraphGeneration, AdjacencyRelations, ProjectIdentity, PublicationMetadata,
13673 PurposeOwners, RelationEntities, RelationOccurrences, RelationsByDigest,
13674 };
13675
13676 let temp = tempfile::tempdir()?;
13677 let project_root = temp.path().join("detailed-relation-storage");
13678 let atlas_dir = project_root.join(".projectatlas");
13679 fs::create_dir_all(&atlas_dir)?;
13680 let db_path = atlas_dir.join("projectatlas.db");
13681 let mut writer = AtlasStore::open_for_project(&db_path, &project_root)?;
13682 let fixture =
13683 publish_detailed_relation_trace_fixture(&mut writer, "detailed-relation-storage")?;
13684 drop(writer);
13685 let mut store = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
13686 let generation = store
13687 .repository_graph_generation()?
13688 .ok_or_else(|| io::Error::other("graph generation missing"))?;
13689 let budget = maximum_repository_graph_read_budget()?;
13690
13691 assert_cursor_hydration_indexes(&store)?;
13692
13693 let one_frontier = vec![fixture.source.key().clone()];
13694 let many_frontier = std::iter::once(fixture.source.key().clone())
13695 .chain(
13696 fixture
13697 .relations
13698 .iter()
13699 .filter_map(|relation| relation.resolution().resolved_target().cloned())
13700 .take(3),
13701 )
13702 .collect::<Vec<_>>();
13703 require_eq(
13704 &many_frontier.len(),
13705 &4,
13706 "multi-frontier trace fixture cardinality",
13707 )?;
13708 for (context, frontier) in [
13709 ("one-frontier adjacency", one_frontier),
13710 ("many-frontier adjacency", many_frontier),
13711 ] {
13712 let (page, statements) = trace_statements(&mut store, |store| {
13713 store.repository_graph_adjacency_page_bounded(
13714 &frontier,
13715 RepositoryGraphDirection::Outbound,
13716 None,
13717 4,
13718 budget,
13719 None,
13720 )
13721 })?;
13722 require(
13723 page.page.rows.len() == 4 && page.page.truncated,
13724 &format!("{context} did not exercise its bounded sentinel"),
13725 )?;
13726 require_traced_statement_multiset(
13727 &statements,
13728 &[
13729 (ProjectIdentity, 1),
13730 (PublicationMetadata, 1),
13731 (ActiveGraphGeneration, 1),
13732 (AdjacencyRelations, 1),
13733 (RelationEntities, 1),
13734 ],
13735 context,
13736 )?;
13737 }
13738
13739 let relation_digests = fixture
13740 .relations
13741 .iter()
13742 .map(|relation| relation.key().digest_bytes())
13743 .collect::<Result<Vec<_>, _>>()?;
13744 for (context, digests, endpoint_chunks) in [
13745 ("one-relation hydration", &relation_digests[..1], 1),
13746 (
13747 "128 unique endpoint hydration",
13748 &relation_digests[..GRAPH_ENTITY_HYDRATION_CHUNK - 1],
13749 1,
13750 ),
13751 (
13752 "129 unique endpoint hydration",
13753 &relation_digests[..GRAPH_ENTITY_HYDRATION_CHUNK],
13754 2,
13755 ),
13756 ("maximum relation hydration", relation_digests.as_slice(), 3),
13757 ] {
13758 let (relations, statements) = trace_statements(&mut store, |store| {
13759 store.repository_graph_relation_rows_by_digest(
13760 fixture.project,
13761 generation,
13762 digests,
13763 budget,
13764 None,
13765 )
13766 })?;
13767 require_eq(
13768 &relations.rows.len(),
13769 &digests.len(),
13770 &format!("{context} returned relation count"),
13771 )?;
13772 require_traced_statement_multiset(
13773 &statements,
13774 &[
13775 (ProjectIdentity, 1),
13776 (PublicationMetadata, 1),
13777 (ActiveGraphGeneration, 1),
13778 (RelationsByDigest, 1),
13779 (RelationEntities, endpoint_chunks),
13780 ],
13781 context,
13782 )?;
13783 }
13784
13785 for (context, relations) in [
13786 ("one-relation occurrences", &fixture.relations[..1]),
13787 (
13788 "maximum relation occurrence batch",
13789 fixture.relations.as_slice(),
13790 ),
13791 ] {
13792 let (pages, statements) = trace_statements(&mut store, |store| {
13793 store.repository_graph_occurrence_pages_bounded(relations, 1, budget, None)
13794 })?;
13795 require(
13796 pages.pages.len() == relations.len()
13797 && pages
13798 .pages
13799 .iter()
13800 .all(|page| page.rows.len() == 1 && page.truncated),
13801 &format!("{context} did not retain one row plus its occurrence sentinel"),
13802 )?;
13803 require_traced_statement_multiset(
13804 &statements,
13805 &[
13806 (ProjectIdentity, 1),
13807 (PublicationMetadata, 1),
13808 (ActiveGraphGeneration, 1),
13809 (RelationOccurrences, 1),
13810 ],
13811 context,
13812 )?;
13813 }
13814 let mut oversized_occurrence_batch = fixture.relations.clone();
13815 oversized_occurrence_batch.push(fixture.relations[0].clone());
13816 let oversized_occurrences = require_db_error(
13817 store.repository_graph_occurrence_pages_bounded(
13818 &oversized_occurrence_batch,
13819 1,
13820 budget,
13821 None,
13822 ),
13823 "oversized relation occurrence batch was accepted",
13824 )?;
13825 require(
13826 matches!(oversized_occurrences, DbError::GraphContract(_)),
13827 &format!("unexpected oversized occurrence error: {oversized_occurrences}"),
13828 )?;
13829
13830 let one_purpose_path = vec![".".to_string()];
13831 let one_purpose_chunk = std::iter::once(".".to_string())
13832 .chain(
13833 (1..crate::MAX_PURPOSE_CURATION_BATCH_ROWS)
13834 .map(|index| format!("missing/purpose-{index}.rs")),
13835 )
13836 .collect::<Vec<_>>();
13837 let two_purpose_chunks = one_purpose_chunk
13838 .iter()
13839 .cloned()
13840 .chain(std::iter::once("missing/purpose-overflow.rs".to_string()))
13841 .collect::<Vec<_>>();
13842 for (context, paths, expected) in [
13843 ("one purpose owner", one_purpose_path.as_slice(), 1),
13844 (
13845 "one full purpose-owner chunk",
13846 one_purpose_chunk.as_slice(),
13847 1,
13848 ),
13849 ("two purpose-owner chunks", two_purpose_chunks.as_slice(), 2),
13850 ] {
13851 let (_purposes, statements) = trace_statements(&mut store, |store| {
13852 store.load_purpose_owner_nodes_by_paths_controlled(
13853 fixture.project,
13854 generation,
13855 paths,
13856 budget,
13857 None,
13858 )
13859 })?;
13860 require_traced_statement_multiset(
13861 &statements,
13862 &[
13863 (ProjectIdentity, 1),
13864 (PublicationMetadata, 1),
13865 (ActiveGraphGeneration, 1),
13866 (PurposeOwners, expected),
13867 ],
13868 context,
13869 )?;
13870 }
13871
13872 store.finish_index_read_snapshot()?;
13873 Ok(())
13874 }
13875
13876 #[test]
13877 fn cursor_hydration_is_ordered_bounded_and_fail_closed() -> Result<(), Box<dyn Error>> {
13878 let temp = tempfile::tempdir()?;
13879 let project_root = temp.path().join("cursor-hydration");
13880 let atlas_dir = project_root.join(".projectatlas");
13881 fs::create_dir_all(&atlas_dir)?;
13882 let db_path = atlas_dir.join("projectatlas.db");
13883 let mut writer = AtlasStore::open_for_project(&db_path, &project_root)?;
13884 let fixture = publish_fixture(&mut writer, "cursor-hydration")?;
13885 writer.connection.execute(
13886 "INSERT INTO graph_relation_occurrences(
13887 relation_key, file_path, start_line, start_column, end_line, end_column
13888 ) VALUES(?1, 'Cargo.toml', 1, 0, 1, 1)",
13889 params![&fixture.relations[0].key().digest_bytes()?[..]],
13890 )?;
13891 writer.connection.execute(
13892 "INSERT INTO graph_coverage(
13893 project_instance_id, scope_kind, scope_path, relation_scope,
13894 relation_kind, state, total, covered, omitted, reason, reached_limit
13895 ) VALUES(?1, 'path', 'Cargo.toml', NULL, NULL,
13896 'complete', 1, 1, 0, NULL, NULL)",
13897 params![&fixture.project.as_bytes()[..]],
13898 )?;
13899 drop(writer);
13900 let store = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
13901 let generation = store
13902 .repository_graph_generation()?
13903 .ok_or_else(|| io::Error::other("graph generation missing"))?;
13904 let full_budget = RepositoryGraphReadBudget::new(
13905 MAX_REPOSITORY_GRAPH_FRONTIER as u32,
13906 MAX_REPOSITORY_GRAPH_FRONTIER as u32,
13907 RepositoryGraphReadBudget::MAX_DECODED_BYTES,
13908 RepositoryGraphReadBudget::MAX_HYDRATED_ENTITIES,
13909 RepositoryGraphReadBudget::MAX_HYDRATED_PATHS,
13910 )?;
13911
13912 let expected_entities = fixture
13913 .entities
13914 .iter()
13915 .rev()
13916 .take(3)
13917 .cloned()
13918 .collect::<Vec<_>>();
13919 let entity_digests = expected_entities
13920 .iter()
13921 .map(|entity| entity.key().digest_bytes())
13922 .collect::<Result<Vec<_>, _>>()?;
13923 let entity_batch = store.repository_graph_entities_by_digest(
13924 fixture.project,
13925 generation,
13926 &entity_digests,
13927 full_budget,
13928 None,
13929 )?;
13930 require_eq(
13931 &entity_batch.rows,
13932 &expected_entities,
13933 "ordered entity cursor hydration",
13934 )?;
13935 require_eq(
13936 &entity_batch.work,
13937 &RepositoryGraphReadWork {
13938 requested_rows: 3,
13939 returned_rows: 3,
13940 decoded_bytes: entity_batch.work.decoded_bytes,
13941 hydrated_entities: 3,
13942 hydrated_paths: 2,
13943 },
13944 "exact entity cursor work",
13945 )?;
13946 require(
13947 entity_batch.work.decoded_bytes > 0,
13948 "entity cursor decoded no SQLite payload bytes",
13949 )?;
13950
13951 let file_entity = fixture
13952 .entities
13953 .iter()
13954 .find(|entity| matches!(entity.selector(), EntitySelector::File { .. }))
13955 .ok_or_else(|| io::Error::other("file anchor fixture missing"))?;
13956 let file_anchor = store.repository_graph_entity_bounded(
13957 file_entity.key(),
13958 generation,
13959 full_budget,
13960 None,
13961 )?;
13962 require_eq(
13963 &file_anchor.rows,
13964 &vec![file_entity.clone()],
13965 "exact file anchor hydration",
13966 )?;
13967 require_eq(
13968 &file_anchor.work,
13969 &RepositoryGraphReadWork {
13970 requested_rows: 1,
13971 returned_rows: 1,
13972 decoded_bytes: file_anchor.work.decoded_bytes,
13973 hydrated_entities: 1,
13974 hydrated_paths: 1,
13975 },
13976 "exact file anchor work",
13977 )?;
13978 let exact_file_budget =
13979 RepositoryGraphReadBudget::new(1, 1, file_anchor.work.decoded_bytes, 1, 1)?;
13980 require_eq(
13981 &store.repository_graph_entity_bounded(
13982 file_entity.key(),
13983 generation,
13984 exact_file_budget,
13985 None,
13986 )?,
13987 &file_anchor,
13988 "exact file anchor envelope",
13989 )?;
13990 require_eq(
13991 &store.repository_graph_entity(file_entity.key())?,
13992 &Some(file_entity.clone()),
13993 "legacy file anchor wrapper compatibility",
13994 )?;
13995 let file_decode_overrun = require_db_error(
13996 store.repository_graph_entity_bounded(
13997 file_entity.key(),
13998 generation,
13999 RepositoryGraphReadBudget::new(1, 1, file_anchor.work.decoded_bytes - 1, 1, 1)?,
14000 None,
14001 ),
14002 "file anchor decoded-byte overrun was accepted",
14003 )?;
14004 require(
14005 matches!(file_decode_overrun, DbError::GraphContract(_)),
14006 &format!("unexpected file anchor envelope error: {file_decode_overrun}"),
14007 )?;
14008 let missing_file_selector = EntitySelector::File {
14009 path: RepositoryFilePath::new(Path::new("missing-anchor.rs"))?,
14010 };
14011 let missing_file_key = GraphEntityKey::new(fixture.project, &missing_file_selector);
14012 let missing_file = store.repository_graph_entity_bounded(
14013 &missing_file_key,
14014 generation,
14015 full_budget,
14016 None,
14017 )?;
14018 require(
14019 missing_file.rows.is_empty()
14020 && missing_file.work
14021 == (RepositoryGraphReadWork {
14022 requested_rows: 1,
14023 returned_rows: 0,
14024 decoded_bytes: 0,
14025 hydrated_entities: 0,
14026 hydrated_paths: 0,
14027 }),
14028 "missing file anchor did not return exact empty work",
14029 )?;
14030
14031 let anchor_path = RepositoryNodePath::new(Path::new("src/Äuth.rs"))?;
14032 let path_anchors = store.repository_graph_entities_by_path_bounded(
14033 fixture.project,
14034 generation,
14035 &anchor_path,
14036 1,
14037 full_budget,
14038 None,
14039 )?;
14040 require(
14041 path_anchors.page.rows.len() == 1
14042 && path_anchors.page.truncated
14043 && matches!(
14044 path_anchors.page.rows[0].selector(),
14045 EntitySelector::File { .. }
14046 )
14047 && path_anchors.work.requested_rows == 1
14048 && path_anchors.work.returned_rows == 1
14049 && path_anchors.work.decoded_bytes > 0
14050 && path_anchors.work.hydrated_entities == 2
14051 && path_anchors.work.hydrated_paths == 1,
14052 "path anchor page lost stable order or sentinel work",
14053 )?;
14054 let exact_path_anchor_budget =
14055 RepositoryGraphReadBudget::new(1, 1, path_anchors.work.decoded_bytes, 2, 1)?;
14056 require_eq(
14057 &store.repository_graph_entities_by_path_bounded(
14058 fixture.project,
14059 generation,
14060 &anchor_path,
14061 1,
14062 exact_path_anchor_budget,
14063 None,
14064 )?,
14065 &path_anchors,
14066 "exact path anchor envelope",
14067 )?;
14068 require_eq(
14069 &store.repository_graph_entities_by_path(fixture.project, &anchor_path, 1)?,
14070 &path_anchors.page,
14071 "legacy path anchor wrapper compatibility",
14072 )?;
14073 for (budget, limit, context) in [
14074 (
14075 RepositoryGraphReadBudget::new(1, 1, path_anchors.work.decoded_bytes - 1, 2, 1)?,
14076 1,
14077 "path anchor decoded-byte overrun was accepted",
14078 ),
14079 (
14080 RepositoryGraphReadBudget::new(1, 1, path_anchors.work.decoded_bytes, 1, 1)?,
14081 1,
14082 "path anchor sentinel entity overrun was accepted",
14083 ),
14084 (
14085 exact_path_anchor_budget,
14086 2,
14087 "path anchor returned-row overrun was accepted",
14088 ),
14089 ] {
14090 let error = require_db_error(
14091 store.repository_graph_entities_by_path_bounded(
14092 fixture.project,
14093 generation,
14094 &anchor_path,
14095 limit,
14096 budget,
14097 None,
14098 ),
14099 context,
14100 )?;
14101 require(
14102 matches!(error, DbError::GraphContract(_)),
14103 &format!("unexpected path anchor envelope error: {error}"),
14104 )?;
14105 }
14106 let anchor_cancellation = projectatlas_core::IndexCancellation::new();
14107 anchor_cancellation.cancel();
14108 let anchor_control = IndexWorkControl::new(anchor_cancellation, None);
14109 let cancelled_anchor = store.repository_graph_entities_by_path_bounded(
14110 fixture.project,
14111 generation,
14112 &anchor_path,
14113 1,
14114 full_budget,
14115 Some(&anchor_control),
14116 );
14117 require(
14118 matches!(
14119 cancelled_anchor,
14120 Err(DbError::IndexWork(
14121 projectatlas_core::IndexWorkFailure::Cancelled {
14122 stage: IndexWorkStage::RepositoryTraversal
14123 }
14124 ))
14125 ),
14126 "path anchor cancellation was not typed",
14127 )?;
14128 let expired_anchor = IndexWorkControl::with_deadline(
14129 projectatlas_core::IndexCancellation::new(),
14130 Instant::now(),
14131 );
14132 let anchor_deadline = store.repository_graph_entity_bounded(
14133 file_entity.key(),
14134 generation,
14135 full_budget,
14136 Some(&expired_anchor),
14137 );
14138 require(
14139 matches!(
14140 anchor_deadline,
14141 Err(DbError::IndexWork(
14142 projectatlas_core::IndexWorkFailure::DeadlineExceeded {
14143 stage: IndexWorkStage::RepositoryTraversal
14144 }
14145 ))
14146 ),
14147 "file anchor deadline was not typed",
14148 )?;
14149
14150 let expected_relations = fixture.relations.iter().rev().cloned().collect::<Vec<_>>();
14151 let relation_digests = expected_relations
14152 .iter()
14153 .map(|relation| relation.key().digest_bytes())
14154 .collect::<Result<Vec<_>, _>>()?;
14155 let relation_rows = store.repository_graph_relation_rows_by_digest(
14156 fixture.project,
14157 generation,
14158 &relation_digests,
14159 full_budget,
14160 None,
14161 )?;
14162 require_eq(
14163 &relation_rows
14164 .rows
14165 .iter()
14166 .map(|row| row.relation.clone())
14167 .collect::<Vec<_>>(),
14168 &expected_relations,
14169 "ordered relation cursor hydration",
14170 )?;
14171 require(
14172 relation_rows.rows.iter().all(|row| {
14173 row.source.key().project() == fixture.project
14174 && row
14175 .target
14176 .as_ref()
14177 .is_none_or(|target| target.key().project() == fixture.project)
14178 }),
14179 "relation cursor hydration returned an unvalidated endpoint",
14180 )?;
14181 require_eq(
14182 &relation_rows.work,
14183 &RepositoryGraphReadWork {
14184 requested_rows: 4,
14185 returned_rows: 4,
14186 decoded_bytes: relation_rows.work.decoded_bytes,
14187 hydrated_entities: 3,
14188 hydrated_paths: 1,
14189 },
14190 "exact relation cursor work",
14191 )?;
14192 require(
14193 relation_rows.work.decoded_bytes > entity_batch.work.decoded_bytes,
14194 "relation cursor did not meter relation and endpoint payload bytes",
14195 )?;
14196
14197 let exact_entity_budget =
14198 RepositoryGraphReadBudget::new(3, 3, entity_batch.work.decoded_bytes, 3, 2)?;
14199 require_eq(
14200 &store
14201 .repository_graph_entities_by_digest(
14202 fixture.project,
14203 generation,
14204 &entity_digests,
14205 exact_entity_budget,
14206 None,
14207 )?
14208 .work,
14209 &entity_batch.work,
14210 "exact entity envelope",
14211 )?;
14212 let exact_relation_budget =
14213 RepositoryGraphReadBudget::new(4, 4, relation_rows.work.decoded_bytes, 3, 1)?;
14214 require_eq(
14215 &store
14216 .repository_graph_relation_rows_by_digest(
14217 fixture.project,
14218 generation,
14219 &relation_digests,
14220 exact_relation_budget,
14221 None,
14222 )?
14223 .work,
14224 &relation_rows.work,
14225 "exact relation envelope",
14226 )?;
14227
14228 for (budget, context) in [
14229 (
14230 RepositoryGraphReadBudget::new(3, 3, entity_batch.work.decoded_bytes - 1, 3, 2)?,
14231 "decoded-byte envelope overrun was accepted",
14232 ),
14233 (
14234 RepositoryGraphReadBudget::new(3, 3, entity_batch.work.decoded_bytes, 3, 1)?,
14235 "purpose-path envelope overrun was accepted",
14236 ),
14237 ] {
14238 let error = require_db_error(
14239 store.repository_graph_entities_by_digest(
14240 fixture.project,
14241 generation,
14242 &entity_digests,
14243 budget,
14244 None,
14245 ),
14246 context,
14247 )?;
14248 require(
14249 matches!(error, DbError::GraphContract(_)),
14250 &format!("unexpected entity envelope error: {error}"),
14251 )?;
14252 }
14253 let endpoint_budget =
14254 RepositoryGraphReadBudget::new(4, 4, relation_rows.work.decoded_bytes, 2, 1)?;
14255 let endpoint_error = require_db_error(
14256 store.repository_graph_relation_rows_by_digest(
14257 fixture.project,
14258 generation,
14259 &relation_digests,
14260 endpoint_budget,
14261 None,
14262 ),
14263 "endpoint entity envelope overrun was accepted",
14264 )?;
14265 require(
14266 matches!(endpoint_error, DbError::GraphContract(_)),
14267 &format!("unexpected endpoint envelope error: {endpoint_error}"),
14268 )?;
14269
14270 for (budget, context) in [
14271 (
14272 RepositoryGraphReadBudget::new(
14273 2,
14274 3,
14275 RepositoryGraphReadBudget::MAX_DECODED_BYTES,
14276 3,
14277 2,
14278 )?,
14279 "requested-row envelope overrun was accepted",
14280 ),
14281 (
14282 RepositoryGraphReadBudget::new(
14283 3,
14284 2,
14285 RepositoryGraphReadBudget::MAX_DECODED_BYTES,
14286 3,
14287 2,
14288 )?,
14289 "returned-row envelope overrun was accepted",
14290 ),
14291 ] {
14292 let error = require_db_error(
14293 store.repository_graph_entities_by_digest(
14294 fixture.project,
14295 generation,
14296 &entity_digests,
14297 budget,
14298 None,
14299 ),
14300 context,
14301 )?;
14302 require(
14303 matches!(error, DbError::GraphContract(_)),
14304 &format!("unexpected row-envelope error: {error}"),
14305 )?;
14306 }
14307
14308 for invalid in [
14309 RepositoryGraphReadBudget::new(0, 1, 1, 1, 1),
14310 RepositoryGraphReadBudget::new(
14311 RepositoryGraphReadBudget::MAX_REQUESTED_ROWS + 1,
14312 1,
14313 1,
14314 1,
14315 1,
14316 ),
14317 RepositoryGraphReadBudget::new(
14318 1,
14319 RepositoryGraphReadBudget::MAX_RETURNED_ROWS + 1,
14320 1,
14321 1,
14322 1,
14323 ),
14324 RepositoryGraphReadBudget::new(
14325 1,
14326 1,
14327 RepositoryGraphReadBudget::MAX_DECODED_BYTES + 1,
14328 1,
14329 1,
14330 ),
14331 RepositoryGraphReadBudget::new(
14332 1,
14333 1,
14334 1,
14335 RepositoryGraphReadBudget::MAX_HYDRATED_ENTITIES + 1,
14336 1,
14337 ),
14338 RepositoryGraphReadBudget::new(
14339 1,
14340 1,
14341 1,
14342 1,
14343 RepositoryGraphReadBudget::MAX_HYDRATED_PATHS + 1,
14344 ),
14345 ] {
14346 require(
14347 matches!(invalid, Err(GraphContractError::InvalidLimits { .. })),
14348 "invalid graph read budget was accepted",
14349 )?;
14350 }
14351
14352 let empty_entities = store.repository_graph_entities_by_digest(
14353 fixture.project,
14354 generation,
14355 &[],
14356 full_budget,
14357 None,
14358 )?;
14359 let empty_relations = store.repository_graph_relation_rows_by_digest(
14360 fixture.project,
14361 generation,
14362 &[],
14363 full_budget,
14364 None,
14365 )?;
14366 require(
14367 empty_entities.rows.is_empty()
14368 && empty_relations.rows.is_empty()
14369 && empty_entities.work
14370 == (RepositoryGraphReadWork {
14371 requested_rows: 0,
14372 returned_rows: 0,
14373 decoded_bytes: 0,
14374 hydrated_entities: 0,
14375 hydrated_paths: 0,
14376 })
14377 && empty_relations.work == empty_entities.work,
14378 "empty cursor hydration was not stable",
14379 )?;
14380
14381 let purpose_paths = vec![
14382 "Cargo.toml".to_string(),
14383 ".".to_string(),
14384 "src/Äuth.rs".to_string(),
14385 "src".to_string(),
14386 ];
14387 let purpose_batch = store.load_purpose_owner_nodes_by_paths_controlled(
14388 fixture.project,
14389 generation,
14390 &purpose_paths,
14391 full_budget,
14392 None,
14393 )?;
14394 require_eq(
14395 &purpose_batch
14396 .rows
14397 .iter()
14398 .map(|node| node.node.path.clone())
14399 .collect::<Vec<_>>(),
14400 &purpose_paths,
14401 "ordered purpose-owner hydration",
14402 )?;
14403 require_eq(
14404 &purpose_batch.work,
14405 &RepositoryGraphReadWork {
14406 requested_rows: 4,
14407 returned_rows: 4,
14408 decoded_bytes: purpose_batch.work.decoded_bytes,
14409 hydrated_entities: 0,
14410 hydrated_paths: 4,
14411 },
14412 "exact purpose-owner work",
14413 )?;
14414 require(
14415 purpose_batch.work.decoded_bytes > 0,
14416 "purpose-owner hydration decoded no SQLite payload bytes",
14417 )?;
14418 let exact_purpose_budget =
14419 RepositoryGraphReadBudget::new(4, 4, purpose_batch.work.decoded_bytes, 1, 4)?;
14420 require_eq(
14421 &store
14422 .load_purpose_owner_nodes_by_paths_controlled(
14423 fixture.project,
14424 generation,
14425 &purpose_paths,
14426 exact_purpose_budget,
14427 None,
14428 )?
14429 .work,
14430 &purpose_batch.work,
14431 "exact purpose-owner envelope",
14432 )?;
14433 for (budget, context) in [
14434 (
14435 RepositoryGraphReadBudget::new(4, 4, purpose_batch.work.decoded_bytes - 1, 1, 4)?,
14436 "purpose-owner decoded-byte overrun was accepted",
14437 ),
14438 (
14439 RepositoryGraphReadBudget::new(4, 4, purpose_batch.work.decoded_bytes, 1, 3)?,
14440 "purpose-owner path overrun was accepted",
14441 ),
14442 ] {
14443 let error = require_db_error(
14444 store.load_purpose_owner_nodes_by_paths_controlled(
14445 fixture.project,
14446 generation,
14447 &purpose_paths,
14448 budget,
14449 None,
14450 ),
14451 context,
14452 )?;
14453 require(
14454 matches!(error, DbError::GraphContract(_)),
14455 &format!("unexpected purpose-owner envelope error: {error}"),
14456 )?;
14457 }
14458 let mut missing_purpose_paths = purpose_paths.clone();
14459 missing_purpose_paths.push("missing/purpose-owner.rs".to_string());
14460 let missing_purpose = store.load_purpose_owner_nodes_by_paths_controlled(
14461 fixture.project,
14462 generation,
14463 &missing_purpose_paths,
14464 full_budget,
14465 None,
14466 )?;
14467 require_eq(
14468 &missing_purpose
14469 .rows
14470 .iter()
14471 .map(|node| node.node.path.clone())
14472 .collect::<Vec<_>>(),
14473 &purpose_paths,
14474 "absent purpose-owner candidate ordering",
14475 )?;
14476 require(
14477 missing_purpose.work.requested_rows == 5
14478 && missing_purpose.work.returned_rows == 4
14479 && missing_purpose.work.hydrated_paths == 4,
14480 "absent purpose-owner candidate work was not exact",
14481 )?;
14482 let duplicate_purpose = require_db_error(
14483 store.load_purpose_owner_nodes_by_paths_controlled(
14484 fixture.project,
14485 generation,
14486 &[purpose_paths[0].clone(), purpose_paths[0].clone()],
14487 full_budget,
14488 None,
14489 ),
14490 "duplicate purpose-owner paths were accepted",
14491 )?;
14492 require(
14493 matches!(duplicate_purpose, DbError::GraphContract(_)),
14494 &format!("unexpected duplicate purpose-owner error: {duplicate_purpose}"),
14495 )?;
14496 let purpose_cancellation = projectatlas_core::IndexCancellation::new();
14497 purpose_cancellation.cancel();
14498 let purpose_control = IndexWorkControl::new(purpose_cancellation, None);
14499 let cancelled_purpose = store.load_purpose_owner_nodes_by_paths_controlled(
14500 fixture.project,
14501 generation,
14502 &purpose_paths,
14503 full_budget,
14504 Some(&purpose_control),
14505 );
14506 require(
14507 matches!(
14508 cancelled_purpose,
14509 Err(DbError::IndexWork(
14510 projectatlas_core::IndexWorkFailure::Cancelled {
14511 stage: IndexWorkStage::RepositoryTraversal
14512 }
14513 ))
14514 ),
14515 "purpose-owner cancellation was not typed",
14516 )?;
14517
14518 let occurrence_batch = store.repository_graph_occurrence_pages_bounded(
14519 &fixture.relations,
14520 1,
14521 full_budget,
14522 None,
14523 )?;
14524 require(
14525 occurrence_batch.pages.len() == fixture.relations.len()
14526 && occurrence_batch.pages[0].rows.len() == 1
14527 && occurrence_batch.pages[0].truncated
14528 && occurrence_batch.pages[1..]
14529 .iter()
14530 .all(|page| page.rows.is_empty() && !page.truncated),
14531 "batched occurrence pages lost owner order or truncation state",
14532 )?;
14533 require_eq(
14534 &occurrence_batch.work,
14535 &RepositoryGraphReadWork {
14536 requested_rows: 4,
14537 returned_rows: 1,
14538 decoded_bytes: occurrence_batch.work.decoded_bytes,
14539 hydrated_entities: 0,
14540 hydrated_paths: 2,
14541 },
14542 "exact occurrence batch work including sentinel path",
14543 )?;
14544 let exact_occurrence_budget =
14545 RepositoryGraphReadBudget::new(4, 1, occurrence_batch.work.decoded_bytes, 1, 2)?;
14546 require_eq(
14547 &store.repository_graph_occurrence_pages_bounded(
14548 &fixture.relations,
14549 1,
14550 exact_occurrence_budget,
14551 None,
14552 )?,
14553 &occurrence_batch,
14554 "exact occurrence envelope",
14555 )?;
14556 require_eq(
14557 &store.repository_graph_occurrence_pages(&fixture.relations, 1, None)?,
14558 &occurrence_batch.pages,
14559 "legacy occurrence wrapper compatibility",
14560 )?;
14561 for (budget, limit, context) in [
14562 (
14563 RepositoryGraphReadBudget::new(
14564 4,
14565 1,
14566 occurrence_batch.work.decoded_bytes - 1,
14567 1,
14568 2,
14569 )?,
14570 1,
14571 "occurrence decoded-byte overrun was accepted",
14572 ),
14573 (
14574 RepositoryGraphReadBudget::new(4, 1, occurrence_batch.work.decoded_bytes, 1, 1)?,
14575 1,
14576 "occurrence sentinel path overrun was accepted",
14577 ),
14578 (
14579 RepositoryGraphReadBudget::new(
14580 4,
14581 1,
14582 RepositoryGraphReadBudget::MAX_DECODED_BYTES,
14583 1,
14584 RepositoryGraphReadBudget::MAX_HYDRATED_PATHS,
14585 )?,
14586 3,
14587 "occurrence returned-row overrun was accepted",
14588 ),
14589 ] {
14590 let error = require_db_error(
14591 store.repository_graph_occurrence_pages_bounded(
14592 &fixture.relations,
14593 limit,
14594 budget,
14595 None,
14596 ),
14597 context,
14598 )?;
14599 require(
14600 matches!(error, DbError::GraphContract(_)),
14601 &format!("unexpected occurrence envelope error: {error}"),
14602 )?;
14603 }
14604 let occurrence_cancellation = projectatlas_core::IndexCancellation::new();
14605 occurrence_cancellation.cancel();
14606 let occurrence_control = IndexWorkControl::new(occurrence_cancellation, None);
14607 let cancelled_occurrences = store.repository_graph_occurrence_pages_bounded(
14608 &fixture.relations,
14609 1,
14610 full_budget,
14611 Some(&occurrence_control),
14612 );
14613 require(
14614 matches!(
14615 cancelled_occurrences,
14616 Err(DbError::IndexWork(
14617 projectatlas_core::IndexWorkFailure::Cancelled {
14618 stage: IndexWorkStage::RepositoryTraversal
14619 }
14620 ))
14621 ),
14622 "occurrence batch cancellation was not typed",
14623 )?;
14624 let expired_occurrences = IndexWorkControl::with_deadline(
14625 projectatlas_core::IndexCancellation::new(),
14626 Instant::now(),
14627 );
14628 let occurrence_deadline = store.repository_graph_occurrence_pages_bounded(
14629 &fixture.relations,
14630 1,
14631 full_budget,
14632 Some(&expired_occurrences),
14633 );
14634 require(
14635 matches!(
14636 occurrence_deadline,
14637 Err(DbError::IndexWork(
14638 projectatlas_core::IndexWorkFailure::DeadlineExceeded {
14639 stage: IndexWorkStage::RepositoryTraversal
14640 }
14641 ))
14642 ),
14643 "occurrence batch deadline was not typed",
14644 )?;
14645
14646 let coverage_paths = vec![
14647 RepositoryNodePath::new(Path::new("src/Äuth.rs"))?,
14648 RepositoryNodePath::new(Path::new("Cargo.toml"))?,
14649 ];
14650 let coverage_batch = store.repository_graph_path_coverage_bounded(
14651 fixture.project,
14652 generation,
14653 &coverage_paths,
14654 full_budget,
14655 None,
14656 )?;
14657 require_eq(
14658 &coverage_batch
14659 .page
14660 .rows
14661 .iter()
14662 .map(|coverage| match coverage.scope() {
14663 CoverageScope::Path { path } => path.as_str().to_string(),
14664 CoverageScope::Project => "project".to_string(),
14665 })
14666 .collect::<Vec<_>>(),
14667 &vec!["Cargo.toml".to_string(), "src/Äuth.rs".to_string()],
14668 "stable path coverage order",
14669 )?;
14670 require(
14671 !coverage_batch.page.truncated
14672 && coverage_batch.work
14673 == (RepositoryGraphReadWork {
14674 requested_rows: 2,
14675 returned_rows: 2,
14676 decoded_bytes: coverage_batch.work.decoded_bytes,
14677 hydrated_entities: 0,
14678 hydrated_paths: 2,
14679 })
14680 && coverage_batch.work.decoded_bytes > 0,
14681 "exact coverage batch work was incomplete",
14682 )?;
14683 let exact_coverage_budget =
14684 RepositoryGraphReadBudget::new(2, 2, coverage_batch.work.decoded_bytes, 1, 2)?;
14685 require_eq(
14686 &store.repository_graph_path_coverage_bounded(
14687 fixture.project,
14688 generation,
14689 &coverage_paths,
14690 exact_coverage_budget,
14691 None,
14692 )?,
14693 &coverage_batch,
14694 "exact coverage envelope",
14695 )?;
14696 require_eq(
14697 &store.repository_graph_path_coverage(fixture.project, &coverage_paths, None)?,
14698 &coverage_batch.page,
14699 "legacy coverage wrapper compatibility",
14700 )?;
14701 for (budget, context) in [
14702 (
14703 RepositoryGraphReadBudget::new(2, 2, coverage_batch.work.decoded_bytes - 1, 1, 2)?,
14704 "coverage decoded-byte overrun was accepted",
14705 ),
14706 (
14707 RepositoryGraphReadBudget::new(2, 1, coverage_batch.work.decoded_bytes, 1, 2)?,
14708 "coverage returned-row overrun was accepted",
14709 ),
14710 (
14711 RepositoryGraphReadBudget::new(2, 2, coverage_batch.work.decoded_bytes, 1, 1)?,
14712 "coverage hydrated-path overrun was accepted",
14713 ),
14714 ] {
14715 let error = require_db_error(
14716 store.repository_graph_path_coverage_bounded(
14717 fixture.project,
14718 generation,
14719 &coverage_paths,
14720 budget,
14721 None,
14722 ),
14723 context,
14724 )?;
14725 require(
14726 matches!(error, DbError::GraphContract(_)),
14727 &format!("unexpected coverage envelope error: {error}"),
14728 )?;
14729 }
14730 let coverage_cancellation = projectatlas_core::IndexCancellation::new();
14731 coverage_cancellation.cancel();
14732 let coverage_control = IndexWorkControl::new(coverage_cancellation, None);
14733 let cancelled_coverage = store.repository_graph_path_coverage_bounded(
14734 fixture.project,
14735 generation,
14736 &coverage_paths,
14737 full_budget,
14738 Some(&coverage_control),
14739 );
14740 require(
14741 matches!(
14742 cancelled_coverage,
14743 Err(DbError::IndexWork(
14744 projectatlas_core::IndexWorkFailure::Cancelled {
14745 stage: IndexWorkStage::RepositoryTraversal
14746 }
14747 ))
14748 ),
14749 "coverage batch cancellation was not typed",
14750 )?;
14751 let expired_coverage = IndexWorkControl::with_deadline(
14752 projectatlas_core::IndexCancellation::new(),
14753 Instant::now(),
14754 );
14755 let coverage_deadline = store.repository_graph_path_coverage_bounded(
14756 fixture.project,
14757 generation,
14758 &coverage_paths,
14759 full_budget,
14760 Some(&expired_coverage),
14761 );
14762 require(
14763 matches!(
14764 coverage_deadline,
14765 Err(DbError::IndexWork(
14766 projectatlas_core::IndexWorkFailure::DeadlineExceeded {
14767 stage: IndexWorkStage::RepositoryTraversal
14768 }
14769 ))
14770 ),
14771 "coverage batch deadline was not typed",
14772 )?;
14773
14774 let missing = [0xff; 32];
14775 let missing_entity = require_db_error(
14776 store.repository_graph_entities_by_digest(
14777 fixture.project,
14778 generation,
14779 &[entity_digests[0], missing],
14780 full_budget,
14781 None,
14782 ),
14783 "missing entity cursor key returned a partial set",
14784 )?;
14785 require(
14786 matches!(
14787 missing_entity,
14788 DbError::GraphRowShape {
14789 table: "graph_entities",
14790 ..
14791 }
14792 ),
14793 &format!("unexpected missing-entity error: {missing_entity}"),
14794 )?;
14795 let missing_relation = require_db_error(
14796 store.repository_graph_relation_rows_by_digest(
14797 fixture.project,
14798 generation,
14799 &[relation_digests[0], missing],
14800 full_budget,
14801 None,
14802 ),
14803 "missing relation cursor key returned a partial set",
14804 )?;
14805 require(
14806 matches!(
14807 missing_relation,
14808 DbError::GraphRowShape {
14809 table: "graph_relations",
14810 ..
14811 }
14812 ),
14813 &format!("unexpected missing-relation error: {missing_relation}"),
14814 )?;
14815
14816 let duplicate = require_db_error(
14817 store.repository_graph_entities_by_digest(
14818 fixture.project,
14819 generation,
14820 &[entity_digests[0], entity_digests[0]],
14821 full_budget,
14822 None,
14823 ),
14824 "duplicate entity cursor keys were accepted",
14825 )?;
14826 require(
14827 matches!(duplicate, DbError::GraphContract(_)),
14828 &format!("unexpected duplicate hydration error: {duplicate}"),
14829 )?;
14830 let oversized = vec![[0; 32]; MAX_REPOSITORY_GRAPH_FRONTIER + 1];
14831 let oversized = require_db_error(
14832 store.repository_graph_relation_rows_by_digest(
14833 fixture.project,
14834 generation,
14835 &oversized,
14836 full_budget,
14837 None,
14838 ),
14839 "oversized relation cursor key set was accepted",
14840 )?;
14841 require(
14842 matches!(oversized, DbError::GraphContract(_)),
14843 &format!("unexpected oversized hydration error: {oversized}"),
14844 )?;
14845
14846 let foreign_project = ProjectInstanceId::from_bytes([0x7f; 16])?;
14847 let foreign = require_db_error(
14848 store.repository_graph_entities_by_digest(
14849 foreign_project,
14850 generation,
14851 &entity_digests,
14852 full_budget,
14853 None,
14854 ),
14855 "cross-project entity cursor hydration was accepted",
14856 )?;
14857 require(
14858 matches!(foreign, DbError::GraphProjectIdentityMismatch { .. }),
14859 &format!("unexpected cross-project hydration error: {foreign}"),
14860 )?;
14861 let stale_generation = generation
14862 .checked_next()
14863 .ok_or_else(|| io::Error::other("fixture generation overflowed"))?;
14864 let stale = require_db_error(
14865 store.repository_graph_relation_rows_by_digest(
14866 fixture.project,
14867 stale_generation,
14868 &relation_digests,
14869 full_budget,
14870 None,
14871 ),
14872 "stale-generation relation cursor hydration was accepted",
14873 )?;
14874 require(
14875 matches!(stale, DbError::GraphContract(_)),
14876 &format!("unexpected stale-generation hydration error: {stale}"),
14877 )?;
14878 for entities in [true, false] {
14879 let cancellation = projectatlas_core::IndexCancellation::new();
14880 cancellation.cancel();
14881 let control = IndexWorkControl::new(cancellation, None);
14882 let cancelled = if entities {
14883 store
14884 .repository_graph_entities_by_digest(
14885 fixture.project,
14886 generation,
14887 &entity_digests,
14888 full_budget,
14889 Some(&control),
14890 )
14891 .map(|_| ())
14892 } else {
14893 store
14894 .repository_graph_relation_rows_by_digest(
14895 fixture.project,
14896 generation,
14897 &relation_digests,
14898 full_budget,
14899 Some(&control),
14900 )
14901 .map(|_| ())
14902 };
14903 require(
14904 matches!(
14905 cancelled,
14906 Err(DbError::IndexWork(
14907 projectatlas_core::IndexWorkFailure::Cancelled {
14908 stage: IndexWorkStage::RepositoryTraversal
14909 }
14910 ))
14911 ),
14912 "cursor hydration cancellation was not typed",
14913 )?;
14914 }
14915 let expired = IndexWorkControl::with_deadline(
14916 projectatlas_core::IndexCancellation::new(),
14917 Instant::now(),
14918 );
14919 let deadline = store.repository_graph_entities_by_digest(
14920 fixture.project,
14921 generation,
14922 &entity_digests,
14923 full_budget,
14924 Some(&expired),
14925 );
14926 require(
14927 matches!(
14928 deadline,
14929 Err(DbError::IndexWork(
14930 projectatlas_core::IndexWorkFailure::DeadlineExceeded {
14931 stage: IndexWorkStage::RepositoryTraversal
14932 }
14933 ))
14934 ),
14935 "cursor hydration deadline was not typed",
14936 )?;
14937
14938 let source = fixture
14939 .entities
14940 .iter()
14941 .find(|entity| matches!(entity.selector(), EntitySelector::File { .. }))
14942 .ok_or_else(|| io::Error::other("source file fixture missing"))?;
14943 let adjacency = store.repository_graph_adjacency_page_bounded(
14944 &[source.key().clone()],
14945 RepositoryGraphDirection::Outbound,
14946 None,
14947 1,
14948 full_budget,
14949 None,
14950 )?;
14951 require(
14952 adjacency.page.rows.len() == 1
14953 && adjacency.page.truncated
14954 && adjacency.work.requested_rows == 1
14955 && adjacency.work.returned_rows == 1
14956 && adjacency.work.decoded_bytes > 0
14957 && adjacency.work.hydrated_entities > 0
14958 && adjacency.work.hydrated_paths > 0,
14959 "bounded adjacency page omitted exact raw or endpoint work",
14960 )?;
14961 let exact_adjacency_budget = RepositoryGraphReadBudget::new(
14962 1,
14963 1,
14964 adjacency.work.decoded_bytes,
14965 adjacency.work.hydrated_entities,
14966 adjacency.work.hydrated_paths,
14967 )?;
14968 require_eq(
14969 &store.repository_graph_adjacency_page_bounded(
14970 &[source.key().clone()],
14971 RepositoryGraphDirection::Outbound,
14972 None,
14973 1,
14974 exact_adjacency_budget,
14975 None,
14976 )?,
14977 &adjacency,
14978 "exact adjacency envelope",
14979 )?;
14980 let adjacency_overrun = require_db_error(
14981 store.repository_graph_adjacency_page_bounded(
14982 &[source.key().clone()],
14983 RepositoryGraphDirection::Outbound,
14984 None,
14985 1,
14986 RepositoryGraphReadBudget::new(
14987 1,
14988 1,
14989 adjacency.work.decoded_bytes - 1,
14990 adjacency.work.hydrated_entities,
14991 adjacency.work.hydrated_paths,
14992 )?,
14993 None,
14994 ),
14995 "adjacency decoded-byte overrun was accepted",
14996 )?;
14997 require(
14998 matches!(adjacency_overrun, DbError::GraphContract(_)),
14999 &format!("unexpected adjacency envelope error: {adjacency_overrun}"),
15000 )?;
15001 let adjacency_return_limit = require_db_error(
15002 store.repository_graph_adjacency_page_bounded(
15003 &[source.key().clone()],
15004 RepositoryGraphDirection::Outbound,
15005 None,
15006 2,
15007 exact_adjacency_budget,
15008 None,
15009 ),
15010 "adjacency page limit exceeded the return budget",
15011 )?;
15012 require(
15013 matches!(adjacency_return_limit, DbError::GraphContract(_)),
15014 &format!("unexpected adjacency return-budget error: {adjacency_return_limit}"),
15015 )?;
15016 let continuation = adjacency
15017 .page
15018 .continuation
15019 .ok_or_else(|| io::Error::other("adjacency continuation missing"))?;
15020 let encoded =
15021 serde_json::to_vec(&(RepositoryGraphDirection::Outbound, continuation.clone()))?;
15022 let decoded: (
15023 RepositoryGraphDirection,
15024 RepositoryGraphAdjacencyContinuation,
15025 ) = serde_json::from_slice(&encoded)?;
15026 require_eq(
15027 &decoded,
15028 &(RepositoryGraphDirection::Outbound, continuation),
15029 "opaque relation cursor serde round trip",
15030 )?;
15031
15032 assert_cursor_hydration_indexes(&store)?;
15033 store.finish_index_read_snapshot()?;
15034 Ok(())
15035 }
15036
15037 #[test]
15038 fn continued_adjacency_seeks_past_high_degree_prefixes() -> Result<(), Box<dyn Error>> {
15039 let temp = tempfile::tempdir()?;
15040 let project_root = temp.path().join("continued-adjacency-work");
15041 let atlas_dir = project_root.join(".projectatlas");
15042 fs::create_dir_all(&atlas_dir)?;
15043 let db_path = atlas_dir.join("projectatlas.db");
15044 let mut writer = AtlasStore::open_for_project(&db_path, &project_root)?;
15045 let fixture = publish_fixture(&mut writer, "continued-adjacency-work")?;
15046 let source = fixture
15047 .entities
15048 .iter()
15049 .find(|entity| matches!(entity.selector(), EntitySelector::File { .. }))
15050 .ok_or_else(|| io::Error::other("source file fixture missing"))?;
15051 let external = fixture
15052 .entities
15053 .iter()
15054 .find(|entity| matches!(entity.selector(), EntitySelector::External { .. }))
15055 .ok_or_else(|| io::Error::other("external fixture missing"))?;
15056 writer.connection.execute(
15057 "WITH RECURSIVE sequence(value) AS (
15058 VALUES(1)
15059 UNION ALL
15060 SELECT value + 1 FROM sequence WHERE value < 100000
15061 )
15062 INSERT INTO graph_relations(
15063 relation_key, project_instance_id, canonical_identity,
15064 source_entity_key, relation_scope, relation_kind,
15065 resolution_status, target_entity_key, reference_text,
15066 candidate_count, confidence, completeness
15067 )
15068 SELECT CAST(printf('%032d', 1000000 + value) AS BLOB),
15069 ?1, printf('perf-%06d', value), ?2,
15070 'legacy', 'calls', 'resolved', ?3, NULL, NULL,
15071 'exact', 'complete'
15072 FROM sequence",
15073 params![
15074 fixture.project.as_bytes().as_slice(),
15075 source.key().digest_bytes()?.as_slice(),
15076 external.key().digest_bytes()?.as_slice(),
15077 ],
15078 )?;
15079 drop(writer);
15080
15081 let store = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
15082 let generation = store
15083 .repository_graph_generation()?
15084 .ok_or_else(|| io::Error::other("graph generation missing"))?;
15085 let source_digest = source.key().digest_bytes()?;
15086 let external_digest = external.key().digest_bytes()?;
15087 let skipped_frontier = vec![source.key().clone(), external.key().clone()];
15088 let skipped_continuation = RepositoryGraphAdjacencyContinuation {
15089 project: fixture.project,
15090 generation,
15091 direction: RepositoryGraphDirection::Outbound,
15092 relation: None,
15093 resolved_only: false,
15094 include_documents: false,
15095 frontier: vec![source_digest, external_digest],
15096 frontier_index: 1,
15097 relation_scope: String::new(),
15098 relation_kind: String::new(),
15099 relation_key: [0; 32],
15100 };
15101 let skipped_steps = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
15102 let skipped_counter = std::sync::Arc::clone(&skipped_steps);
15103 store.connection.progress_handler(
15104 1_000,
15105 Some(move || {
15106 skipped_counter.fetch_add(1_000, std::sync::atomic::Ordering::Relaxed);
15107 false
15108 }),
15109 )?;
15110 let skipped_result = store.repository_graph_adjacency_page(
15111 &skipped_frontier,
15112 RepositoryGraphDirection::Outbound,
15113 Some(&skipped_continuation),
15114 2,
15115 None,
15116 );
15117 store.connection.progress_handler(0, None::<fn() -> bool>)?;
15118 skipped_result?;
15119 require(
15120 skipped_steps.load(std::sync::atomic::Ordering::Relaxed) < 100_000,
15121 "continued adjacency scanned a completed high-degree frontier branch",
15122 )?;
15123
15124 let last_key: [u8; 32] = format!("{:032}", 1_100_000)
15125 .into_bytes()
15126 .try_into()
15127 .map_err(|_source| io::Error::other("high-degree cursor key width changed"))?;
15128 let deep_continuation = RepositoryGraphAdjacencyContinuation {
15129 project: fixture.project,
15130 generation,
15131 direction: RepositoryGraphDirection::Outbound,
15132 relation: None,
15133 resolved_only: false,
15134 include_documents: false,
15135 frontier: vec![source_digest],
15136 frontier_index: 0,
15137 relation_scope: "legacy".to_string(),
15138 relation_kind: "calls".to_string(),
15139 relation_key: last_key,
15140 };
15141 let deep_steps = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
15142 let deep_counter = std::sync::Arc::clone(&deep_steps);
15143 store.connection.progress_handler(
15144 1_000,
15145 Some(move || {
15146 deep_counter.fetch_add(1_000, std::sync::atomic::Ordering::Relaxed);
15147 false
15148 }),
15149 )?;
15150 let deep_result = store.repository_graph_adjacency_page(
15151 &[source.key().clone()],
15152 RepositoryGraphDirection::Outbound,
15153 Some(&deep_continuation),
15154 2,
15155 None,
15156 );
15157 store.connection.progress_handler(0, None::<fn() -> bool>)?;
15158 deep_result?;
15159 require(
15160 deep_steps.load(std::sync::atomic::Ordering::Relaxed) < 100_000,
15161 "continued adjacency rescanned a high-degree keyset prefix",
15162 )?;
15163 store.finish_index_read_snapshot()?;
15164 Ok(())
15165 }
15166
15167 #[test]
15168 fn bounded_rust_frontier_matches_indexed_recursive_cte_on_cycles_and_high_degree()
15169 -> Result<(), Box<dyn Error>> {
15170 const HIGH_DEGREE: usize = 4_096;
15171 const RECURSIVE_CTE: &str = "WITH RECURSIVE walk(entity_key) AS (
15172 VALUES(?2)
15173 UNION
15174 SELECT relation.target_entity_key
15175 FROM graph_relations AS relation INDEXED BY idx_graph_relations_source_kind
15176 JOIN walk ON relation.source_entity_key = walk.entity_key
15177 WHERE relation.project_instance_id = ?1
15178 AND relation.relation_scope = 'legacy'
15179 AND relation.relation_kind = 'calls'
15180 AND relation.target_entity_key IS NOT NULL
15181 )
15182 SELECT entity_key FROM walk ORDER BY entity_key";
15183
15184 let temp = tempfile::tempdir()?;
15185 let project_root = temp.path().join("frontier-cte-comparison");
15186 let atlas_dir = project_root.join(".projectatlas");
15187 fs::create_dir_all(&atlas_dir)?;
15188 let db_path = atlas_dir.join("projectatlas.db");
15189 let mut writer = AtlasStore::open_for_project(&db_path, &project_root)?;
15190 let fixture = publish_fixture(&mut writer, "frontier-cte-comparison")?;
15191 let source = fixture
15192 .entities
15193 .iter()
15194 .find(|entity| matches!(entity.selector(), EntitySelector::File { .. }))
15195 .ok_or_else(|| io::Error::other("source file fixture missing"))?;
15196 let generation = source.generation();
15197 let mut high_degree_entities = Vec::with_capacity(HIGH_DEGREE);
15198 let mut cyclic_relations = Vec::with_capacity(HIGH_DEGREE * 2);
15199 for index in 0..HIGH_DEGREE {
15200 let target = GraphEntity::new(
15201 fixture.project,
15202 EntitySelector::External {
15203 external: ExternalSelector {
15204 system: GraphIdentityText::new("frontier-measurement")?,
15205 identity: GraphIdentityText::new(format!("node-{index:05}"))?,
15206 },
15207 },
15208 generation,
15209 )?;
15210 cyclic_relations.push(LogicalRelation::new(
15211 source,
15212 GraphRelationKind::Legacy(RelationKind::Calls),
15213 RelationResolution::external(&target)?,
15214 ConfidenceClass::Exact,
15215 Completeness::Complete,
15216 generation,
15217 )?);
15218 cyclic_relations.push(LogicalRelation::new(
15219 &target,
15220 GraphRelationKind::Legacy(RelationKind::Calls),
15221 RelationResolution::resolved(source)?,
15222 ConfidenceClass::Exact,
15223 Completeness::Complete,
15224 generation,
15225 )?);
15226 high_degree_entities.push(target);
15227 }
15228 let transaction = writer.connection.transaction()?;
15229 insert_entities(&transaction, fixture.project, &high_degree_entities)?;
15230 insert_relations(&transaction, fixture.project, &cyclic_relations)?;
15231 transaction.commit()?;
15232 drop(writer);
15233
15234 let store = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
15235 let project = fixture.project.as_bytes();
15236 let source_key = source.key().digest_bytes()?;
15237 let plan_sql = format!("EXPLAIN QUERY PLAN {RECURSIVE_CTE}");
15238 let cte_plan = store
15239 .connection
15240 .prepare(&plan_sql)?
15241 .query_map(params![&project[..], &source_key[..]], |row| {
15242 row.get::<_, String>(3)
15243 })?
15244 .collect::<Result<Vec<_>, _>>()?;
15245 require(
15246 cte_plan
15247 .iter()
15248 .any(|detail| detail.contains("idx_graph_relations_source_kind"))
15249 && cte_plan
15250 .iter()
15251 .all(|detail| !detail.contains("SCAN relation")),
15252 &format!("recursive CTE did not retain the source-owned index: {cte_plan:?}"),
15253 )?;
15254
15255 let cte_steps = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
15256 let cte_counter = std::sync::Arc::clone(&cte_steps);
15257 store.connection.progress_handler(
15258 1_000,
15259 Some(move || {
15260 cte_counter.fetch_add(1_000, std::sync::atomic::Ordering::Relaxed);
15261 false
15262 }),
15263 )?;
15264 let cte_started = Instant::now();
15265 let cte_result = store
15266 .connection
15267 .prepare(RECURSIVE_CTE)?
15268 .query_map(params![&project[..], &source_key[..]], |row| {
15269 row.get::<_, Vec<u8>>(0)
15270 })?
15271 .map(|row| fixed_bytes::<32>("recursive_cte.entity_key", row?))
15272 .collect::<DbResult<Vec<_>>>();
15273 let cte_elapsed = cte_started.elapsed();
15274 store.connection.progress_handler(0, None::<fn() -> bool>)?;
15275 let cte_nodes = cte_result?;
15276
15277 let rust_steps = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
15278 let rust_counter = std::sync::Arc::clone(&rust_steps);
15279 store.connection.progress_handler(
15280 1_000,
15281 Some(move || {
15282 rust_counter.fetch_add(1_000, std::sync::atomic::Ordering::Relaxed);
15283 false
15284 }),
15285 )?;
15286 let rust_started = Instant::now();
15287 let rust_result = collect_bounded_outbound_calls(&store, source.key());
15288 let rust_elapsed = rust_started.elapsed();
15289 store.connection.progress_handler(0, None::<fn() -> bool>)?;
15290 let (rust_nodes, inspected_edges, peak_frontier) = rust_result?;
15291 let (repeated_nodes, repeated_edges, repeated_peak) =
15292 collect_bounded_outbound_calls(&store, source.key())?;
15293
15294 require_eq(
15295 &rust_nodes,
15296 &cte_nodes,
15297 "Rust frontier versus recursive CTE topology",
15298 )?;
15299 require_eq(
15300 &repeated_nodes,
15301 &rust_nodes,
15302 "deterministic Rust frontier order",
15303 )?;
15304 require_eq(
15305 &repeated_edges,
15306 &inspected_edges,
15307 "deterministic inspected edges",
15308 )?;
15309 require_eq(
15310 &repeated_peak,
15311 &peak_frontier,
15312 "deterministic peak frontier",
15313 )?;
15314 require_eq(
15315 &rust_nodes.len(),
15316 &(HIGH_DEGREE + 2),
15317 "cycle-safe high-degree topology",
15318 )?;
15319 require_eq(
15320 &inspected_edges,
15321 &(HIGH_DEGREE * 2 + 1),
15322 "bounded frontier inspected every call edge once",
15323 )?;
15324 require(
15325 cte_steps.load(std::sync::atomic::Ordering::Relaxed) > 0
15326 && rust_steps.load(std::sync::atomic::Ordering::Relaxed) > 0
15327 && cte_elapsed > Duration::ZERO
15328 && rust_elapsed > Duration::ZERO,
15329 "frontier comparison did not record VM work and elapsed time",
15330 )?;
15331 let retained_key_bytes = rust_nodes
15332 .len()
15333 .checked_add(peak_frontier)
15334 .and_then(|keys| keys.checked_mul(std::mem::size_of::<[u8; 32]>()))
15335 .ok_or_else(|| io::Error::other("retained key measurement overflowed"))?;
15336 let output_key_bytes = rust_nodes
15337 .len()
15338 .checked_mul(std::mem::size_of::<[u8; 32]>())
15339 .ok_or_else(|| io::Error::other("output key measurement overflowed"))?;
15340 require(
15341 retained_key_bytes <= GraphLimits::MAX_OUTPUT_BYTES as usize
15342 && output_key_bytes <= GraphLimits::MAX_OUTPUT_BYTES as usize,
15343 "bounded Rust frontier exceeded the shared compact byte ceiling",
15344 )?;
15345
15346 store.connection.progress_handler(1, Some(|| true))?;
15347 let cancelled_cte = (|| -> Result<Vec<Vec<u8>>, rusqlite::Error> {
15348 let mut statement = store.connection.prepare(RECURSIVE_CTE)?;
15349 statement
15350 .query_map(params![&project[..], &source_key[..]], |row| {
15351 row.get::<_, Vec<u8>>(0)
15352 })?
15353 .collect()
15354 })();
15355 store.connection.progress_handler(0, None::<fn() -> bool>)?;
15356 require(
15357 matches!(
15358 cancelled_cte,
15359 Err(rusqlite::Error::SqliteFailure(ref failure, _))
15360 if failure.code == rusqlite::ErrorCode::OperationInterrupted
15361 ),
15362 "recursive CTE did not stop through the SQLite progress handler",
15363 )?;
15364 let cancellation = projectatlas_core::IndexCancellation::new();
15365 cancellation.cancel();
15366 let control = IndexWorkControl::new(cancellation, None);
15367 let cancelled_rust = store.repository_graph_adjacency_page_filtered(
15368 &[source.key().clone()],
15369 RepositoryGraphDirection::Outbound,
15370 Some(GraphRelationKind::Legacy(RelationKind::Calls)),
15371 None,
15372 1,
15373 Some(&control),
15374 );
15375 require(
15376 matches!(
15377 cancelled_rust,
15378 Err(DbError::IndexWork(
15379 projectatlas_core::IndexWorkFailure::Cancelled {
15380 stage: IndexWorkStage::RepositoryTraversal
15381 }
15382 ))
15383 ),
15384 "bounded Rust frontier did not retain typed cancellation",
15385 )?;
15386 store.finish_index_read_snapshot()?;
15387 Ok(())
15388 }
15389
15390 #[cfg(feature = "sqlite-progress-test-observer")]
15391 #[test]
15392 fn zero_candidate_coverage_discovery_is_cancellable_at_scale() -> Result<(), Box<dyn Error>> {
15393 use crate::sqlite_progress_test_observer::{
15394 SqliteReadProgressEvent, observe_sqlite_read_progress,
15395 };
15396 use std::cell::Cell;
15397 use std::rc::Rc;
15398
15399 const POSITIVE_DOCUMENT_ROWS: usize = 100_000;
15400
15401 let temp = tempfile::tempdir()?;
15402 let project_root = temp.path().join("coverage-zero-candidate-scale");
15403 let atlas_dir = project_root.join(".projectatlas");
15404 fs::create_dir_all(&atlas_dir)?;
15405 let db_path = atlas_dir.join("projectatlas.db");
15406 let mut writer = AtlasStore::open_for_project(&db_path, &project_root)?;
15407 let fixture = publish_fixture(&mut writer, "coverage-zero-candidate-scale")?;
15408 writer.connection.execute(
15409 "DELETE FROM graph_coverage
15410 WHERE relation_scope = 'extended'
15411 AND relation_kind = 'documents'
15412 AND state = 'complete' AND total = 0",
15413 [],
15414 )?;
15415 writer.connection.execute(
15416 "WITH RECURSIVE sequence(value) AS (
15417 VALUES(1)
15418 UNION ALL
15419 SELECT value + 1 FROM sequence WHERE value < ?2
15420 )
15421 INSERT INTO graph_coverage(
15422 project_instance_id, scope_kind, scope_path,
15423 relation_scope, relation_kind, state,
15424 total, covered, omitted, reason, reached_limit
15425 )
15426 SELECT ?1, 'path', printf('src/perf-%06d.rs', value),
15427 'extended', 'documents', 'complete',
15428 1, 1, 0, NULL, NULL
15429 FROM sequence",
15430 params![
15431 fixture.project.as_bytes().as_slice(),
15432 i64::try_from(POSITIVE_DOCUMENT_ROWS)?,
15433 ],
15434 )?;
15435 writer.connection.execute(
15436 "INSERT INTO graph_coverage(
15437 project_instance_id, scope_kind, scope_path,
15438 relation_scope, relation_kind, state,
15439 total, covered, omitted, reason, reached_limit
15440 ) VALUES (?1, 'path', 'docs/empty.md',
15441 'extended', 'documents', 'complete',
15442 0, 0, 0, NULL, NULL)",
15443 params![fixture.project.as_bytes().as_slice()],
15444 )?;
15445 drop(writer);
15446
15447 let store = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
15448 let cancellation = projectatlas_core::IndexCancellation::new();
15449 let control = IndexWorkControl::new(cancellation.clone(), None);
15450 let inspected_steps = Rc::new(Cell::new(0_usize));
15451 let page = observe_sqlite_read_progress(
15452 {
15453 let inspected_steps = Rc::clone(&inspected_steps);
15454 move |event| {
15455 if matches!(
15456 event,
15457 SqliteReadProgressEvent::CallbackEntered {
15458 stage: IndexWorkStage::RepositoryTraversal
15459 }
15460 ) {
15461 inspected_steps.set(inspected_steps.get().saturating_add(1_000));
15462 cancellation.cancel();
15463 }
15464 }
15465 },
15466 || {
15467 store.repository_coverage_page_controlled(
15468 fixture.project,
15469 &RepositoryCoverageQuery {
15470 start_index: 0,
15471 limit: 1,
15472 path_prefix: None,
15473 parser: None,
15474 provider: None,
15475 relation: None,
15476 state: Some(CoverageState::NoCandidates),
15477 reason: None,
15478 },
15479 Some(&control),
15480 )
15481 },
15482 );
15483 require(
15484 matches!(
15485 page,
15486 Err(DbError::IndexWork(
15487 projectatlas_core::IndexWorkFailure::Cancelled {
15488 stage: IndexWorkStage::RepositoryTraversal
15489 }
15490 ))
15491 ),
15492 "scaled zero-candidate discovery did not propagate cancellation",
15493 )?;
15494 require(
15495 inspected_steps.get() <= 1_000,
15496 "zero-candidate discovery exceeded one SQLite progress interval before cancellation",
15497 )?;
15498 store.finish_index_read_snapshot()?;
15499 Ok(())
15500 }
15501
15502 #[test]
15503 fn coverage_discovery_filters_provenance_and_fails_closed_after_reopen()
15504 -> Result<(), Box<dyn Error>> {
15505 let temp = tempfile::tempdir()?;
15506 let project_root = temp.path().join("coverage-discovery");
15507 fs::create_dir_all(&project_root)?;
15508 let db_path = project_root.join("projectatlas.db");
15509 let mut writer = AtlasStore::open_for_project(&db_path, &project_root)?;
15510 let fixture = publish_fixture(&mut writer, "coverage-discovery")?;
15511 let sibling_path = "src/Äuth.rs.backup";
15512 let sibling_coverage = CoverageRecord::new(
15513 CoverageScope::Path {
15514 path: RepositoryNodePath::new(Path::new(sibling_path))?,
15515 },
15516 None,
15517 CoverageState::Complete,
15518 1,
15519 0,
15520 IndexGeneration::new(2),
15521 None,
15522 None,
15523 )?;
15524 let mut publication = writer.begin_index_publication("coverage-discovery-sibling")?;
15525 publication.replace_repository_graph_for_paths(
15526 fixture.project,
15527 &[sibling_path.to_string()],
15528 &[],
15529 &[],
15530 &[],
15531 &[sibling_coverage],
15532 )?;
15533 publication.complete()?;
15534 drop(writer);
15535
15536 let store = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
15537 let project_value = Value::Blob(fixture.project.as_bytes().to_vec());
15538 assert_coverage_discovery_plan(
15539 &store.connection,
15540 "EXPLAIN QUERY PLAN
15541 SELECT coverage.id FROM graph_coverage AS coverage
15542 WHERE coverage.project_instance_id = ?
15543 AND coverage.scope_kind = 'path'
15544 AND coverage.scope_path >= ? AND coverage.scope_path < ?
15545 AND (coverage.scope_path = ? OR coverage.scope_path >= ?)
15546 ORDER BY coverage.scope_path, coverage.relation_scope, coverage.relation_kind,
15547 coverage.state, coverage.id LIMIT 11",
15548 &[
15549 project_value.clone(),
15550 Value::Text("src/Äuth.rs".to_string()),
15551 Value::Text("src/Äuth.rs0".to_string()),
15552 Value::Text("src/Äuth.rs".to_string()),
15553 Value::Text("src/Äuth.rs/".to_string()),
15554 ],
15555 &["idx_graph_coverage_scope_order"],
15556 false,
15557 "coverage path filter",
15558 )?;
15559 assert_coverage_discovery_plan(
15560 &store.connection,
15561 "EXPLAIN QUERY PLAN
15562 SELECT coverage.id FROM graph_coverage AS coverage
15563 WHERE coverage.project_instance_id = ? AND coverage.state = ?
15564 ORDER BY coverage.state, coverage.scope_path, coverage.id LIMIT 11",
15565 &[project_value.clone(), Value::Text("failed".to_string())],
15566 &["idx_graph_coverage_discovery_state"],
15567 false,
15568 "coverage state filter",
15569 )?;
15570 assert_coverage_discovery_plan(
15571 &store.connection,
15572 "EXPLAIN QUERY PLAN
15573 SELECT coverage.id FROM graph_coverage AS coverage
15574 WHERE coverage.project_instance_id = ?
15575 AND coverage.state = 'complete' AND coverage.total = 0
15576 AND coverage.relation_scope = 'extended'
15577 AND coverage.relation_kind = 'documents'
15578 ORDER BY coverage.relation_scope, coverage.relation_kind,
15579 coverage.state, coverage.id LIMIT 11",
15580 std::slice::from_ref(&project_value),
15581 &["idx_graph_coverage_relation_state"],
15582 false,
15583 "zero-candidate coverage filter",
15584 )?;
15585 assert_coverage_discovery_plan(
15586 &store.connection,
15587 "EXPLAIN QUERY PLAN
15588 SELECT coverage.id FROM graph_coverage AS coverage
15589 WHERE coverage.project_instance_id = ?
15590 AND coverage.state = 'complete'
15591 AND NOT (coverage.total = 0
15592 AND coverage.relation_scope IS 'extended'
15593 AND coverage.relation_kind IS 'documents')
15594 ORDER BY coverage.state, coverage.scope_path, coverage.id LIMIT 11",
15595 std::slice::from_ref(&project_value),
15596 &["idx_graph_coverage_discovery_state"],
15597 false,
15598 "positive-complete coverage filter",
15599 )?;
15600 assert_coverage_discovery_plan(
15601 &store.connection,
15602 "EXPLAIN QUERY PLAN
15603 SELECT coverage.id FROM graph_coverage AS coverage
15604 WHERE coverage.project_instance_id = ? AND coverage.reason = ?
15605 ORDER BY coverage.reason, coverage.scope_path, coverage.id LIMIT 11",
15606 &[
15607 project_value.clone(),
15608 Value::Text("parser failed".to_string()),
15609 ],
15610 &["idx_graph_coverage_discovery_reason"],
15611 false,
15612 "coverage reason filter",
15613 )?;
15614 for (column, index, context) in [
15615 (
15616 "source_parser",
15617 "idx_source_parse_metadata_source_parser_path",
15618 "coverage parser filter",
15619 ),
15620 (
15621 "fact_parser",
15622 "idx_source_parse_metadata_fact_parser_path",
15623 "coverage provider filter",
15624 ),
15625 ] {
15626 assert_coverage_discovery_plan(
15627 &store.connection,
15628 &format!(
15629 "EXPLAIN QUERY PLAN
15630 SELECT coverage.id
15631 FROM source_parse_metadata AS metadata
15632 CROSS JOIN graph_coverage AS coverage
15633 ON coverage.scope_kind = 'path'
15634 AND coverage.scope_path = metadata.path
15635 WHERE coverage.project_instance_id = ?
15636 AND metadata.{column} = ?
15637 ORDER BY metadata.path, coverage.id LIMIT 11"
15638 ),
15639 &[
15640 project_value.clone(),
15641 Value::Text("tree-sitter".to_string()),
15642 ],
15643 &[index, "idx_graph_coverage_identity"],
15644 true,
15645 context,
15646 )?;
15647 }
15648 let all = store.repository_coverage_page(
15649 fixture.project,
15650 &RepositoryCoverageQuery {
15651 start_index: 0,
15652 limit: 2,
15653 path_prefix: None,
15654 parser: None,
15655 provider: None,
15656 relation: None,
15657 state: None,
15658 reason: None,
15659 },
15660 )?;
15661 require(
15662 all.truncated && all.rows.len() == 2,
15663 "coverage discovery did not use LIMIT + 1",
15664 )?;
15665 let all_states = store.repository_coverage_page(
15666 fixture.project,
15667 &RepositoryCoverageQuery {
15668 start_index: 0,
15669 limit: 20,
15670 path_prefix: None,
15671 parser: None,
15672 provider: None,
15673 relation: None,
15674 state: None,
15675 reason: None,
15676 },
15677 )?;
15678 for state in [
15679 CoverageState::Complete,
15680 CoverageState::NoCandidates,
15681 CoverageState::Partial,
15682 CoverageState::Failed,
15683 CoverageState::Ignored,
15684 CoverageState::Oversized,
15685 CoverageState::Quarantined,
15686 CoverageState::Stale,
15687 ] {
15688 require(
15689 all_states
15690 .rows
15691 .iter()
15692 .any(|row| row.coverage.state() == state),
15693 &format!("coverage discovery omitted {state:?}"),
15694 )?;
15695 }
15696
15697 let complete = store.repository_coverage_page(
15698 fixture.project,
15699 &RepositoryCoverageQuery {
15700 start_index: 0,
15701 limit: 10,
15702 path_prefix: None,
15703 parser: None,
15704 provider: None,
15705 relation: None,
15706 state: Some(CoverageState::Complete),
15707 reason: None,
15708 },
15709 )?;
15710 let complete_contract = !complete.rows.is_empty()
15711 && complete
15712 .rows
15713 .iter()
15714 .all(|row| row.coverage.state() == CoverageState::Complete)
15715 && complete.rows.iter().any(|row| {
15716 row.coverage.total() == 0
15717 && row.coverage.relation()
15718 == Some(GraphRelationKind::Extended(ExtendedRelationKind::Tests))
15719 });
15720 require(
15721 complete_contract,
15722 &format!(
15723 "complete coverage filter changed a non-document zero row or admitted no-candidates: {:?}",
15724 complete.rows
15725 ),
15726 )?;
15727 let no_candidates = store.repository_coverage_page(
15728 fixture.project,
15729 &RepositoryCoverageQuery {
15730 start_index: 0,
15731 limit: 10,
15732 path_prefix: None,
15733 parser: None,
15734 provider: None,
15735 relation: None,
15736 state: Some(CoverageState::NoCandidates),
15737 reason: None,
15738 },
15739 )?;
15740 require(
15741 !no_candidates.rows.is_empty()
15742 && no_candidates.rows.iter().all(|row| {
15743 row.coverage.state() == CoverageState::NoCandidates && row.coverage.total() == 0
15744 }),
15745 "zero-candidate coverage filter admitted a positive-complete row",
15746 )?;
15747
15748 let exact_path = store.repository_coverage_page(
15749 fixture.project,
15750 &RepositoryCoverageQuery {
15751 start_index: 0,
15752 limit: 1,
15753 path_prefix: Some("src/Äuth.rs".to_string()),
15754 parser: None,
15755 provider: None,
15756 relation: None,
15757 state: None,
15758 reason: None,
15759 },
15760 )?;
15761 require(
15762 !exact_path.truncated && exact_path.rows.len() == 1,
15763 "exact coverage path admitted a lexical sibling",
15764 )?;
15765 require_eq(
15766 exact_path.rows[0].coverage.scope(),
15767 &CoverageScope::Path {
15768 path: RepositoryNodePath::new(Path::new("src/Äuth.rs"))?,
15769 },
15770 "exact coverage path scope",
15771 )?;
15772
15773 let parsed = store.repository_coverage_page(
15774 fixture.project,
15775 &RepositoryCoverageQuery {
15776 start_index: 0,
15777 limit: 10,
15778 path_prefix: Some("src/Äuth.rs".to_string()),
15779 parser: Some(ParserKind::TreeSitter),
15780 provider: Some(ParserKind::TreeSitter),
15781 relation: None,
15782 state: Some(CoverageState::Complete),
15783 reason: None,
15784 },
15785 )?;
15786 require_eq(&parsed.rows.len(), &1, "parser/provider filtered coverage")?;
15787 require_eq(
15788 &parsed.rows[0].parser,
15789 &Some(ParserKind::TreeSitter),
15790 "source parser provenance",
15791 )?;
15792 require_eq(
15793 &parsed.rows[0].provider,
15794 &Some(ParserKind::TreeSitter),
15795 "fact provider provenance",
15796 )?;
15797
15798 let failed_calls = store.repository_coverage_page(
15799 fixture.project,
15800 &RepositoryCoverageQuery {
15801 start_index: 0,
15802 limit: 10,
15803 path_prefix: None,
15804 parser: None,
15805 provider: None,
15806 relation: Some(GraphRelationKind::Legacy(RelationKind::Calls)),
15807 state: Some(CoverageState::Failed),
15808 reason: Some("parser failed".to_string()),
15809 },
15810 )?;
15811 require_eq(&failed_calls.rows.len(), &1, "combined coverage filters")?;
15812 require_eq(
15813 &failed_calls.rows[0].coverage.state(),
15814 &CoverageState::Failed,
15815 "failed coverage state",
15816 )?;
15817
15818 let absent = store.repository_coverage_page(
15819 fixture.project,
15820 &RepositoryCoverageQuery {
15821 start_index: 0,
15822 limit: 10,
15823 path_prefix: None,
15824 parser: None,
15825 provider: Some(ParserKind::Manifest),
15826 relation: None,
15827 state: None,
15828 reason: None,
15829 },
15830 )?;
15831 require(
15832 absent.rows.is_empty(),
15833 "provider filter returned a false match",
15834 )?;
15835 store.finish_index_read_snapshot()?;
15836 drop(store);
15837
15838 let writer = AtlasStore::open_for_project(&db_path, &project_root)?;
15839 writer.connection.execute(
15840 "UPDATE source_parse_metadata SET source_parser = 'corrupt-parser'
15841 WHERE path = 'src/Äuth.rs'",
15842 [],
15843 )?;
15844 drop(writer);
15845 let store = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
15846 let error = require_db_error(
15847 store.repository_coverage_page(
15848 fixture.project,
15849 &RepositoryCoverageQuery {
15850 start_index: 0,
15851 limit: 10,
15852 path_prefix: Some("src/Äuth.rs".to_string()),
15853 parser: None,
15854 provider: None,
15855 relation: None,
15856 state: None,
15857 reason: None,
15858 },
15859 ),
15860 "corrupt parser provenance was accepted",
15861 )?;
15862 require(
15863 matches!(error, DbError::InvalidEnum { .. }),
15864 &format!("unexpected parser corruption error: {error}"),
15865 )?;
15866 Ok(())
15867 }
15868
15869 #[test]
15870 fn navigation_connections_cover_families_prefixes_truncation_and_reopen()
15871 -> Result<(), Box<dyn Error>> {
15872 let temp = tempfile::tempdir()?;
15873 let root = temp.path().join("navigation-connections");
15874 fs::create_dir_all(&root)?;
15875 let db_path = root.join("projectatlas.db");
15876 let mut store = AtlasStore::open_for_project(&db_path, &root)?;
15877 let fixture = publish_navigation_fixture(&mut store, "navigation-connections")?;
15878 let owners = vec![
15879 RepositoryNavigationNode {
15880 path: fixture.api_path.clone(),
15881 kind: NodeKind::File,
15882 },
15883 RepositoryNavigationNode {
15884 path: "src/auth".to_string(),
15885 kind: NodeKind::Folder,
15886 },
15887 RepositoryNavigationNode {
15888 path: fixture.manifest_path.clone(),
15889 kind: NodeKind::File,
15890 },
15891 RepositoryNavigationNode {
15892 path: ".".to_string(),
15893 kind: NodeKind::Folder,
15894 },
15895 ];
15896 let pages = store.repository_navigation_connections(&owners, 2, 20)?;
15897 require_eq(&pages.len(), &owners.len(), "navigation owner count")?;
15898 let api = &pages[0];
15899 let families = api
15900 .counts
15901 .iter()
15902 .map(|count| count.kind)
15903 .collect::<Vec<_>>();
15904 require_eq(
15905 &families,
15906 &NAVIGATION_CONNECTION_FAMILIES
15907 .iter()
15908 .map(|&(kind, _, _)| kind)
15909 .collect::<Vec<_>>(),
15910 "all navigation families",
15911 )?;
15912 let calls = api
15913 .counts
15914 .iter()
15915 .find(|count| count.kind == RankedConnectionKind::Call)
15916 .ok_or_else(|| io::Error::other("call navigation count is missing"))?;
15917 require_eq(&calls.count, &2, "bounded high-degree call count")?;
15918 require_eq(&calls.truncated, &true, "high-degree call truncation")?;
15919 require_eq(&api.truncated, &true, "file aggregate truncation")?;
15920 require(
15921 api.connections.iter().any(|connection| {
15922 connection.kind == RankedConnectionKind::Test
15923 && connection.direction == RankedConnectionDirection::Inbound
15924 }),
15925 "inbound test connection was not projected",
15926 )?;
15927
15928 let folder = &pages[1];
15929 let folder_imports = folder
15930 .counts
15931 .iter()
15932 .find(|count| count.kind == RankedConnectionKind::Import)
15933 .ok_or_else(|| io::Error::other("folder import count is missing"))?;
15934 require_eq(
15935 &folder_imports.count,
15936 &1,
15937 "folder prefix import count excluding sibling authz.rs",
15938 )?;
15939 let manifest = &pages[2];
15940 require(
15941 manifest
15942 .counts
15943 .iter()
15944 .any(|count| count.kind == RankedConnectionKind::Package && count.count == 1),
15945 "manifest-owned package context is missing",
15946 )?;
15947 require(
15948 pages[3]
15949 .counts
15950 .iter()
15951 .any(|count| count.kind == RankedConnectionKind::Call),
15952 "root aggregate omitted bounded call context",
15953 )?;
15954
15955 let globally_sampled = store.repository_navigation_connections(&owners[..1], 10, 3)?;
15956 require_eq(
15957 &globally_sampled[0].connections.len(),
15958 &3,
15959 "global connection sample limit",
15960 )?;
15961 require_eq(
15962 &globally_sampled[0].counts.len(),
15963 &NAVIGATION_CONNECTION_FAMILIES.len(),
15964 "global sample retained all family counts",
15965 )?;
15966 require(
15967 globally_sampled[0]
15968 .counts
15969 .iter()
15970 .all(|count| !count.truncated),
15971 "global sample incorrectly reported family overflow",
15972 )?;
15973 require_eq(
15974 &globally_sampled[0].truncated,
15975 &true,
15976 "global sample truncation",
15977 )?;
15978
15979 drop(store);
15980 let reader = AtlasStore::open_read_only_for_project(&db_path, &root)?;
15981 require_eq(
15982 &reader.project_instance_id()?,
15983 &Some(fixture.project),
15984 "reopened navigation identity",
15985 )?;
15986 let reopened = reader.repository_navigation_connections(&owners[..1], 2, 20)?;
15987 require_eq(
15988 &reopened[0].counts,
15989 &api.counts,
15990 "reopened navigation counts",
15991 )?;
15992 Ok(())
15993 }
15994
15995 #[test]
15996 fn navigation_connections_use_owned_indexes_and_fail_all_or_error_on_corruption()
15997 -> Result<(), Box<dyn Error>> {
15998 let temp = tempfile::tempdir()?;
15999 let root = temp.path().join("navigation-plan-corruption");
16000 fs::create_dir_all(&root)?;
16001 let db_path = root.join("projectatlas.db");
16002 let mut store = AtlasStore::open_for_project(&db_path, &root)?;
16003 let fixture = publish_navigation_fixture(&mut store, "navigation-plan-corruption")?;
16004 let owner = RepositoryNavigationNode {
16005 path: fixture.api_path,
16006 kind: NodeKind::File,
16007 };
16008 for (direction, expected_index) in [
16009 (
16010 RankedConnectionDirection::Outbound,
16011 "idx_graph_relations_source_kind",
16012 ),
16013 (
16014 RankedConnectionDirection::Inbound,
16015 "idx_graph_relations_target_kind",
16016 ),
16017 ] {
16018 let mut values = Vec::new();
16019 let sql = navigation_connection_branch(
16020 0,
16021 &owner,
16022 RankedConnectionKind::Call,
16023 "legacy",
16024 "calls",
16025 direction,
16026 3,
16027 &mut values,
16028 );
16029 let mut statement = store
16030 .connection
16031 .prepare(&format!("EXPLAIN QUERY PLAN {sql}"))?;
16032 let details = statement
16033 .query_map(params_from_iter(values.iter()), |row| {
16034 row.get::<_, String>(3)
16035 })?
16036 .collect::<Result<Vec<_>, _>>()?;
16037 for expected in [
16038 expected_index,
16039 "idx_graph_entities_path",
16040 "idx_graph_entities_manifest_path",
16041 ] {
16042 require(
16043 details.iter().any(|detail| detail.contains(expected)),
16044 &format!("navigation plan missed {expected}: {details:?}"),
16045 )?;
16046 }
16047 require(
16048 details.iter().all(|detail| !detail.contains("SCAN graph_")),
16049 &format!("navigation plan scanned graph storage: {details:?}"),
16050 )?;
16051 }
16052 let folder_owner = RepositoryNavigationNode {
16053 path: "src/auth".to_string(),
16054 kind: NodeKind::Folder,
16055 };
16056 for (direction, expected_index) in [
16057 (
16058 RankedConnectionDirection::Outbound,
16059 "idx_graph_relations_source_kind",
16060 ),
16061 (
16062 RankedConnectionDirection::Inbound,
16063 "idx_graph_relations_target_kind",
16064 ),
16065 ] {
16066 let mut values = Vec::new();
16067 let sql = navigation_connection_branch(
16068 0,
16069 &folder_owner,
16070 RankedConnectionKind::Call,
16071 "legacy",
16072 "calls",
16073 direction,
16074 3,
16075 &mut values,
16076 );
16077 let details = store
16078 .connection
16079 .prepare(&format!("EXPLAIN QUERY PLAN {sql}"))?
16080 .query_map(params_from_iter(values.iter()), |row| {
16081 row.get::<_, String>(3)
16082 })?
16083 .collect::<Result<Vec<_>, _>>()?;
16084 for expected in [
16085 expected_index,
16086 "idx_graph_entities_path",
16087 "idx_graph_entities_manifest_path",
16088 ] {
16089 require(
16090 details.iter().any(|detail| detail.contains(expected)),
16091 &format!("folder navigation plan missed {expected}: {details:?}"),
16092 )?;
16093 }
16094 require(
16095 details.iter().all(|detail| !detail.contains("SCAN graph_")),
16096 &format!("folder navigation plan scanned graph storage: {details:?}"),
16097 )?;
16098 }
16099 let root_owner = RepositoryNavigationNode {
16100 path: ".".to_string(),
16101 kind: NodeKind::Folder,
16102 };
16103 let mut root_values = Vec::new();
16104 let root_sql = navigation_connection_branch(
16105 0,
16106 &root_owner,
16107 RankedConnectionKind::Call,
16108 "legacy",
16109 "calls",
16110 RankedConnectionDirection::Outbound,
16111 2,
16112 &mut root_values,
16113 );
16114 let root_details = store
16115 .connection
16116 .prepare(&format!("EXPLAIN QUERY PLAN {root_sql}"))?
16117 .query_map(params_from_iter(root_values.iter()), |row| {
16118 row.get::<_, String>(3)
16119 })?
16120 .collect::<Result<Vec<_>, _>>()?;
16121 require(
16122 root_details
16123 .iter()
16124 .any(|detail| detail.contains("idx_graph_relations_kind_order")),
16125 &format!("root navigation plan missed family index: {root_details:?}"),
16126 )?;
16127 require(
16128 root_details
16129 .iter()
16130 .all(|detail| !detail.contains("SCAN graph_")),
16131 &format!("root navigation plan scanned graph storage: {root_details:?}"),
16132 )?;
16133
16134 store
16135 .connection
16136 .execute_batch("PRAGMA ignore_check_constraints = ON")?;
16137 store.connection.execute(
16138 "UPDATE graph_relations
16139 SET resolution_status = 'unresolved', reference_text = 'broken-route'
16140 WHERE relation_scope = 'extended' AND relation_kind = 'routes-to'",
16141 [],
16142 )?;
16143 let error = require_db_error(
16144 store.repository_navigation_connections(&[owner], 4, 20),
16145 "corrupt navigation relation returned a partial page",
16146 )?;
16147 require(
16148 matches!(error, DbError::GraphRowShape { .. }),
16149 &format!("corrupt navigation relation returned {error}"),
16150 )?;
16151 Ok(())
16152 }
16153
16154 #[test]
16155 fn graph_queries_fail_closed_on_corrupt_normalized_rows() -> Result<(), Box<dyn Error>> {
16156 let temp = tempfile::tempdir()?;
16157 let project_root = temp.path().join("graph-corruption");
16158 let atlas_dir = project_root.join(".projectatlas");
16159 fs::create_dir_all(&atlas_dir)?;
16160 let db_path = atlas_dir.join("projectatlas.db");
16161 let mut store = AtlasStore::open_for_project(&db_path, &project_root)?;
16162 let fixture = publish_fixture(&mut store, "graph-corruption")?;
16163 drop(store);
16164 let store = AtlasStore::open_for_project(&db_path, &project_root)?;
16165 let source = fixture
16166 .entities
16167 .iter()
16168 .find(|entity| matches!(entity.selector(), EntitySelector::File { .. }))
16169 .ok_or_else(|| io::Error::other("source file fixture missing"))?;
16170 let folder = fixture
16171 .entities
16172 .iter()
16173 .find(|entity| matches!(entity.selector(), EntitySelector::Folder { .. }))
16174 .ok_or_else(|| io::Error::other("folder fixture missing"))?;
16175 let symbol = fixture
16176 .entities
16177 .iter()
16178 .find(|entity| matches!(entity.selector(), EntitySelector::Symbol { .. }))
16179 .ok_or_else(|| io::Error::other("symbol fixture missing"))?;
16180 let ambiguous = fixture
16181 .relations
16182 .iter()
16183 .find(|relation| matches!(relation.resolution(), RelationResolution::Ambiguous { .. }))
16184 .ok_or_else(|| io::Error::other("ambiguous relation fixture missing"))?;
16185 let source_digest = source.key().digest_bytes()?;
16186 let folder_digest = folder.key().digest_bytes()?;
16187 let symbol_digest = symbol.key().digest_bytes()?;
16188 let ambiguous_digest = ambiguous.key().digest_bytes()?;
16189 let source_canonical = store.connection.query_row(
16190 "SELECT canonical_identity FROM graph_entities WHERE entity_key = ?1",
16191 [&source_digest[..]],
16192 |row| row.get::<_, String>(0),
16193 )?;
16194 let symbol_canonical = store.connection.query_row(
16195 "SELECT canonical_identity FROM graph_entities WHERE entity_key = ?1",
16196 [&symbol_digest[..]],
16197 |row| row.get::<_, String>(0),
16198 )?;
16199 store
16200 .connection
16201 .execute_batch("PRAGMA ignore_check_constraints = ON")?;
16202
16203 store.connection.execute(
16204 "UPDATE graph_entities SET entity_kind = 'corrupt' WHERE entity_key = ?1",
16205 [&source_digest[..]],
16206 )?;
16207 {
16208 let reader = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
16209 let error = require_db_error(
16210 reader.repository_graph_entity(source.key()),
16211 "malformed graph enum was accepted",
16212 )?;
16213 require(
16214 matches!(error, DbError::InvalidEnum { .. }),
16215 &format!("unexpected malformed-enum error: {error}"),
16216 )?;
16217 reader.finish_index_read_snapshot()?;
16218 }
16219 store.connection.execute(
16220 "UPDATE graph_entities SET entity_kind = 'file' WHERE entity_key = ?1",
16221 [&source_digest[..]],
16222 )?;
16223
16224 store.connection.execute(
16225 "UPDATE graph_relations SET candidate_count = 0 WHERE relation_key = ?1",
16226 [&ambiguous_digest[..]],
16227 )?;
16228 {
16229 let reader = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
16230 let error = require_db_error(
16231 reader.repository_graph_relations(
16232 RepositoryGraphRelationQuery::Outbound {
16233 source: source.key().clone(),
16234 },
16235 10,
16236 ),
16237 "zero ambiguity count was accepted",
16238 )?;
16239 require(
16240 matches!(error, DbError::GraphRowShape { .. }),
16241 &format!("unexpected candidate-count error: {error}"),
16242 )?;
16243 let adjacency_error = require_db_error(
16244 reader.repository_graph_adjacency_page(
16245 &[source.key().clone()],
16246 RepositoryGraphDirection::Outbound,
16247 None,
16248 10,
16249 None,
16250 ),
16251 "corrupt adjacency relation returned a partial page",
16252 )?;
16253 require(
16254 matches!(adjacency_error, DbError::GraphRowShape { .. }),
16255 &format!("unexpected adjacency row-shape error: {adjacency_error}"),
16256 )?;
16257 reader.finish_index_read_snapshot()?;
16258 }
16259 store.connection.execute(
16260 "UPDATE graph_relations SET candidate_count = 2 WHERE relation_key = ?1",
16261 [&ambiguous_digest[..]],
16262 )?;
16263
16264 store.connection.execute(
16265 "UPDATE graph_relations SET resolution_status = 'resolved'
16266 WHERE relation_key = ?1",
16267 [&ambiguous_digest[..]],
16268 )?;
16269 {
16270 let reader = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
16271 let error = require_db_error(
16272 reader.repository_graph_relations(
16273 RepositoryGraphRelationQuery::Outbound {
16274 source: source.key().clone(),
16275 },
16276 10,
16277 ),
16278 "contradictory resolution columns were accepted",
16279 )?;
16280 require(
16281 matches!(error, DbError::GraphRowShape { .. }),
16282 &format!("unexpected resolution-shape error: {error}"),
16283 )?;
16284 reader.finish_index_read_snapshot()?;
16285 }
16286 store.connection.execute(
16287 "UPDATE graph_relations SET resolution_status = 'ambiguous'
16288 WHERE relation_key = ?1",
16289 [&ambiguous_digest[..]],
16290 )?;
16291
16292 store.connection.execute(
16293 "UPDATE graph_coverage SET total = 999
16294 WHERE scope_kind = 'project' AND relation_scope IS NULL",
16295 [],
16296 )?;
16297 {
16298 let reader = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
16299 let error = require_db_error(
16300 reader.repository_graph_coverage(fixture.project, &CoverageScope::Project, 10),
16301 "contradictory coverage total was accepted",
16302 )?;
16303 require(
16304 matches!(error, DbError::GraphRowShape { .. }),
16305 &format!("unexpected coverage-total error: {error}"),
16306 )?;
16307 reader.finish_index_read_snapshot()?;
16308 }
16309 store.connection.execute(
16310 "UPDATE graph_coverage SET total = covered + omitted
16311 WHERE scope_kind = 'project' AND relation_scope IS NULL",
16312 [],
16313 )?;
16314
16315 store.connection.execute(
16316 "UPDATE project_identity SET active_generation = 99 WHERE singleton = 1",
16317 [],
16318 )?;
16319 {
16320 let reader = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
16321 let error = require_db_error(
16322 reader.repository_graph_entity(source.key()),
16323 "mismatched typed graph generation was accepted",
16324 )?;
16325 require(
16326 matches!(error, DbError::GraphRowShape { .. }),
16327 &format!("unexpected typed-generation error: {error}"),
16328 )?;
16329 reader.finish_index_read_snapshot()?;
16330 }
16331 store.connection.execute(
16332 "UPDATE project_identity SET active_generation = 1 WHERE singleton = 1",
16333 [],
16334 )?;
16335
16336 store.connection.execute(
16337 "UPDATE graph_entities SET canonical_identity = 'different-collision-witness'
16338 WHERE entity_key = ?1",
16339 [&source_digest[..]],
16340 )?;
16341 {
16342 let reader = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
16343 let error = require_db_error(
16344 reader.repository_graph_entity(source.key()),
16345 "canonical collision witness was accepted",
16346 )?;
16347 require(
16348 matches!(error, DbError::GraphContract(_)),
16349 &format!("unexpected collision-witness error: {error}"),
16350 )?;
16351 reader.finish_index_read_snapshot()?;
16352 }
16353 store.connection.execute(
16354 "UPDATE graph_entities SET canonical_identity = ?1 WHERE entity_key = ?2",
16355 params![source_canonical, &source_digest[..]],
16356 )?;
16357
16358 store.connection.execute(
16359 "UPDATE graph_entities SET entity_key = zeroblob(32) WHERE entity_key = ?1",
16360 [&folder_digest[..]],
16361 )?;
16362 let folder_path = RepositoryNodePath::new(Path::new("src"))?;
16363 {
16364 let reader = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
16365 let error = require_db_error(
16366 reader.repository_graph_entities_by_path(fixture.project, &folder_path, 10),
16367 "invalid stable digest was accepted",
16368 )?;
16369 require(
16370 matches!(error, DbError::GraphContract(_)),
16371 &format!("unexpected stable-digest error: {error}"),
16372 )?;
16373 reader.finish_index_read_snapshot()?;
16374 }
16375 store.connection.execute(
16376 "UPDATE graph_entities SET entity_key = ?1 WHERE entity_key = zeroblob(32)",
16377 [&folder_digest[..]],
16378 )?;
16379
16380 store.connection.execute(
16381 "UPDATE graph_entities SET entity_key = X'01' WHERE entity_key = ?1",
16382 [&folder_digest[..]],
16383 )?;
16384 {
16385 let reader = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
16386 let error = require_db_error(
16387 reader.repository_graph_entities_by_path(fixture.project, &folder_path, 10),
16388 "short graph key blob was accepted",
16389 )?;
16390 require(
16391 matches!(
16392 error,
16393 DbError::InvalidBlobLength {
16394 field: "graph_entities.entity_key",
16395 expected: 32,
16396 found: 1
16397 }
16398 ),
16399 &format!("unexpected graph-key length error: {error}"),
16400 )?;
16401 reader.finish_index_read_snapshot()?;
16402 }
16403 store.connection.execute(
16404 "UPDATE graph_entities SET entity_key = ?1 WHERE entity_key = X'01'",
16405 [&folder_digest[..]],
16406 )?;
16407
16408 store.connection.execute(
16409 "UPDATE graph_entities SET canonical_identity = X'00' WHERE entity_key = ?1",
16410 [&symbol_digest[..]],
16411 )?;
16412 let source_path = RepositoryNodePath::new(Path::new("src/Äuth.rs"))?;
16413 {
16414 let reader = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
16415 let error = require_db_error(
16416 reader.repository_graph_entities_by_path(fixture.project, &source_path, 10),
16417 "later row conversion failure returned a successful partial page",
16418 )?;
16419 require(
16420 matches!(error, DbError::Sqlite(_)),
16421 &format!("unexpected later-row conversion error: {error}"),
16422 )?;
16423 reader.finish_index_read_snapshot()?;
16424 }
16425 store.connection.execute(
16426 "UPDATE graph_entities SET canonical_identity = ?1 WHERE entity_key = ?2",
16427 params![symbol_canonical, &symbol_digest[..]],
16428 )?;
16429 store
16430 .connection
16431 .execute_batch("PRAGMA ignore_check_constraints = OFF")?;
16432 Ok(())
16433 }
16434
16435 #[test]
16436 fn identity_rejections_are_generation_atomic_indexed_and_reopenable()
16437 -> Result<(), Box<dyn Error>> {
16438 let temp = tempfile::tempdir()?;
16439 let project_root = temp.path().join("identity-rejections");
16440 let atlas_dir = project_root.join(".projectatlas");
16441 fs::create_dir_all(&atlas_dir)?;
16442 let db_path = atlas_dir.join("projectatlas.db");
16443 let mut writer = AtlasStore::open_for_project(&db_path, &project_root)?;
16444 let fixture = publish_fixture(&mut writer, "identity-rejections")?;
16445 let graph_v2 = graph_fixture(fixture.project, IndexGeneration::new(2))?;
16446 let graph_v3 = graph_fixture(fixture.project, IndexGeneration::new(3))?;
16447 let graph_v4 = graph_fixture(fixture.project, IndexGeneration::new(4))?;
16448 let source_path = RepositoryNodePath::new(Path::new("src/Äuth.rs"))?;
16449 let first = GraphIdentityRejection {
16450 path: source_path.clone(),
16451 span: SourceSpan::new(2, 0, 2, 3)?,
16452 parser: ParserKind::TreeSitter,
16453 field: GraphIdentityField::Symbol,
16454 reason: GraphIdentityRejectionReason::Empty,
16455 fact_index: 0,
16456 };
16457 let first_distinct = GraphIdentityRejection {
16458 fact_index: 1,
16459 ..first.clone()
16460 };
16461 let mut first_rows = vec![first, first_distinct];
16462 first_rows.push(first_rows[0].clone());
16463 let expected_first = first_rows[..2].to_vec();
16464 {
16465 let mut publication = writer.begin_index_publication("identity-rejections")?;
16466 publication.replace_repository_graph(
16467 fixture.project,
16468 &graph_v2.entities,
16469 &graph_v2.relations,
16470 &graph_v2.occurrences,
16471 &graph_v2.coverage,
16472 )?;
16473 publication.replace_graph_identity_rejections(fixture.project, &first_rows)?;
16474 publication.complete()?;
16475 }
16476 let first_rows = writer.repository_graph_identity_rejections(
16477 fixture.project,
16478 std::slice::from_ref(&source_path),
16479 10,
16480 None,
16481 )?;
16482 require_eq(
16483 &first_rows,
16484 &expected_first,
16485 "first typed rejection publication",
16486 )?;
16487
16488 let query_plan = writer
16489 .connection
16490 .prepare(
16491 "EXPLAIN QUERY PLAN
16492 SELECT file_path
16493 FROM graph_identity_rejections
16494 WHERE project_instance_id = ?1
16495 AND generation = ?2
16496 AND file_path IN (?3)",
16497 )?
16498 .query_map(
16499 params![&fixture.project.as_bytes()[..], 2_i64, source_path.as_str()],
16500 |row| row.get::<_, String>(3),
16501 )?
16502 .collect::<Result<Vec<_>, _>>()?
16503 .join(" ");
16504 require(
16505 query_plan.contains("idx_graph_identity_rejections_generation_path"),
16506 "typed rejection lookup did not use its generation/path index",
16507 )?;
16508
16509 let reopened = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
16510 let reopened_rows = reopened.repository_graph_identity_rejections(
16511 fixture.project,
16512 std::slice::from_ref(&source_path),
16513 10,
16514 None,
16515 )?;
16516 require_eq(
16517 &reopened_rows,
16518 &expected_first,
16519 "typed rejection survives SQLite reopen",
16520 )?;
16521 reopened.finish_index_read_snapshot()?;
16522
16523 let second = GraphIdentityRejection {
16524 path: source_path.clone(),
16525 span: SourceSpan::new(4, 0, 4, 4)?,
16526 parser: ParserKind::TreeSitter,
16527 field: GraphIdentityField::Signature,
16528 reason: GraphIdentityRejectionReason::SurroundingWhitespace,
16529 fact_index: 0,
16530 };
16531 let expected_second = vec![second.clone()];
16532 {
16533 let mut publication = writer.begin_index_publication("identity-rejections")?;
16534 publication.replace_repository_graph_for_paths(
16535 fixture.project,
16536 &[source_path.as_str().to_string()],
16537 &graph_v3.entities,
16538 &graph_v3.relations,
16539 &graph_v3.occurrences,
16540 &graph_v3.coverage,
16541 )?;
16542 publication.replace_graph_identity_rejections(
16543 fixture.project,
16544 std::slice::from_ref(&second),
16545 )?;
16546 publication.complete()?;
16547 }
16548 let incremental_rows = writer.repository_graph_identity_rejections(
16549 fixture.project,
16550 std::slice::from_ref(&source_path),
16551 10,
16552 None,
16553 )?;
16554 require_eq(
16555 &incremental_rows,
16556 &expected_second,
16557 "incremental path replacement swaps typed rejection rows",
16558 )?;
16559 let third = GraphIdentityRejection {
16560 path: source_path.clone(),
16561 span: SourceSpan::new(6, 0, 6, 6)?,
16562 parser: ParserKind::TreeSitter,
16563 field: GraphIdentityField::Package,
16564 reason: GraphIdentityRejectionReason::Oversized,
16565 fact_index: 0,
16566 };
16567 let expected_third = vec![third.clone()];
16568 {
16569 let mut publication = writer.begin_index_publication("identity-rejections")?;
16570 publication.replace_repository_graph(
16571 fixture.project,
16572 &graph_v4.entities,
16573 &graph_v4.relations,
16574 &graph_v4.occurrences,
16575 &graph_v4.coverage,
16576 )?;
16577 publication
16578 .replace_graph_identity_rejections(fixture.project, std::slice::from_ref(&third))?;
16579 let missing = GraphEntity::new(
16580 fixture.project,
16581 EntitySelector::File {
16582 path: RepositoryFilePath::new(Path::new("src/missing.rs"))?,
16583 },
16584 IndexGeneration::new(4),
16585 )?;
16586 let error = publication.replace_repository_graph_for_paths(
16587 fixture.project,
16588 &["src/missing.rs".to_string()],
16589 &[missing],
16590 &[],
16591 &[],
16592 &[],
16593 );
16594 require(
16595 error.is_err(),
16596 "late graph fault did not abort staged rejection",
16597 )?;
16598 }
16599 let retained_after_fault = writer.repository_graph_identity_rejections(
16600 fixture.project,
16601 std::slice::from_ref(&source_path),
16602 10,
16603 None,
16604 )?;
16605 require_eq(
16606 &retained_after_fault,
16607 &expected_second,
16608 "late publication fault retained the prior complete generation",
16609 )?;
16610
16611 {
16612 let mut publication = writer.begin_index_publication("identity-rejections")?;
16613 publication.replace_repository_graph(
16614 fixture.project,
16615 &graph_v4.entities,
16616 &graph_v4.relations,
16617 &graph_v4.occurrences,
16618 &graph_v4.coverage,
16619 )?;
16620 publication
16621 .replace_graph_identity_rejections(fixture.project, std::slice::from_ref(&third))?;
16622 publication.complete()?;
16623 }
16624 let retried = writer.repository_graph_identity_rejections(
16625 fixture.project,
16626 std::slice::from_ref(&source_path),
16627 10,
16628 None,
16629 )?;
16630 require_eq(
16631 &retried,
16632 &expected_third,
16633 "typed rejection retry replaces prior generation",
16634 )?;
16635 let retained_raw_identity_count = writer.connection.query_row(
16636 "SELECT COUNT(*)
16637 FROM graph_identity_rejections
16638 WHERE file_path LIKE '%bad%'
16639 OR reason LIKE '%bad%'",
16640 [],
16641 |row| row.get::<_, i64>(0),
16642 )?;
16643 require_eq(
16644 &retained_raw_identity_count,
16645 &0,
16646 "typed rejection table retained no invalid raw identity material",
16647 )?;
16648 Ok(())
16649 }
16650
16651 #[test]
16652 fn incremental_rejection_ceiling_covers_preserved_and_incoming_rows()
16653 -> Result<(), Box<dyn Error>> {
16654 let temp = tempfile::tempdir()?;
16655 let root = temp.path().join("identity-rejection-ceiling");
16656 fs::create_dir_all(root.join(".projectatlas"))?;
16657 let database = root.join(".projectatlas/projectatlas.db");
16658 let mut store = AtlasStore::open_for_project(&database, &root)?;
16659 let fixture = publish_fixture(&mut store, "identity-rejection-ceiling")?;
16660 let source_path = RepositoryNodePath::new(Path::new("src/Äuth.rs"))?;
16661 let preserved_count = usize::try_from(GraphLimits::MAX_ROWS)?.saturating_sub(1);
16662 let preserved = (0..preserved_count)
16663 .map(|index| {
16664 let line = u32::try_from(index.saturating_add(1))?;
16665 Ok(GraphIdentityRejection {
16666 path: source_path.clone(),
16667 span: SourceSpan::new(line, 0, line, 0)?,
16668 parser: ParserKind::TreeSitter,
16669 field: GraphIdentityField::Symbol,
16670 reason: GraphIdentityRejectionReason::Empty,
16671 fact_index: u64::try_from(index)?,
16672 })
16673 })
16674 .collect::<Result<Vec<_>, Box<dyn Error>>>()?;
16675 let graph_v2 = graph_fixture(fixture.project, IndexGeneration::new(2))?;
16676 {
16677 let mut publication = store.begin_index_publication("identity-rejection-ceiling")?;
16678 publication.replace_repository_graph(
16679 fixture.project,
16680 &graph_v2.entities,
16681 &graph_v2.relations,
16682 &graph_v2.occurrences,
16683 &graph_v2.coverage,
16684 )?;
16685 publication.replace_graph_identity_rejections(fixture.project, &preserved)?;
16686 publication.complete()?;
16687 }
16688 let incoming = vec![
16689 GraphIdentityRejection {
16690 path: source_path.clone(),
16691 span: SourceSpan::new(20_000, 0, 20_000, 0)?,
16692 parser: ParserKind::TreeSitter,
16693 field: GraphIdentityField::RelationTarget,
16694 reason: GraphIdentityRejectionReason::ControlCharacters,
16695 fact_index: 20_000,
16696 },
16697 GraphIdentityRejection {
16698 path: source_path.clone(),
16699 span: SourceSpan::new(20_001, 0, 20_001, 0)?,
16700 parser: ParserKind::TreeSitter,
16701 field: GraphIdentityField::RelationTarget,
16702 reason: GraphIdentityRejectionReason::ControlCharacters,
16703 fact_index: 20_001,
16704 },
16705 ];
16706 {
16707 let mut publication = store.begin_index_publication("identity-rejection-overflow")?;
16708 publication.replace_repository_graph_for_paths(
16709 fixture.project,
16710 &["Cargo.toml".to_string()],
16711 &[],
16712 &[],
16713 &[],
16714 &[],
16715 )?;
16716 let error =
16717 match publication.replace_graph_identity_rejections(fixture.project, &incoming) {
16718 Ok(()) => {
16719 return Err(io::Error::other(
16720 "preserved plus incoming rows unexpectedly exceeded silently",
16721 )
16722 .into());
16723 }
16724 Err(error) => error,
16725 };
16726 require(
16727 matches!(
16728 error,
16729 DbError::GraphContract(GraphContractError::InvalidLimits { .. })
16730 ),
16731 "combined rejection ceiling returned an unrelated error",
16732 )?;
16733 }
16734 require_eq(
16735 &store.repository_graph_generation()?,
16736 &Some(IndexGeneration::new(2)),
16737 "combined rejection ceiling retained the prior generation",
16738 )?;
16739 require_eq(
16740 &store
16741 .repository_graph_identity_rejections(
16742 fixture.project,
16743 std::slice::from_ref(&source_path),
16744 GraphLimits::MAX_ROWS,
16745 None,
16746 )?
16747 .len(),
16748 &preserved_count,
16749 "combined rejection ceiling did not preserve complete prior rows",
16750 )?;
16751
16752 let graph_v3 = graph_fixture(fixture.project, IndexGeneration::new(3))?;
16753 {
16754 let mut publication = store.begin_index_publication("identity-rejection-retry")?;
16755 publication.replace_repository_graph_for_paths(
16756 fixture.project,
16757 std::slice::from_ref(&source_path.as_str().to_string()),
16758 &graph_v3.entities,
16759 &graph_v3.relations,
16760 &graph_v3.occurrences,
16761 &graph_v3.coverage,
16762 )?;
16763 publication.replace_graph_identity_rejections(fixture.project, &incoming)?;
16764 publication.complete()?;
16765 }
16766 let retried = store.repository_graph_identity_rejections(
16767 fixture.project,
16768 std::slice::from_ref(&source_path),
16769 GraphLimits::MAX_ROWS,
16770 None,
16771 )?;
16772 require_eq(
16773 &retried,
16774 &incoming,
16775 "rejection ceiling retry after affected replacement",
16776 )?;
16777 Ok(())
16778 }
16779
16780 #[test]
16781 fn graph_publication_failure_rolls_back_text_graph_and_generation_for_readers()
16782 -> Result<(), Box<dyn Error>> {
16783 let temp = tempfile::tempdir()?;
16784 let project_root = temp.path().join("graph-publication");
16785 let atlas_dir = project_root.join(".projectatlas");
16786 fs::create_dir_all(&atlas_dir)?;
16787 let db_path = atlas_dir.join("projectatlas.db");
16788 let mut writer = AtlasStore::open_for_project(&db_path, &project_root)?;
16789 let fixture_v1 = publish_fixture(&mut writer, "graph-publication")?;
16790 let initial_index_definitions = graph_rebuildable_index_sql(&writer.connection)?;
16791 let old_reader = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
16792 require_graph_projection(
16793 &old_reader,
16794 &fixture_v1,
16795 IndexGeneration::new(1),
16796 "fn verifyToken()",
16797 )?;
16798
16799 let missing_entity = GraphEntity::new(
16800 fixture_v1.project,
16801 EntitySelector::File {
16802 path: RepositoryFilePath::new(Path::new("src/missing.rs"))?,
16803 },
16804 IndexGeneration::new(2),
16805 )?;
16806 {
16807 let mut publication = writer.begin_index_publication("graph-publication")?;
16808 let error = require_db_error(
16809 publication.replace_repository_graph(
16810 fixture_v1.project,
16811 std::slice::from_ref(&missing_entity),
16812 &[],
16813 &[],
16814 &[],
16815 ),
16816 "full graph with an unindexed source unexpectedly succeeded",
16817 )?;
16818 require(
16819 matches!(error, DbError::Sqlite(_)),
16820 "full replacement reaches the injected late source constraint",
16821 )?;
16822 require_eq(
16823 &graph_rebuildable_index_sql(&publication.store.connection)?,
16824 &initial_index_definitions,
16825 "failed bulk replacement restores exact read-index definitions",
16826 )?;
16827 }
16828 require_graph_projection(
16829 &writer,
16830 &fixture_v1,
16831 IndexGeneration::new(1),
16832 "fn verifyToken()",
16833 )?;
16834 {
16835 let mut publication = writer.begin_index_publication("graph-publication")?;
16836 publication.replace_file_texts_for_paths(
16837 &["src/Äuth.rs".to_string()],
16838 &[IndexedFileText {
16839 path: "src/Äuth.rs".to_string(),
16840 content_hash: Some("hash-new".to_string()),
16841 byte_count: "fn verifyTokenUpdated()".len(),
16842 line_count: 1,
16843 content: "fn verifyTokenUpdated()".to_string(),
16844 }],
16845 )?;
16846 let error = require_db_error(
16847 publication.replace_repository_graph_for_paths(
16848 fixture_v1.project,
16849 &["src/Äuth.rs".to_string(), "src/missing.rs".to_string()],
16850 &[missing_entity],
16851 &[],
16852 &[],
16853 &[],
16854 ),
16855 "missing-node graph publication unexpectedly succeeded",
16856 )?;
16857 require(
16858 matches!(error, DbError::Sqlite(_)),
16859 &format!("unexpected late graph publication error: {error}"),
16860 )?;
16861 }
16862
16863 require_graph_projection(
16864 &writer,
16865 &fixture_v1,
16866 IndexGeneration::new(1),
16867 "fn verifyToken()",
16868 )?;
16869 let rolled_back_reader = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
16870 require_graph_projection(
16871 &rolled_back_reader,
16872 &fixture_v1,
16873 IndexGeneration::new(1),
16874 "fn verifyToken()",
16875 )?;
16876 require_graph_projection(
16877 &old_reader,
16878 &fixture_v1,
16879 IndexGeneration::new(1),
16880 "fn verifyToken()",
16881 )?;
16882 rolled_back_reader.finish_index_read_snapshot()?;
16883
16884 let project = writer
16885 .project_instance_id()?
16886 .ok_or_else(|| io::Error::other("bound writer identity is missing"))?;
16887 let fixture_v2 = graph_fixture(project, IndexGeneration::new(2))?;
16888 {
16889 let mut publication = writer.begin_index_publication("graph-publication")?;
16890 publication.replace_file_texts_for_paths(
16891 &["src/Äuth.rs".to_string()],
16892 &[IndexedFileText {
16893 path: "src/Äuth.rs".to_string(),
16894 content_hash: Some("hash-new".to_string()),
16895 byte_count: "fn verifyTokenUpdated()".len(),
16896 line_count: 1,
16897 content: "fn verifyTokenUpdated()".to_string(),
16898 }],
16899 )?;
16900 publication.replace_repository_graph(
16901 fixture_v2.project,
16902 &fixture_v2.entities,
16903 &fixture_v2.relations,
16904 &fixture_v2.occurrences,
16905 &fixture_v2.coverage,
16906 )?;
16907 publication.complete()?;
16908 }
16909
16910 require_eq(
16911 &graph_rebuildable_index_sql(&writer.connection)?,
16912 &initial_index_definitions,
16913 "successful bulk replacement preserves exact read-index definitions",
16914 )?;
16915
16916 require_graph_projection(
16917 &old_reader,
16918 &fixture_v1,
16919 IndexGeneration::new(1),
16920 "fn verifyToken()",
16921 )?;
16922 let new_reader = AtlasStore::open_read_only_for_project(&db_path, &project_root)?;
16923 require_graph_projection(
16924 &new_reader,
16925 &fixture_v2,
16926 IndexGeneration::new(2),
16927 "fn verifyTokenUpdated()",
16928 )?;
16929 new_reader.finish_index_read_snapshot()?;
16930 old_reader.finish_index_read_snapshot()?;
16931 Ok(())
16932 }
16933}