Skip to main content

lora_store/memory/
graph.rs

1//! The [`InMemoryGraph`] data structure: slot-indexed node/relationship
2//! storage, adjacency lists, label/type indexes, and the inherent
3//! helpers that the trait impls in `super::impls` delegate to.
4
5use std::collections::{BTreeMap, BTreeSet};
6use std::sync::atomic::{AtomicUsize, Ordering};
7use std::sync::{Arc, RwLock, RwLockWriteGuard};
8
9use lora_ast::Direction;
10
11use crate::{
12    DeletedRecordSink, LoraPoint, MutationEvent, MutationRecorder, NodeId, NodeRecord, Properties,
13    PropertyValue, RelationshipId, RelationshipRecord,
14};
15
16use super::chunked_vec::ChunkedVec;
17use super::constraint_catalog::{
18    ConstraintCatalog, ConstraintRequest, CreateConstraintError, CreateConstraintOutcome,
19    DropConstraintError, DropConstraintOutcome,
20};
21use super::entity_index_store::{
22    read_shared, share, write_shared, IndexBundle, IndexRead, IndexWrite,
23};
24use super::fulltext_index::FulltextRegistry;
25use super::hnsw::HnswParams;
26use super::index_catalog::{
27    CreateIndexError, CreateIndexOutcome, DropIndexError, DropIndexOutcome, IndexCatalog,
28    IndexDefinition, IndexRequest, StoredIndexEntity, StoredIndexKind, StoredIndexState,
29};
30use super::point_index::PointRegistry;
31use super::property_index::PropertyIndexRegistry;
32#[cfg(test)]
33use super::property_index::PropertyIndexState;
34use super::secondary_index_maintenance::SecondaryIndexMutation;
35use super::sorted_property_index::SortedPropertyIndex;
36use super::stats::GraphStats;
37use super::text_index::TrigramRegistry;
38use super::vector_index::{VectorIndexProvider, VectorIndexRegistry, VectorSimilarity};
39
40/// Per-node adjacency list. Two relationship ids fit inline in the same
41/// 24 bytes a `Vec` header takes, so the low-degree nodes that make up
42/// most graphs (chains, trees, sparse social graphs) need no heap
43/// allocation for their edges: less memory, one fewer pointer chase per
44/// hop, and a graph clone that copies them with `memcpy` instead of one
45/// `malloc` per node.
46pub(super) type AdjList = smallvec::SmallVec<RelationshipId, 2>;
47
48#[derive(Default)]
49pub struct InMemoryGraph {
50    pub(super) next_node_id: NodeId,
51    pub(super) next_rel_id: RelationshipId,
52
53    /// Slot-indexed node storage: `nodes[id as usize]` is the record at `id`.
54    /// `None` slots are tombstones from deletes (we don't compact). Because
55    /// `next_node_id` is monotonic the slot at `id` is initialized exactly
56    /// when `id < next_node_id` — same identity guarantee the previous
57    /// `BTreeMap<NodeId, NodeRecord>` had, just with O(1) lookup and
58    /// cache-coherent layout.
59    ///
60    /// [`Self::clone`] (called on every staged write to build a working
61    /// copy, and for every reader snapshot) shares this storage: a
62    /// [`ChunkedVec`] clone is one refcount bump, so the whole-graph
63    /// clone is O(#labels + #relationship types), not O(N). A write
64    /// copies only the chunk path it touches. Records are wrapped in
65    /// `Arc` so copying a chunk bumps refcounts instead of deep-cloning
66    /// records, and mutating a record uses `Arc::make_mut`: in place when
67    /// no snapshot still holds it, a single-record copy otherwise.
68    pub(super) nodes: ChunkedVec<Option<Arc<NodeRecord>>>,
69    pub(super) relationships: ChunkedVec<Option<Arc<RelationshipRecord>>>,
70    /// Live (non-tombstoned) counts kept in sync with `put_*`/`take_*` so
71    /// `node_count` / `relationship_count` stay O(1) — without a counter
72    /// they'd have to scan the slab.
73    pub(super) live_node_count: usize,
74    pub(super) live_rel_count: usize,
75
76    /// Adjacency keyed by NodeId. `outgoing[id]` is the list of relationship
77    /// ids that leave `id`; mirrored on `incoming[id]`. Inner `Vec` instead
78    /// of `BTreeSet` because edges are inserted exactly once and traversal
79    /// only needs sequential iteration; the cache-friendly contiguous layout
80    /// shows up on every traversal hop.
81    pub(super) outgoing: ChunkedVec<AdjList>,
82    pub(super) incoming: ChunkedVec<AdjList>,
83
84    // secondary indexes
85    /// Label -> the (unique, monotonic) node ids that carry it. The inner
86    /// `Vec` instead of `BTreeSet` because every node id is inserted at most
87    /// once per label (no dedup needed) and every consumer iterates the
88    /// whole list anyway — contiguous storage iterates faster than a
89    /// tree-of-pointers, and removes via `swap_remove` stay O(degree-of-label).
90    pub(super) nodes_by_label: BTreeMap<String, ChunkedVec<NodeId>>,
91    pub(super) relationships_by_type: BTreeMap<String, ChunkedVec<RelationshipId>>,
92
93    /// All index machinery — the declared-index catalog, hash-bucket
94    /// property registry, and the per-entity-kind secondary index
95    /// registries (text, sorted, point, fulltext) plus their active
96    /// counters — collapsed into one bundle. See [`IndexBundle`] for
97    /// the rationale. The bundle is a packaging-only abstraction:
98    /// every field accessed through `self.indexes.<x>` lives at the
99    /// same address it would have as a top-level field.
100    pub(super) indexes: IndexBundle,
101
102    /// Catalog of explicitly-created constraints (CREATE CONSTRAINT).
103    /// Deliberately not part of [`IndexBundle`] — constraints describe
104    /// data invariants, not indexed access. The fact that uniqueness /
105    /// key constraints back range indexes is handled in the
106    /// constraint code path, not by the bundle's layout.
107    pub(super) constraint_catalog: RwLock<Arc<ConstraintCatalog>>,
108    /// Fast-path counter for mutation-time constraint checks. Most
109    /// workloads have no constraints installed; this lets the executor
110    /// skip taking the catalog lock in that case.
111    pub(super) active_constraints: AtomicUsize,
112
113    /// Optional mutation observer. When `Some`, every committed mutation
114    /// fans out to this recorder *after* the in-memory state has been
115    /// updated. The recorder is not part of the graph's identity, so Clone
116    /// and snapshot restore both reset it to `None`.
117    pub(super) recorder: Option<Arc<dyn MutationRecorder>>,
118
119    /// Optional sink that sees each node / relationship record just before
120    /// a delete drops it. Change feeds use it to report deleted entities
121    /// without copying the graph. Like the recorder, it is not part of the
122    /// graph's identity and is dropped on clone.
123    pub(super) deleted_sink: Option<Arc<dyn DeletedRecordSink>>,
124}
125
126impl std::fmt::Debug for InMemoryGraph {
127    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
128        f.debug_struct("InMemoryGraph")
129            .field("next_node_id", &self.next_node_id)
130            .field("next_rel_id", &self.next_rel_id)
131            .field("nodes", &self.nodes)
132            .field("relationships", &self.relationships)
133            .field("outgoing", &self.outgoing)
134            .field("incoming", &self.incoming)
135            .field("nodes_by_label", &self.nodes_by_label)
136            .field("relationships_by_type", &self.relationships_by_type)
137            .field("indexes", &self.indexes)
138            .field(
139                "active_node_property_indexes",
140                &self.active_node_property_index_count(),
141            )
142            .field(
143                "active_relationship_property_indexes",
144                &self.active_relationship_property_index_count(),
145            )
146            .field(
147                "index_catalog_entries",
148                &self
149                    .indexes
150                    .catalog
151                    .read()
152                    .map(|c| c.list().len())
153                    .unwrap_or(0),
154            )
155            .field("active_constraints", &self.active_constraint_count())
156            .field(
157                "active_fulltext_indexes",
158                &self.active_fulltext_index_count(),
159            )
160            .field("recorder", &self.recorder.as_ref().map(|_| "installed"))
161            .finish()
162    }
163}
164
165impl Clone for InMemoryGraph {
166    fn clone(&self) -> Self {
167        // Deliberately drop the recorder on clone: a cloned store is a
168        // separate identity; it should not silently share the observer.
169        Self {
170            next_node_id: self.next_node_id,
171            next_rel_id: self.next_rel_id,
172            nodes: self.nodes.clone(),
173            relationships: self.relationships.clone(),
174            live_node_count: self.live_node_count,
175            live_rel_count: self.live_rel_count,
176            outgoing: self.outgoing.clone(),
177            incoming: self.incoming.clone(),
178            nodes_by_label: self.nodes_by_label.clone(),
179            relationships_by_type: self.relationships_by_type.clone(),
180            // Shares every registry and catalog; each is copied on its
181            // first write (see `IndexBundle`).
182            indexes: self.indexes.clone(),
183            constraint_catalog: share(&self.constraint_catalog),
184            active_constraints: AtomicUsize::new(self.active_constraint_count()),
185            recorder: None,
186            deleted_sink: None,
187        }
188    }
189}
190
191impl InMemoryGraph {
192    pub fn new() -> Self {
193        Self::default()
194    }
195
196    /// Kept for API compatibility. The chunked storage allocates full
197    /// chunks as the graph grows, so there is no repeated doubling for a
198    /// capacity hint to avoid.
199    pub fn with_capacity_hint(_nodes: usize, _relationships: usize) -> Self {
200        Self::default()
201    }
202
203    pub fn contains_node(&self, node_id: NodeId) -> bool {
204        self.node_at(node_id).is_some()
205    }
206
207    pub fn contains_relationship(&self, rel_id: RelationshipId) -> bool {
208        self.rel_at(rel_id).is_some()
209    }
210
211    /// Install (or clear) the mutation recorder. Passing `None` detaches any
212    /// currently-installed recorder. The recorder observes every committed
213    /// mutation *after* it has been applied.
214    pub fn set_mutation_recorder(&mut self, recorder: Option<Arc<dyn MutationRecorder>>) {
215        self.recorder = recorder;
216    }
217
218    /// Install (or clear) the [`DeletedRecordSink`].
219    pub fn set_deleted_record_sink(&mut self, sink: Option<Arc<dyn DeletedRecordSink>>) {
220        self.deleted_sink = sink;
221    }
222
223    /// Handle to the currently-installed recorder, if any.
224    pub fn mutation_recorder(&self) -> Option<&Arc<dyn MutationRecorder>> {
225        self.recorder.as_ref()
226    }
227
228    /// Emit a mutation event only if a recorder is installed. The event is
229    /// built lazily — callers pass a closure, so when no recorder is
230    /// attached we pay only a `None` check and the cost of constructing the
231    /// event (labels/properties clones) is avoided.
232    #[inline]
233    pub(super) fn emit<F: FnOnce() -> MutationEvent>(&self, build: F) {
234        if let Some(rec) = &self.recorder {
235            rec.record(build());
236        }
237    }
238
239    fn bump_next_node_id_past(&mut self, id: NodeId) -> Result<(), String> {
240        let next = id
241            .checked_add(1)
242            .ok_or_else(|| format!("node id {id} leaves no valid next node id"))?;
243        self.next_node_id = self.next_node_id.max(next);
244        Ok(())
245    }
246
247    fn bump_next_rel_id_past(&mut self, id: RelationshipId) -> Result<(), String> {
248        let next = id
249            .checked_add(1)
250            .ok_or_else(|| format!("relationship id {id} leaves no valid next relationship id"))?;
251        self.next_rel_id = self.next_rel_id.max(next);
252        Ok(())
253    }
254
255    pub(super) fn try_reserve_next_node_slot(&mut self) -> Option<(NodeId, usize)> {
256        let id = self.next_node_id;
257        let idx = self.ensure_node_slot_checked(id).ok()?;
258        self.bump_next_node_id_past(id).ok()?;
259        Some((id, idx))
260    }
261
262    pub(super) fn try_reserve_next_rel_slot(&mut self) -> Option<(RelationshipId, usize)> {
263        let id = self.next_rel_id;
264        let idx = self.ensure_rel_slot_checked(id).ok()?;
265        self.bump_next_rel_id_past(id).ok()?;
266        Some((id, idx))
267    }
268
269    // ---------- Slab access helpers ----------
270    //
271    // Stand-in for the BTreeMap API the previous storage used. They keep the
272    // call sites readable while the underlying layout is positional Vec.
273
274    #[inline]
275    pub(super) fn node_at(&self, id: NodeId) -> Option<&NodeRecord> {
276        self.nodes
277            .get(Self::slot_index(id)?)
278            .and_then(|s| s.as_ref())
279            .map(|arc| arc.as_ref())
280    }
281
282    /// Mutable handle to a node record, doing copy-on-write only when the
283    /// `Arc` is shared with a concurrent reader. With no readers (the
284    /// common case after a fresh write_store clone), `Arc::make_mut`
285    /// upgrades in place — no record clone.
286    #[inline]
287    pub(super) fn node_at_mut(&mut self, id: NodeId) -> Option<&mut NodeRecord> {
288        self.nodes
289            .get_mut(Self::slot_index(id)?)
290            .and_then(|s| s.as_mut())
291            .map(Arc::make_mut)
292    }
293
294    #[inline]
295    pub(super) fn rel_at(&self, id: RelationshipId) -> Option<&RelationshipRecord> {
296        self.relationships
297            .get(Self::slot_index(id)?)
298            .and_then(|s| s.as_ref())
299            .map(|arc| arc.as_ref())
300    }
301
302    #[inline]
303    pub(super) fn rel_at_mut(&mut self, id: RelationshipId) -> Option<&mut RelationshipRecord> {
304        self.relationships
305            .get_mut(Self::slot_index(id)?)
306            .and_then(|s| s.as_mut())
307            .map(Arc::make_mut)
308    }
309
310    /// Resize the node-keyed Vecs so `id as usize` is in range. Adjacency
311    /// lists are kept in lockstep with `nodes`, so a freshly-grown slot has
312    /// empty outgoing/incoming Vecs ready to receive edges.
313    fn slot_len_for_id(id: u64, kind: &str) -> Result<usize, String> {
314        let idx = usize::try_from(id)
315            .map_err(|_| format!("{kind} id {id} does not fit in usize on this platform"))?;
316        idx.checked_add(1)
317            .ok_or_else(|| format!("{kind} id {id} leaves no valid slab slot"))
318    }
319
320    #[inline]
321    fn slot_index(id: u64) -> Option<usize> {
322        usize::try_from(id).ok()
323    }
324
325    fn ensure_node_slot_checked(&mut self, id: NodeId) -> Result<usize, String> {
326        let target = Self::slot_len_for_id(id, "node")?;
327        if self.nodes.len() < target {
328            let additional = target - self.nodes.len();
329            self.nodes.try_reserve_exact(additional).map_err(|e| {
330                format!("node id {id} requires {target} slots, but allocation failed: {e}")
331            })?;
332            self.outgoing.try_reserve_exact(additional).map_err(|e| {
333                format!(
334                    "node id {id} requires {target} adjacency slots, but allocation failed: {e}"
335                )
336            })?;
337            self.incoming.try_reserve_exact(additional).map_err(|e| {
338                format!(
339                    "node id {id} requires {target} adjacency slots, but allocation failed: {e}"
340                )
341            })?;
342            self.nodes.resize_with(target, || None);
343            self.outgoing.resize_with(target, AdjList::new);
344            self.incoming.resize_with(target, AdjList::new);
345        }
346        Ok(target - 1)
347    }
348
349    fn ensure_rel_slot_checked(&mut self, id: RelationshipId) -> Result<usize, String> {
350        let target = Self::slot_len_for_id(id, "relationship")?;
351        if self.relationships.len() < target {
352            self.relationships
353                .try_reserve_exact(target - self.relationships.len())
354                .map_err(|e| {
355                    format!(
356                        "relationship id {id} requires {target} slots, but allocation failed: {e}"
357                    )
358                })?;
359            self.relationships.resize_with(target, || None);
360        }
361        Ok(target - 1)
362    }
363
364    pub(super) fn put_node_checked(&mut self, id: NodeId, node: NodeRecord) -> Result<(), String> {
365        let idx = self.ensure_node_slot_checked(id)?;
366        self.put_node_at_slot(idx, node);
367        Ok(())
368    }
369
370    pub(super) fn put_rel_checked(
371        &mut self,
372        id: RelationshipId,
373        rel: RelationshipRecord,
374    ) -> Result<(), String> {
375        let idx = self.ensure_rel_slot_checked(id)?;
376        self.put_rel_at_slot(idx, rel);
377        Ok(())
378    }
379
380    pub(super) fn put_node_at_slot(&mut self, idx: usize, node: NodeRecord) {
381        let was_present = self.nodes[idx].is_some();
382        self.nodes[idx] = Some(Arc::new(node));
383        if !was_present {
384            self.live_node_count += 1;
385        }
386    }
387
388    pub(super) fn put_rel_at_slot(&mut self, idx: usize, rel: RelationshipRecord) {
389        let was_present = self.relationships[idx].is_some();
390        self.relationships[idx] = Some(Arc::new(rel));
391        if !was_present {
392            self.live_rel_count += 1;
393        }
394    }
395
396    pub(super) fn take_node(&mut self, id: NodeId) -> Option<NodeRecord> {
397        let idx = Self::slot_index(id)?;
398        let removed = self.nodes.get_mut(idx).and_then(|s| s.take());
399        if removed.is_some() {
400            self.live_node_count -= 1;
401            // Also clear the per-id adjacency entries so the memory is reclaimed
402            // on the typical "delete every node" pattern. We deliberately do not
403            // shrink the outer Vec — leaving the slot lets new ids reuse the
404            // same index without growth churn (and `next_node_id` is monotonic
405            // anyway, so no immediate reuse).
406            if let Some(out) = self.outgoing.get_mut(idx) {
407                out.clear();
408            }
409            if let Some(inc) = self.incoming.get_mut(idx) {
410                inc.clear();
411            }
412        }
413        // Unwrap the Arc — `try_unwrap` returns the inner `NodeRecord`
414        // without cloning when our slab held the only reference, falling
415        // back to a clone only when concurrent readers still hold a
416        // snapshot Arc.
417        removed.map(|arc| Arc::try_unwrap(arc).unwrap_or_else(|arc| (*arc).clone()))
418    }
419
420    pub(super) fn take_rel(&mut self, id: RelationshipId) -> Option<RelationshipRecord> {
421        let idx = Self::slot_index(id)?;
422        let removed = self.relationships.get_mut(idx).and_then(|s| s.take());
423        if removed.is_some() {
424            self.live_rel_count -= 1;
425        }
426        removed.map(|arc| Arc::try_unwrap(arc).unwrap_or_else(|arc| (*arc).clone()))
427    }
428
429    #[inline]
430    pub(super) fn outgoing_at(&self, id: NodeId) -> Option<&[RelationshipId]> {
431        self.outgoing
432            .get(Self::slot_index(id)?)
433            .map(|adj| adj.as_slice())
434    }
435
436    #[inline]
437    pub(super) fn incoming_at(&self, id: NodeId) -> Option<&[RelationshipId]> {
438        self.incoming
439            .get(Self::slot_index(id)?)
440            .map(|adj| adj.as_slice())
441    }
442
443    #[inline]
444    fn try_for_each_adjacent_slice<F, E>(
445        &self,
446        node_id: NodeId,
447        types: &[String],
448        adj: &[RelationshipId],
449        skip_self_loops: bool,
450        visit: &mut F,
451    ) -> Result<(), E>
452    where
453        F: FnMut(RelationshipId, NodeId) -> Result<(), E>,
454    {
455        let single_type = match types {
456            [single] => Some(single.as_str()),
457            _ => None,
458        };
459        let has_type_filter = !types.is_empty();
460
461        for &rel_id in adj {
462            let Some(rel) = self.rel_at(rel_id) else {
463                continue;
464            };
465            if skip_self_loops && rel.src == node_id && rel.dst == node_id {
466                continue;
467            }
468            if let Some(single) = single_type {
469                if rel.rel_type != single {
470                    continue;
471                }
472            } else if has_type_filter && !types.iter().any(|t| t == &rel.rel_type) {
473                continue;
474            }
475            let Some(other_id) = Self::other_endpoint(rel, node_id) else {
476                continue;
477            };
478            visit(rel_id, other_id)?;
479        }
480        Ok(())
481    }
482
483    #[inline]
484    pub(super) fn try_for_each_adjacent_id_unchecked<F, E>(
485        &self,
486        node_id: NodeId,
487        direction: Direction,
488        types: &[String],
489        mut visit: F,
490    ) -> Result<(), E>
491    where
492        F: FnMut(RelationshipId, NodeId) -> Result<(), E>,
493    {
494        match direction {
495            Direction::Right => {
496                if let Some(adj) = self.outgoing_at(node_id) {
497                    self.try_for_each_adjacent_slice(node_id, types, adj, false, &mut visit)?;
498                }
499            }
500            Direction::Left => {
501                if let Some(adj) = self.incoming_at(node_id) {
502                    self.try_for_each_adjacent_slice(node_id, types, adj, false, &mut visit)?;
503                }
504            }
505            Direction::Undirected => {
506                if let Some(adj) = self.outgoing_at(node_id) {
507                    self.try_for_each_adjacent_slice(node_id, types, adj, false, &mut visit)?;
508                }
509                if let Some(adj) = self.incoming_at(node_id) {
510                    self.try_for_each_adjacent_slice(node_id, types, adj, true, &mut visit)?;
511                }
512            }
513        }
514
515        Ok(())
516    }
517
518    #[inline]
519    pub(super) fn try_for_each_adjacent_id<F, E>(
520        &self,
521        node_id: NodeId,
522        direction: Direction,
523        types: &[String],
524        visit: F,
525    ) -> Result<(), E>
526    where
527        F: FnMut(RelationshipId, NodeId) -> Result<(), E>,
528    {
529        if self.node_at(node_id).is_none() {
530            return Ok(());
531        }
532        self.try_for_each_adjacent_id_unchecked(node_id, direction, types, visit)
533    }
534
535    pub(super) fn iter_node_ids(&self) -> impl Iterator<Item = NodeId> + '_ {
536        self.nodes
537            .iter()
538            .enumerate()
539            .filter_map(|(i, slot)| slot.as_ref().map(|_| i as NodeId))
540    }
541
542    pub(super) fn iter_node_records(&self) -> impl Iterator<Item = &NodeRecord> + '_ {
543        self.nodes
544            .iter()
545            .filter_map(|s| s.as_ref())
546            .map(|arc| arc.as_ref())
547    }
548
549    pub(super) fn iter_rel_ids(&self) -> impl Iterator<Item = RelationshipId> + '_ {
550        self.relationships
551            .iter()
552            .enumerate()
553            .filter_map(|(i, slot)| slot.as_ref().map(|_| i as RelationshipId))
554    }
555
556    pub(super) fn iter_rel_records(&self) -> impl Iterator<Item = &RelationshipRecord> + '_ {
557        self.relationships
558            .iter()
559            .filter_map(|s| s.as_ref())
560            .map(|arc| arc.as_ref())
561    }
562
563    pub(super) fn iter_nodes(&self) -> impl Iterator<Item = (NodeId, &NodeRecord)> + '_ {
564        self.nodes
565            .iter()
566            .enumerate()
567            .filter_map(|(i, slot)| slot.as_ref().map(|n| (i as NodeId, n.as_ref())))
568    }
569
570    pub(super) fn iter_rels(
571        &self,
572    ) -> impl Iterator<Item = (RelationshipId, &RelationshipRecord)> + '_ {
573        self.relationships
574            .iter()
575            .enumerate()
576            .filter_map(|(i, slot)| slot.as_ref().map(|r| (i as RelationshipId, r.as_ref())))
577    }
578
579    /// Add `rel_id` to `node_id`'s outgoing list. Relies on the monotonic-id
580    /// invariant: relationship ids are allocated once and never re-used, so
581    /// the bucket can never see a duplicate.
582    fn outgoing_push(&mut self, node_id: NodeId, rel_id: RelationshipId) {
583        if let Ok(idx) = self.ensure_node_slot_checked(node_id) {
584            self.outgoing[idx].push(rel_id);
585        }
586    }
587
588    fn incoming_push(&mut self, node_id: NodeId, rel_id: RelationshipId) {
589        if let Ok(idx) = self.ensure_node_slot_checked(node_id) {
590            self.incoming[idx].push(rel_id);
591        }
592    }
593
594    /// Remove `rel_id` from `node_id`'s outgoing list. `swap_remove` keeps
595    /// the operation O(1) — adjacency order doesn't carry semantic meaning.
596    fn outgoing_remove(&mut self, node_id: NodeId, rel_id: RelationshipId) {
597        if let Some(v) = Self::slot_index(node_id).and_then(|idx| self.outgoing.get_mut(idx)) {
598            if let Some(pos) = v.iter().position(|&id| id == rel_id) {
599                v.swap_remove(pos);
600            }
601        }
602    }
603
604    fn incoming_remove(&mut self, node_id: NodeId, rel_id: RelationshipId) {
605        if let Some(v) = Self::slot_index(node_id).and_then(|idx| self.incoming.get_mut(idx)) {
606            if let Some(pos) = v.iter().position(|&id| id == rel_id) {
607                v.swap_remove(pos);
608            }
609        }
610    }
611
612    pub(super) fn normalize_labels(labels: Vec<String>) -> Vec<String> {
613        let mut seen = BTreeSet::new();
614
615        labels
616            .into_iter()
617            .map(|s| s.trim().to_string())
618            .filter(|s| !s.is_empty())
619            .filter(|s| seen.insert(s.clone()))
620            .collect()
621    }
622
623    pub(super) fn insert_node_label_index(&mut self, node_id: NodeId, label: &str) {
624        // Hot path: skip the `String` alloc when the label bucket already
625        // exists. The monotonic-id invariant on the create path guarantees
626        // `node_id` is unique, so we push unconditionally; the previous
627        // `contains` guard turned bulk CREATE into O(n²).
628        if let Some(bucket) = self.nodes_by_label.get_mut(label) {
629            bucket.push(node_id);
630        } else {
631            self.nodes_by_label
632                .insert(label.to_string(), std::iter::once(node_id).collect());
633        }
634    }
635
636    fn remove_node_label_index(&mut self, node_id: NodeId, label: &str) {
637        if let Some(ids) = self.nodes_by_label.get_mut(label) {
638            let pos = ids.iter().position(|&id| id == node_id);
639            if let Some(pos) = pos {
640                ids.swap_remove(pos);
641            }
642            if ids.is_empty() {
643                self.nodes_by_label.remove(label);
644            }
645        }
646    }
647
648    fn insert_relationship_type_index(&mut self, rel_id: RelationshipId, rel_type: &str) {
649        // See `insert_node_label_index` for the same hot-path rationale.
650        if let Some(bucket) = self.relationships_by_type.get_mut(rel_type) {
651            bucket.push(rel_id);
652        } else {
653            self.relationships_by_type
654                .insert(rel_type.to_string(), std::iter::once(rel_id).collect());
655        }
656    }
657
658    fn remove_relationship_type_index(&mut self, rel_id: RelationshipId, rel_type: &str) {
659        if let Some(ids) = self.relationships_by_type.get_mut(rel_type) {
660            let pos = ids.iter().position(|&id| id == rel_id);
661            if let Some(pos) = pos {
662                ids.swap_remove(pos);
663            }
664            if ids.is_empty() {
665                self.relationships_by_type.remove(rel_type);
666            }
667        }
668    }
669
670    /// Ids of `label` nodes whose `key` equals `value`, from the scoped
671    /// hash index. `None` when that index is not active for `key` or
672    /// `value` has no index image; the caller must then scan.
673    pub(super) fn indexed_node_ids(
674        &self,
675        label: &str,
676        key: &str,
677        value: &PropertyValue,
678    ) -> Option<Vec<NodeId>> {
679        super::property_index::PropertyIndexKey::from_value(value)?;
680        let indexes = self.indexes_read();
681        if !indexes.node_properties.is_active(key) {
682            return None;
683        }
684        Some(
685            indexes
686                .node_properties
687                .scoped_ids_for(label, key, value)
688                .map(|ids| ids.to_vec())
689                .unwrap_or_default(),
690        )
691    }
692
693    /// Relationship counterpart of [`Self::indexed_node_ids`].
694    pub(super) fn indexed_rel_ids(
695        &self,
696        rel_type: &str,
697        key: &str,
698        value: &PropertyValue,
699    ) -> Option<Vec<RelationshipId>> {
700        super::property_index::PropertyIndexKey::from_value(value)?;
701        let indexes = self.indexes_read();
702        if !indexes.relationship_properties.is_active(key) {
703            return None;
704        }
705        Some(
706            indexes
707                .relationship_properties
708                .scoped_ids_for(rel_type, key, value)
709                .map(|ids| ids.to_vec())
710                .unwrap_or_default(),
711        )
712    }
713
714    pub(super) fn indexes_read(&self) -> std::sync::RwLockReadGuard<'_, PropertyIndexRegistry> {
715        self.indexes
716            .properties
717            .read()
718            .unwrap_or_else(|poisoned| poisoned.into_inner())
719    }
720
721    pub(super) fn indexes_write(&self) -> RwLockWriteGuard<'_, PropertyIndexRegistry> {
722        self.indexes
723            .properties
724            .write()
725            .unwrap_or_else(|poisoned| poisoned.into_inner())
726    }
727
728    pub(super) fn indexes_mut(&mut self) -> &mut PropertyIndexRegistry {
729        self.indexes
730            .properties
731            .get_mut()
732            .unwrap_or_else(|poisoned| poisoned.into_inner())
733    }
734
735    #[inline]
736    pub(super) fn active_node_property_index_count(&self) -> usize {
737        self.indexes
738            .active_node_property_indexes
739            .load(Ordering::Relaxed)
740    }
741
742    #[inline]
743    pub(super) fn active_relationship_property_index_count(&self) -> usize {
744        self.indexes
745            .active_relationship_property_indexes
746            .load(Ordering::Relaxed)
747    }
748
749    #[inline]
750    pub(super) fn active_constraint_count(&self) -> usize {
751        self.active_constraints.load(Ordering::Relaxed)
752    }
753
754    #[inline]
755    pub(super) fn has_active_constraints(&self) -> bool {
756        self.active_constraint_count() != 0
757    }
758
759    #[inline]
760    pub(super) fn active_fulltext_index_count(&self) -> usize {
761        self.indexes.active_fulltext_indexes.load(Ordering::Relaxed)
762    }
763
764    #[inline]
765    pub(super) fn has_active_fulltext_indexes(&self) -> bool {
766        self.active_fulltext_index_count() != 0
767    }
768
769    pub(super) fn node_property_index_is_active(&mut self, key: &str) -> bool {
770        self.active_node_property_index_count() != 0
771            && self.indexes_mut().node_properties.is_active(key)
772    }
773
774    pub(super) fn relationship_property_index_is_active(&mut self, key: &str) -> bool {
775        self.active_relationship_property_index_count() != 0
776            && self.indexes_mut().relationship_properties.is_active(key)
777    }
778
779    pub(super) fn ensure_node_property_index(&self, key: &str) {
780        {
781            let indexes = self.indexes_read();
782            if indexes.node_properties.is_active(key) {
783                return;
784            }
785        }
786
787        let mut indexes = self.indexes_write();
788        if indexes.node_properties.is_active(key) {
789            return;
790        }
791
792        indexes.node_properties.insert_bulk(key, || {
793            self.iter_nodes().filter_map(|(id, node)| {
794                let value = node.properties.get(key)?;
795                Some((id, node.labels.iter().map(String::as_str), value))
796            })
797        });
798        if indexes.node_properties.activate(key) {
799            self.indexes
800                .active_node_property_indexes
801                .fetch_add(1, Ordering::Relaxed);
802        }
803    }
804
805    pub(super) fn ensure_relationship_property_index(&self, key: &str) {
806        {
807            let indexes = self.indexes_read();
808            if indexes.relationship_properties.is_active(key) {
809                return;
810            }
811        }
812
813        let mut indexes = self.indexes_write();
814        if indexes.relationship_properties.is_active(key) {
815            return;
816        }
817
818        indexes.relationship_properties.insert_bulk(key, || {
819            self.iter_rels().filter_map(|(id, rel)| {
820                let value = rel.properties.get(key)?;
821                Some((id, [rel.rel_type.as_str()], value))
822            })
823        });
824        if indexes.relationship_properties.activate(key) {
825            self.indexes
826                .active_relationship_property_indexes
827                .fetch_add(1, Ordering::Relaxed);
828        }
829    }
830
831    pub(super) fn index_catalog_read(&self) -> IndexRead<'_, IndexCatalog> {
832        read_shared(&self.indexes.catalog)
833    }
834
835    /// Mutable access copies the catalog first if a clone still shares it.
836    pub(super) fn index_catalog_write(&self) -> IndexWrite<'_, IndexCatalog> {
837        write_shared(&self.indexes.catalog)
838    }
839
840    pub(super) fn constraint_catalog_read(&self) -> IndexRead<'_, ConstraintCatalog> {
841        read_shared(&self.constraint_catalog)
842    }
843
844    /// Mutable access copies the catalog first if a clone still shares it.
845    pub(super) fn constraint_catalog_write(&self) -> IndexWrite<'_, ConstraintCatalog> {
846        write_shared(&self.constraint_catalog)
847    }
848
849    /// Register an explicitly-declared index in the catalog and, when
850    /// applicable, force the underlying property-index buckets to be
851    /// populated so equality lookups can use them immediately.
852    ///
853    /// Named with a `register_` prefix to avoid colliding with the
854    /// trait method `GraphStorageMut::create_index` — the trait impl
855    /// in `impls.rs` delegates here.
856    #[allow(clippy::result_large_err)]
857    pub(super) fn register_index(
858        &self,
859        request: IndexRequest,
860        if_not_exists: bool,
861    ) -> Result<CreateIndexOutcome, CreateIndexError> {
862        self.register_index_with_recording(request, if_not_exists, true)
863    }
864
865    #[allow(clippy::result_large_err)]
866    fn register_index_with_recording(
867        &self,
868        request: IndexRequest,
869        if_not_exists: bool,
870        record_event: bool,
871    ) -> Result<CreateIndexOutcome, CreateIndexError> {
872        let request_for_event = record_event.then(|| request.clone());
873        let outcome = {
874            let mut catalog = self.index_catalog_write();
875            catalog.try_create(request, if_not_exists)?
876        };
877
878        if let CreateIndexOutcome::Created(def) = &outcome {
879            self.populate_index_data(def);
880        }
881
882        // Both Created and NoOpExists are committed catalog states; we
883        // log only Created because NoOpExists implies a redundant DDL
884        // that adds nothing to durable state.
885        if matches!(outcome, CreateIndexOutcome::Created(_)) {
886            if let Some(request_for_event) = request_for_event {
887                self.emit(|| crate::MutationEvent::CreateIndex {
888                    request: request_for_event,
889                    if_not_exists,
890                });
891            }
892        }
893
894        Ok(outcome)
895    }
896
897    /// Replay a CreateIndex event against an empty graph. Mirrors the
898    /// `replay_create_node` shape: callers must invoke before installing
899    /// a recorder so we don't re-emit during recovery.
900    #[doc(hidden)]
901    pub fn replay_create_index(
902        &mut self,
903        request: IndexRequest,
904        if_not_exists: bool,
905    ) -> Result<(), String> {
906        if self.recorder.is_some() {
907            return Err("cannot replay create_index while a mutation recorder is installed".into());
908        }
909        self.register_index(request, if_not_exists)
910            .map(|_| ())
911            .map_err(|e| e.to_string())
912    }
913
914    /// Replay a DropIndex event.
915    #[doc(hidden)]
916    pub fn replay_drop_index(&mut self, name: &str, if_exists: bool) -> Result<(), String> {
917        if self.recorder.is_some() {
918            return Err("cannot replay drop_index while a mutation recorder is installed".into());
919        }
920        self.drop_named_index(name, if_exists)
921            .map(|_| ())
922            .map_err(|e| e.to_string())
923    }
924
925    /// Register a constraint. For uniqueness/key kinds this also
926    /// registers a backing RANGE index in the index catalog under the
927    /// same name. Validation of existing data is the caller's
928    /// responsibility (the enforcement layer runs a pre-create scan
929    /// before this method commits).
930    pub(super) fn register_constraint(
931        &self,
932        request: ConstraintRequest,
933        if_not_exists: bool,
934    ) -> Result<CreateConstraintOutcome, CreateConstraintError> {
935        // Constraint-level conflicts (22N65/66/67) take precedence over
936        // index-catalog conflicts: if the request collides with an
937        // existing *constraint* shape or name, we never get to the
938        // backing-index step.
939        {
940            let constraint_catalog = self.constraint_catalog_read();
941            if let Some(existing) = constraint_catalog.find_equivalent(&request) {
942                let cloned = existing.clone();
943                drop(constraint_catalog);
944                if if_not_exists {
945                    return Ok(CreateConstraintOutcome::NoOpExists(cloned));
946                }
947                return Err(CreateConstraintError::EquivalentConstraintExists(
948                    cloned.name,
949                ));
950            }
951            if let Some(existing) = constraint_catalog.get(&request.name) {
952                let cloned = existing.clone();
953                drop(constraint_catalog);
954                if if_not_exists {
955                    return Ok(CreateConstraintOutcome::NoOpExists(cloned));
956                }
957                return Err(CreateConstraintError::DuplicateName(cloned.name));
958            }
959            if let Some(existing) = constraint_catalog.find_same_schema(&request) {
960                let cloned = existing.clone();
961                drop(constraint_catalog);
962                if super::constraint_catalog::kinds_conflict_for_validation(
963                    &cloned.kind,
964                    &request.kind,
965                ) {
966                    if if_not_exists {
967                        return Ok(CreateConstraintOutcome::NoOpExists(cloned));
968                    }
969                    return Err(CreateConstraintError::ConflictingConstraint(cloned.name));
970                }
971            }
972        }
973
974        // Index-catalog conflicts only matter for constraints that need
975        // a backing range index. The catalog won't yet own one for this
976        // request — that registration happens below — so any existing
977        // entry under the same name or schema is from a foreign index.
978        if request.kind.requires_backing_index() {
979            let idx_catalog = self.index_catalog_read();
980            if idx_catalog.get(&request.name).is_some() {
981                return Err(CreateConstraintError::DuplicateIndexName(
982                    request.name.clone(),
983                ));
984            }
985            let conflict = idx_catalog.list().into_iter().find(|def| {
986                def.kind == StoredIndexKind::Range
987                    && def.entity == request.entity
988                    && def.label.as_deref() == Some(request.label.as_str())
989                    && def.properties == request.properties
990                    && def.name != request.name
991            });
992            drop(idx_catalog);
993            if let Some(def) = conflict {
994                return Err(CreateConstraintError::BackingIndexConflict(format!(
995                    "(:{} {{{}}}) already covered by index `{}`",
996                    request.label,
997                    request.properties.join(", "),
998                    def.name,
999                )));
1000            }
1001        }
1002
1003        let owns_backing = request.kind.requires_backing_index();
1004        let request_for_event = request.clone();
1005        let outcome = {
1006            let mut catalog = self.constraint_catalog_write();
1007            catalog.try_create(request, if_not_exists)?
1008        };
1009
1010        if let CreateConstraintOutcome::Created(def) = &outcome {
1011            // Pre-create data scan: if the live graph already violates
1012            // the constraint, fail and roll back the catalog write.
1013            // Creating constraints when conflicting data exists fails
1014            // before the catalog change is retained (22N77/79/80).
1015            if let Err(violation) = self.validate_existing_data_for_constraint(def) {
1016                let mut catalog = self.constraint_catalog_write();
1017                let _ = catalog.try_drop(&def.name, true);
1018                return Err(CreateConstraintError::DataViolation(violation.to_string()));
1019            }
1020        }
1021
1022        if let CreateConstraintOutcome::Created(def) = &outcome {
1023            if owns_backing {
1024                // Register a backing RANGE index under the same name. This
1025                // is an implementation detail of the constraint, so WAL and
1026                // snapshot replay record only the constraint mutation.
1027                let idx_request = IndexRequest {
1028                    explicit_name: Some(def.name.clone()),
1029                    kind: StoredIndexKind::Range,
1030                    entity: def.entity,
1031                    label: Some(def.label.clone()),
1032                    additional_labels: Vec::new(),
1033                    properties: def.properties.clone(),
1034                    options: Default::default(),
1035                };
1036                // Errors from the backing-index registration unwind the
1037                // constraint registration to keep the two catalogs in
1038                // step.
1039                if let Err(err) = self.register_index_with_recording(idx_request, true, false) {
1040                    let mut catalog = self.constraint_catalog_write();
1041                    let _ = catalog.try_drop(&def.name, true);
1042                    return Err(CreateConstraintError::BackingIndexConflict(err.to_string()));
1043                }
1044            }
1045            self.emit(|| crate::MutationEvent::CreateConstraint {
1046                request: request_for_event,
1047                if_not_exists,
1048            });
1049            self.active_constraints.fetch_add(1, Ordering::Relaxed);
1050        }
1051
1052        Ok(outcome)
1053    }
1054
1055    /// Replay a CreateConstraint event against a recorder-detached graph.
1056    #[doc(hidden)]
1057    pub fn replay_create_constraint(
1058        &mut self,
1059        request: ConstraintRequest,
1060        if_not_exists: bool,
1061    ) -> Result<(), String> {
1062        if self.recorder.is_some() {
1063            return Err(
1064                "cannot replay create_constraint while a mutation recorder is installed".into(),
1065            );
1066        }
1067        self.register_constraint(request, if_not_exists)
1068            .map(|_| ())
1069            .map_err(|e| e.to_string())
1070    }
1071
1072    /// Replay a DropConstraint event.
1073    #[doc(hidden)]
1074    pub fn replay_drop_constraint(&mut self, name: &str, if_exists: bool) -> Result<(), String> {
1075        if self.recorder.is_some() {
1076            return Err(
1077                "cannot replay drop_constraint while a mutation recorder is installed".into(),
1078            );
1079        }
1080        self.drop_named_constraint(name, if_exists)
1081            .map(|_| ())
1082            .map_err(|e| e.to_string())
1083    }
1084
1085    /// Inverse of [`Self::register_constraint`]. Cascades to the backing
1086    /// range index when one is owned.
1087    pub(super) fn drop_named_constraint(
1088        &self,
1089        name: &str,
1090        if_exists: bool,
1091    ) -> Result<DropConstraintOutcome, DropConstraintError> {
1092        let outcome = {
1093            let mut catalog = self.constraint_catalog_write();
1094            catalog.try_drop(name, if_exists)?
1095        };
1096        if let DropConstraintOutcome::Dropped(def) = &outcome {
1097            if let Some(index_name) = def.owned_index.as_deref() {
1098                // The backing index is owned exclusively by the
1099                // constraint, so dropping it is unconditional.
1100                let _ = self.drop_named_index_inner(index_name, true, false);
1101            }
1102            self.active_constraints.fetch_sub(1, Ordering::Relaxed);
1103            self.emit(|| crate::MutationEvent::DropConstraint {
1104                name: name.to_string(),
1105                if_exists,
1106            });
1107        }
1108        Ok(outcome)
1109    }
1110
1111    /// Inverse of [`Self::register_index`]. Removes the catalog entry
1112    /// and (for RANGE) leaves the underlying property-index buckets in
1113    /// place — they may still be needed for lazy-activation lookups
1114    /// even after the explicit DDL declaration is gone.
1115    pub(super) fn drop_named_index(
1116        &self,
1117        name: &str,
1118        if_exists: bool,
1119    ) -> Result<DropIndexOutcome, DropIndexError> {
1120        self.drop_named_index_inner(name, if_exists, true)
1121    }
1122
1123    fn drop_named_index_inner(
1124        &self,
1125        name: &str,
1126        if_exists: bool,
1127        emit_event: bool,
1128    ) -> Result<DropIndexOutcome, DropIndexError> {
1129        if let Some(owner) = self
1130            .constraint_catalog_read()
1131            .constraint_owning_index(name)
1132            .cloned()
1133        {
1134            return Err(DropIndexError::ConstraintOwned {
1135                index: name.to_string(),
1136                constraint: owner.name,
1137            });
1138        }
1139
1140        let outcome = {
1141            let mut catalog = self.index_catalog_write();
1142            catalog.try_drop(name, if_exists)?
1143        };
1144        if let DropIndexOutcome::Dropped(def) = &outcome {
1145            // Release backing structures keyed off the dropped def.
1146            match def.kind {
1147                StoredIndexKind::Text => {
1148                    if let Some(label) = def.label.as_deref() {
1149                        for prop in &def.properties {
1150                            self.deactivate_text_scope(def.entity, label, prop);
1151                        }
1152                    }
1153                }
1154                StoredIndexKind::Range => {
1155                    if let Some(label) = def.label.as_deref() {
1156                        for prop in &def.properties {
1157                            self.deactivate_sorted_scope(def.entity, label, prop);
1158                        }
1159                    }
1160                }
1161                StoredIndexKind::Point => {
1162                    if let Some(label) = def.label.as_deref() {
1163                        for prop in &def.properties {
1164                            self.deactivate_point_scope(def.entity, label, prop);
1165                        }
1166                    }
1167                }
1168                StoredIndexKind::Lookup => {
1169                    // Lookup rides on the eagerly-maintained label/type
1170                    // indexes; nothing to release.
1171                }
1172                StoredIndexKind::Vector => {
1173                    self.deactivate_vector_index(def.entity, &def.name);
1174                }
1175                StoredIndexKind::Fulltext => {
1176                    self.deactivate_fulltext_index(def.entity, &def.name);
1177                }
1178            }
1179            if emit_event {
1180                self.emit(|| crate::MutationEvent::DropIndex {
1181                    name: name.to_string(),
1182                    if_exists,
1183                });
1184            }
1185        }
1186        Ok(outcome)
1187    }
1188
1189    /// `(entity, key)` pairs whose hash property index a declaration keeps
1190    /// active: every property of a RANGE index in the catalog, which
1191    /// includes the backing indexes of uniqueness / key constraints. These
1192    /// are the only hash indexes a restart rebuilds; any other active key
1193    /// was activated implicitly by an equality lookup.
1194    pub(super) fn declared_property_index_keys(
1195        &self,
1196    ) -> std::collections::BTreeSet<(StoredIndexEntity, String)> {
1197        self.index_catalog_read()
1198            .list()
1199            .into_iter()
1200            .filter(|def| def.kind == StoredIndexKind::Range)
1201            .flat_map(|def| {
1202                let entity = def.entity;
1203                def.properties.into_iter().map(move |p| (entity, p))
1204            })
1205            .collect()
1206    }
1207
1208    fn populate_index_data(&self, def: &IndexDefinition) {
1209        // RANGE: piggy-back on the existing lazy property-index buckets.
1210        // TEXT: build a trigram inverted index over the existing entity
1211        //       data for the (label, property) tuple.
1212        // POINT: build a grid-bucket spatial index over the existing
1213        //        entity data.
1214        // LOOKUP: catalog-only; existing label/type indexes already
1215        //         answer the predicates.
1216        match def.kind {
1217            StoredIndexKind::Range => {
1218                for key in &def.properties {
1219                    match def.entity {
1220                        StoredIndexEntity::Node => self.ensure_node_property_index(key),
1221                        StoredIndexEntity::Relationship => {
1222                            self.ensure_relationship_property_index(key)
1223                        }
1224                    }
1225                    if let Some(label) = def.label.as_deref() {
1226                        self.activate_sorted_scope(def.entity, label, key);
1227                    }
1228                }
1229            }
1230            StoredIndexKind::Text => {
1231                let label = match def.label.as_deref() {
1232                    Some(l) => l,
1233                    None => return,
1234                };
1235                for property in &def.properties {
1236                    self.activate_text_scope(def.entity, label, property);
1237                }
1238            }
1239            StoredIndexKind::Point => {
1240                let label = match def.label.as_deref() {
1241                    Some(l) => l,
1242                    None => return,
1243                };
1244                let cell_size = PointRegistry::cell_size_from_options(&def.options);
1245                for property in &def.properties {
1246                    self.activate_point_scope(def.entity, label, property, cell_size);
1247                }
1248            }
1249            StoredIndexKind::Fulltext => {
1250                let labels: Vec<String> = def.all_labels().map(String::from).collect();
1251                if labels.is_empty() {
1252                    return;
1253                }
1254                self.activate_fulltext_index(def.entity, &def.name, &labels, &def.properties);
1255            }
1256            StoredIndexKind::Vector => {
1257                let label = match def.label.as_deref() {
1258                    Some(l) => l,
1259                    None => return,
1260                };
1261                let property = match def.properties.first() {
1262                    Some(p) => p.as_str(),
1263                    None => return,
1264                };
1265                let similarity = VectorSimilarity::from_options(&def.options)
1266                    .unwrap_or(VectorSimilarity::Cosine);
1267                let provider = VectorIndexProvider::from_options(&def.options)
1268                    .unwrap_or(VectorIndexProvider::Flat);
1269                let hnsw = HnswParams::from_options(&def.options);
1270                let lazy = matches!(
1271                    def.options.get("vector.populate.async"),
1272                    Some(super::index_catalog::IndexConfigValue::Bool(true))
1273                );
1274                self.activate_vector_index(
1275                    def.entity, &def.name, label, property, similarity, provider, hnsw, lazy,
1276                );
1277                if lazy {
1278                    self.index_catalog_write()
1279                        .set_state(&def.name, StoredIndexState::Populating);
1280                }
1281            }
1282            // LOOKUP rides on the label/type indexes maintained eagerly.
1283            StoredIndexKind::Lookup => {}
1284        }
1285    }
1286
1287    pub(super) fn text_indexes_read(
1288        &self,
1289        entity: StoredIndexEntity,
1290    ) -> IndexRead<'_, TrigramRegistry> {
1291        self.indexes.text.read(entity)
1292    }
1293
1294    pub(super) fn text_indexes_write(
1295        &self,
1296        entity: StoredIndexEntity,
1297    ) -> IndexWrite<'_, TrigramRegistry> {
1298        self.indexes.text.write(entity)
1299    }
1300
1301    pub(super) fn fulltext_indexes_read(
1302        &self,
1303        entity: StoredIndexEntity,
1304    ) -> IndexRead<'_, FulltextRegistry> {
1305        self.indexes.fulltext.read(entity)
1306    }
1307
1308    #[allow(dead_code)]
1309    pub(super) fn fulltext_indexes_write(
1310        &self,
1311        entity: StoredIndexEntity,
1312    ) -> IndexWrite<'_, FulltextRegistry> {
1313        self.indexes.fulltext.write(entity)
1314    }
1315
1316    fn activate_text_scope(&self, entity: StoredIndexEntity, label: &str, property: &str) {
1317        if !self.text_indexes_write(entity).add_scope(label, property) {
1318            return;
1319        }
1320
1321        let backfill: Vec<(u64, String)> = match entity {
1322            StoredIndexEntity::Node => self
1323                .iter_nodes()
1324                .filter(|(_, node)| node.labels.iter().any(|l| l == label))
1325                .filter_map(|(id, node)| match node.properties.get(property) {
1326                    Some(PropertyValue::String(value)) => Some((id, value.clone())),
1327                    _ => None,
1328                })
1329                .collect(),
1330            StoredIndexEntity::Relationship => self
1331                .iter_rels()
1332                .filter(|(_, rel)| rel.rel_type == label)
1333                .filter_map(|(id, rel)| match rel.properties.get(property) {
1334                    Some(PropertyValue::String(value)) => Some((id, value.clone())),
1335                    _ => None,
1336                })
1337                .collect(),
1338        };
1339
1340        let mut registry = self.text_indexes_write(entity);
1341        for (id, value) in backfill {
1342            registry.insert(label, property, id, &value);
1343        }
1344    }
1345
1346    /// Drop a (label, property) text scope, decrementing the refcount.
1347    pub(super) fn deactivate_text_scope(
1348        &self,
1349        entity: StoredIndexEntity,
1350        label: &str,
1351        property: &str,
1352    ) {
1353        self.text_indexes_write(entity)
1354            .remove_scope(label, property);
1355    }
1356
1357    fn activate_fulltext_index(
1358        &self,
1359        entity: StoredIndexEntity,
1360        name: &str,
1361        labels: &[String],
1362        properties: &[String],
1363    ) {
1364        use super::fulltext_index::{term_counts_for_properties, TermCounts};
1365
1366        {
1367            let mut registry = self.fulltext_indexes_write(entity);
1368            registry.register(name.to_string(), labels.to_vec(), properties.to_vec());
1369        }
1370        self.indexes
1371            .active_fulltext_indexes
1372            .fetch_add(1, Ordering::Relaxed);
1373
1374        // Backfill: walk every entity matching any label, tokenise covered
1375        // string properties, install one posting batch per entity.
1376        let backfill: Vec<(u64, TermCounts)> = match entity {
1377            StoredIndexEntity::Node => self
1378                .iter_nodes()
1379                .filter(|(_, node)| {
1380                    labels
1381                        .iter()
1382                        .any(|wanted| node.labels.iter().any(|l| l == wanted))
1383                })
1384                .map(|(id, node)| {
1385                    let counts = term_counts_for_properties(&node.properties, properties);
1386                    (id, counts)
1387                })
1388                .filter(|(_, c)| !c.is_empty())
1389                .collect(),
1390            StoredIndexEntity::Relationship => self
1391                .iter_rels()
1392                .filter(|(_, rel)| labels.iter().any(|wanted| wanted == &rel.rel_type))
1393                .map(|(id, rel)| {
1394                    let counts = term_counts_for_properties(&rel.properties, properties);
1395                    (id, counts)
1396                })
1397                .filter(|(_, c)| !c.is_empty())
1398                .collect(),
1399        };
1400
1401        let mut registry = self.fulltext_indexes_write(entity);
1402        if let Some(index) = registry.get_mut(name) {
1403            for (id, counts) in backfill {
1404                index.reindex_entity(id, counts);
1405            }
1406        }
1407    }
1408
1409    pub(super) fn deactivate_fulltext_index(&self, entity: StoredIndexEntity, name: &str) {
1410        self.fulltext_indexes_write(entity).deregister(name);
1411        self.indexes
1412            .active_fulltext_indexes
1413            .fetch_sub(1, Ordering::Relaxed);
1414    }
1415
1416    pub(super) fn sorted_indexes_read(
1417        &self,
1418        entity: StoredIndexEntity,
1419    ) -> IndexRead<'_, SortedPropertyIndex> {
1420        self.indexes.sorted.read(entity)
1421    }
1422
1423    pub(super) fn sorted_indexes_write(
1424        &self,
1425        entity: StoredIndexEntity,
1426    ) -> IndexWrite<'_, SortedPropertyIndex> {
1427        self.indexes.sorted.write(entity)
1428    }
1429
1430    fn activate_sorted_scope(&self, entity: StoredIndexEntity, label: &str, property: &str) {
1431        if !self.sorted_indexes_write(entity).add_scope(label, property) {
1432            return;
1433        }
1434
1435        let backfill: Vec<(u64, PropertyValue)> = match entity {
1436            StoredIndexEntity::Node => self
1437                .iter_nodes()
1438                .filter(|(_, node)| node.labels.iter().any(|l| l == label))
1439                .filter_map(|(id, node)| {
1440                    node.properties
1441                        .get(property)
1442                        .map(|value| (id, value.clone()))
1443                })
1444                .collect(),
1445            StoredIndexEntity::Relationship => self
1446                .iter_rels()
1447                .filter(|(_, rel)| rel.rel_type == label)
1448                .filter_map(|(id, rel)| {
1449                    rel.properties
1450                        .get(property)
1451                        .map(|value| (id, value.clone()))
1452                })
1453                .collect(),
1454        };
1455
1456        let mut registry = self.sorted_indexes_write(entity);
1457        for (id, value) in backfill {
1458            registry.insert(label, property, id, &value);
1459        }
1460    }
1461
1462    pub(super) fn deactivate_sorted_scope(
1463        &self,
1464        entity: StoredIndexEntity,
1465        label: &str,
1466        property: &str,
1467    ) {
1468        self.sorted_indexes_write(entity)
1469            .remove_scope(label, property);
1470    }
1471
1472    pub(super) fn point_indexes_read(
1473        &self,
1474        entity: StoredIndexEntity,
1475    ) -> IndexRead<'_, PointRegistry> {
1476        self.indexes.point.read(entity)
1477    }
1478
1479    pub(super) fn point_indexes_write(
1480        &self,
1481        entity: StoredIndexEntity,
1482    ) -> IndexWrite<'_, PointRegistry> {
1483        self.indexes.point.write(entity)
1484    }
1485
1486    fn activate_point_scope(
1487        &self,
1488        entity: StoredIndexEntity,
1489        label: &str,
1490        property: &str,
1491        cell_size: Option<f64>,
1492    ) {
1493        if !self
1494            .point_indexes_write(entity)
1495            .add_scope(label, property, cell_size)
1496        {
1497            return;
1498        }
1499
1500        let backfill: Vec<(u64, LoraPoint)> = match entity {
1501            StoredIndexEntity::Node => self
1502                .iter_nodes()
1503                .filter(|(_, node)| node.labels.iter().any(|l| l == label))
1504                .filter_map(|(id, node)| match node.properties.get(property) {
1505                    Some(PropertyValue::Point(point)) => Some((id, point.clone())),
1506                    _ => None,
1507                })
1508                .collect(),
1509            StoredIndexEntity::Relationship => self
1510                .iter_rels()
1511                .filter(|(_, rel)| rel.rel_type == label)
1512                .filter_map(|(id, rel)| match rel.properties.get(property) {
1513                    Some(PropertyValue::Point(point)) => Some((id, point.clone())),
1514                    _ => None,
1515                })
1516                .collect(),
1517        };
1518
1519        let mut registry = self.point_indexes_write(entity);
1520        for (id, point) in backfill {
1521            registry.insert(label, property, id, point);
1522        }
1523    }
1524
1525    pub(super) fn deactivate_point_scope(
1526        &self,
1527        entity: StoredIndexEntity,
1528        label: &str,
1529        property: &str,
1530    ) {
1531        self.point_indexes_write(entity)
1532            .remove_scope(label, property);
1533    }
1534
1535    pub(super) fn vector_indexes_read(
1536        &self,
1537        entity: StoredIndexEntity,
1538    ) -> IndexRead<'_, VectorIndexRegistry> {
1539        self.indexes.vector.read(entity)
1540    }
1541
1542    pub(super) fn vector_indexes_write(
1543        &self,
1544        entity: StoredIndexEntity,
1545    ) -> IndexWrite<'_, VectorIndexRegistry> {
1546        self.indexes.vector.write(entity)
1547    }
1548
1549    #[allow(clippy::too_many_arguments)]
1550    fn activate_vector_index(
1551        &self,
1552        entity: StoredIndexEntity,
1553        name: &str,
1554        label: &str,
1555        property: &str,
1556        similarity: VectorSimilarity,
1557        provider: VectorIndexProvider,
1558        hnsw: HnswParams,
1559        lazy: bool,
1560    ) {
1561        {
1562            let mut registry = self.vector_indexes_write(entity);
1563            registry.register(
1564                name.to_string(),
1565                label.to_string(),
1566                property.to_string(),
1567                similarity,
1568                provider,
1569                hnsw,
1570            );
1571        }
1572
1573        if lazy {
1574            // Skip the initial backfill — the catalog state is flipped
1575            // to Populating by the caller and the first query routed
1576            // to this index triggers `lazy_populate_vector_index`.
1577            // Mutations between CREATE and first query still feed the
1578            // registry via the maintenance hook, so the lazy phase
1579            // only handles vectors that existed before CREATE.
1580            return;
1581        }
1582
1583        self.backfill_vector_index(entity, label, property);
1584    }
1585
1586    /// Walk the property store for vectors matching this index's
1587    /// `(label, property)` scope and replay them into the registry.
1588    /// Shared by sync CREATE and lazy-populate flows.
1589    fn backfill_vector_index(&self, entity: StoredIndexEntity, label: &str, property: &str) {
1590        let backfill: Vec<(u64, crate::LoraVector)> = match entity {
1591            StoredIndexEntity::Node => self
1592                .iter_nodes()
1593                .filter(|(_, node)| node.labels.iter().any(|l| l == label))
1594                .filter_map(|(id, node)| match node.properties.get(property) {
1595                    Some(PropertyValue::Vector(v)) => Some((id, v.clone())),
1596                    _ => None,
1597                })
1598                .collect(),
1599            StoredIndexEntity::Relationship => self
1600                .iter_rels()
1601                .filter(|(_, rel)| rel.rel_type == label)
1602                .filter_map(|(id, rel)| match rel.properties.get(property) {
1603                    Some(PropertyValue::Vector(v)) => Some((id, v.clone())),
1604                    _ => None,
1605                })
1606                .collect(),
1607        };
1608
1609        let mut registry = self.vector_indexes_write(entity);
1610        for (id, vector) in backfill {
1611            registry.insert_for(label, property, id, &vector);
1612        }
1613    }
1614
1615    /// Lazy-populate a `Populating` vector index: backfill from the
1616    /// property store, then flip catalog state to `Online`. Called
1617    /// from `GraphStorage::vector_search` on the first request that
1618    /// hits a still-populating index. Idempotent: a second concurrent
1619    /// caller finds the state already `Online` and does no work.
1620    pub(super) fn lazy_populate_vector_index(&self, name: &str) {
1621        let def = match self.index_catalog_read().get(name).cloned() {
1622            Some(d) => d,
1623            None => return,
1624        };
1625        if def.state != StoredIndexState::Populating || def.kind != StoredIndexKind::Vector {
1626            return;
1627        }
1628        let label = match def.label.as_deref() {
1629            Some(l) => l,
1630            None => return,
1631        };
1632        let property = match def.properties.first() {
1633            Some(p) => p.as_str(),
1634            None => return,
1635        };
1636        self.backfill_vector_index(def.entity, label, property);
1637        self.index_catalog_write()
1638            .set_state(name, StoredIndexState::Online);
1639    }
1640
1641    pub(super) fn deactivate_vector_index(&self, entity: StoredIndexEntity, name: &str) {
1642        self.vector_indexes_write(entity).deregister(name);
1643    }
1644
1645    /// Snapshot of cardinality stats. Cheap: derived from already-tracked
1646    /// `nodes_by_label` / `relationships_by_type` lengths and the active
1647    /// property-index buckets. The cost model uses this to populate
1648    /// `estimated_rows` on plan-tree nodes.
1649    pub fn graph_stats(&self) -> GraphStats {
1650        let mut stats = GraphStats {
1651            node_count: self.live_node_count,
1652            relationship_count: self.live_rel_count,
1653            ..Default::default()
1654        };
1655        for (label, ids) in &self.nodes_by_label {
1656            stats.nodes_by_label.insert(label.clone(), ids.len());
1657        }
1658        for (rel_type, ids) in &self.relationships_by_type {
1659            stats
1660                .relationships_by_type
1661                .insert(rel_type.clone(), ids.len());
1662        }
1663        // Distinct values per (label, property): pulled from the
1664        // property-index scoped buckets, where we already track the
1665        // per-scope value distribution. Empty for properties without
1666        // an active hash-index — the cost model falls back to a
1667        // conservative estimate in that case.
1668        let prop_indexes = self.indexes_read();
1669        for (scope, props) in prop_indexes.node_properties.scoped_values.iter() {
1670            for (key, values) in props.iter() {
1671                stats
1672                    .node_distinct_values
1673                    .insert((scope.to_string(), key.to_string()), values.len());
1674            }
1675        }
1676        for (scope, props) in prop_indexes.relationship_properties.scoped_values.iter() {
1677            for (key, values) in props.iter() {
1678                stats
1679                    .relationship_distinct_values
1680                    .insert((scope.to_string(), key.to_string()), values.len());
1681            }
1682        }
1683
1684        for def in self.index_catalog_read().list() {
1685            if def.state != StoredIndexState::Online {
1686                continue;
1687            }
1688            let Some(label) = def.label else {
1689                continue;
1690            };
1691            for property in def.properties {
1692                let scope = (label.clone(), property);
1693                match (def.entity, def.kind) {
1694                    (StoredIndexEntity::Node, StoredIndexKind::Range) => {
1695                        stats.node_range_indexes.insert(scope);
1696                    }
1697                    (StoredIndexEntity::Node, StoredIndexKind::Text) => {
1698                        stats.node_text_indexes.insert(scope);
1699                    }
1700                    (StoredIndexEntity::Node, StoredIndexKind::Point) => {
1701                        stats.node_point_indexes.insert(scope);
1702                    }
1703                    (StoredIndexEntity::Relationship, StoredIndexKind::Range) => {
1704                        stats.relationship_range_indexes.insert(scope);
1705                    }
1706                    (StoredIndexEntity::Relationship, StoredIndexKind::Text) => {
1707                        stats.relationship_text_indexes.insert(scope);
1708                    }
1709                    (StoredIndexEntity::Relationship, StoredIndexKind::Point) => {
1710                        stats.relationship_point_indexes.insert(scope);
1711                    }
1712                    (StoredIndexEntity::Node, StoredIndexKind::Vector) => {
1713                        stats.node_vector_indexes.insert(scope);
1714                    }
1715                    (StoredIndexEntity::Relationship, StoredIndexKind::Vector) => {
1716                        stats.relationship_vector_indexes.insert(scope);
1717                    }
1718                    (_, StoredIndexKind::Lookup | StoredIndexKind::Fulltext) => {}
1719                }
1720            }
1721        }
1722        stats
1723    }
1724
1725    /// Approximate retained-heap breakdown of this graph. See
1726    /// [`super::MemoryReport`] for the methodology and per-component
1727    /// fields. Intended for benches and the `mem_probe*` examples;
1728    /// not on a hot path.
1729    pub fn memory_estimate(&self) -> super::MemoryReport {
1730        super::mem_report::estimate(self)
1731    }
1732
1733    pub(super) fn on_node_created(&mut self, node: &NodeRecord) {
1734        for label in &node.labels {
1735            self.insert_node_label_index(node.id, label);
1736        }
1737        self.index_node_properties_if_active(
1738            node.id,
1739            node.labels.iter().map(String::as_str),
1740            &node.properties,
1741        );
1742        self.maintain_node_secondary_indexes(node, SecondaryIndexMutation::Insert);
1743    }
1744
1745    pub(super) fn on_node_property_set(
1746        &mut self,
1747        node_id: NodeId,
1748        key: &str,
1749        old: Option<&PropertyValue>,
1750        new: &PropertyValue,
1751    ) {
1752        let Some(labels) = self.node_at(node_id).map(|node| node.labels.clone()) else {
1753            return;
1754        };
1755
1756        if self.node_property_index_is_active(key) {
1757            if let Some(old) = old {
1758                self.unindex_node_property_if_active(
1759                    node_id,
1760                    labels.iter().map(String::as_str),
1761                    key,
1762                    old,
1763                );
1764            }
1765            self.index_node_property_if_active(
1766                node_id,
1767                labels.iter().map(String::as_str),
1768                key,
1769                new,
1770            );
1771        }
1772
1773        self.update_secondary_property(
1774            StoredIndexEntity::Node,
1775            labels.iter().map(String::as_str),
1776            node_id,
1777            key,
1778            old,
1779            Some(new),
1780        );
1781    }
1782
1783    pub(super) fn on_node_property_removed(
1784        &mut self,
1785        node_id: NodeId,
1786        key: &str,
1787        old: &PropertyValue,
1788    ) {
1789        let Some(labels) = self.node_at(node_id).map(|node| node.labels.clone()) else {
1790            return;
1791        };
1792        if self.node_property_index_is_active(key) {
1793            self.unindex_node_property_if_active(
1794                node_id,
1795                labels.iter().map(String::as_str),
1796                key,
1797                old,
1798            );
1799        }
1800        self.update_secondary_property(
1801            StoredIndexEntity::Node,
1802            labels.iter().map(String::as_str),
1803            node_id,
1804            key,
1805            Some(old),
1806            None,
1807        );
1808    }
1809
1810    pub(super) fn on_node_label_added(&mut self, node_id: NodeId, label: &str) {
1811        self.insert_node_label_index(node_id, label);
1812
1813        let Some(properties) = self.node_at(node_id).map(|node| node.properties.clone()) else {
1814            return;
1815        };
1816        if self.active_node_property_index_count() != 0 {
1817            self.index_node_scope_properties_if_active(node_id, label, &properties);
1818        }
1819        for (key, value) in &properties {
1820            self.update_secondary_property(
1821                StoredIndexEntity::Node,
1822                [label],
1823                node_id,
1824                key,
1825                None,
1826                Some(value),
1827            );
1828        }
1829    }
1830
1831    pub(super) fn on_node_label_removed(&mut self, node_id: NodeId, label: &str) {
1832        self.remove_node_label_index(node_id, label);
1833
1834        let Some(properties) = self.node_at(node_id).map(|node| node.properties.clone()) else {
1835            return;
1836        };
1837        if self.active_node_property_index_count() != 0 {
1838            self.unindex_node_scope_properties_if_active(node_id, label, &properties);
1839        }
1840        for (key, value) in &properties {
1841            self.update_secondary_property(
1842                StoredIndexEntity::Node,
1843                [label],
1844                node_id,
1845                key,
1846                Some(value),
1847                None,
1848            );
1849        }
1850    }
1851
1852    pub(super) fn on_node_deleted(&mut self, node: &NodeRecord) {
1853        for label in &node.labels {
1854            self.remove_node_label_index(node.id, label);
1855        }
1856        self.unindex_active_node_properties(
1857            node.id,
1858            node.labels.iter().map(String::as_str),
1859            &node.properties,
1860        );
1861        self.maintain_node_secondary_indexes(node, SecondaryIndexMutation::Remove);
1862    }
1863
1864    pub(super) fn on_relationship_created(&mut self, rel: &RelationshipRecord) {
1865        self.attach_relationship(rel);
1866        self.index_relationship_properties_if_active(
1867            rel.id,
1868            [rel.rel_type.as_str()],
1869            &rel.properties,
1870        );
1871        self.maintain_relationship_secondary_indexes(rel, SecondaryIndexMutation::Insert);
1872    }
1873
1874    pub(super) fn on_relationship_property_set(
1875        &mut self,
1876        rel_id: RelationshipId,
1877        key: &str,
1878        old: Option<&PropertyValue>,
1879        new: &PropertyValue,
1880    ) {
1881        let Some(rel_type) = self.rel_at(rel_id).map(|rel| rel.rel_type.clone()) else {
1882            return;
1883        };
1884
1885        if self.relationship_property_index_is_active(key) {
1886            if let Some(old) = old {
1887                self.unindex_relationship_property_if_active(rel_id, [rel_type.as_str()], key, old);
1888            }
1889            self.index_relationship_property_if_active(rel_id, [rel_type.as_str()], key, new);
1890        }
1891
1892        self.update_secondary_property(
1893            StoredIndexEntity::Relationship,
1894            [rel_type.as_str()],
1895            rel_id,
1896            key,
1897            old,
1898            Some(new),
1899        );
1900    }
1901
1902    pub(super) fn on_relationship_property_removed(
1903        &mut self,
1904        rel_id: RelationshipId,
1905        key: &str,
1906        old: &PropertyValue,
1907    ) {
1908        let Some(rel_type) = self.rel_at(rel_id).map(|rel| rel.rel_type.clone()) else {
1909            return;
1910        };
1911        if self.relationship_property_index_is_active(key) {
1912            self.unindex_relationship_property_if_active(rel_id, [rel_type.as_str()], key, old);
1913        }
1914        self.update_secondary_property(
1915            StoredIndexEntity::Relationship,
1916            [rel_type.as_str()],
1917            rel_id,
1918            key,
1919            Some(old),
1920            None,
1921        );
1922    }
1923
1924    pub(super) fn on_relationship_deleted(&mut self, rel: &RelationshipRecord) {
1925        self.detach_relationship_indexes(rel);
1926        self.unindex_active_relationship_properties(
1927            rel.id,
1928            [rel.rel_type.as_str()],
1929            &rel.properties,
1930        );
1931        self.maintain_relationship_secondary_indexes(rel, SecondaryIndexMutation::Remove);
1932    }
1933
1934    fn index_node_property_if_active<'a>(
1935        &mut self,
1936        node_id: NodeId,
1937        labels: impl IntoIterator<Item = &'a str>,
1938        key: &str,
1939        value: &PropertyValue,
1940    ) {
1941        if self.active_node_property_index_count() == 0 {
1942            return;
1943        }
1944        let indexes = self.indexes_mut();
1945        if indexes.node_properties.is_active(key) {
1946            indexes
1947                .node_properties
1948                .insert_with_scopes(node_id, labels, key, value);
1949        }
1950    }
1951
1952    fn index_node_properties_if_active<'a>(
1953        &mut self,
1954        node_id: NodeId,
1955        labels: impl IntoIterator<Item = &'a str> + Clone,
1956        properties: &Properties,
1957    ) {
1958        if self.active_node_property_index_count() == 0 {
1959            return;
1960        }
1961        let indexes = self.indexes_mut();
1962        for (key, value) in properties {
1963            if indexes.node_properties.is_active(key) {
1964                indexes
1965                    .node_properties
1966                    .insert_with_scopes(node_id, labels.clone(), key, value);
1967            }
1968        }
1969    }
1970
1971    fn unindex_node_property_if_active<'a>(
1972        &mut self,
1973        node_id: NodeId,
1974        labels: impl IntoIterator<Item = &'a str>,
1975        key: &str,
1976        value: &PropertyValue,
1977    ) {
1978        if self.active_node_property_index_count() == 0 {
1979            return;
1980        }
1981        let indexes = self.indexes_mut();
1982        if indexes.node_properties.is_active(key) {
1983            indexes
1984                .node_properties
1985                .remove_with_scopes(node_id, labels, key, value);
1986        }
1987    }
1988
1989    fn index_node_scope_properties_if_active(
1990        &mut self,
1991        node_id: NodeId,
1992        scope: &str,
1993        properties: &Properties,
1994    ) {
1995        if self.active_node_property_index_count() == 0 {
1996            return;
1997        }
1998        let indexes = self.indexes_mut();
1999        for (key, value) in properties {
2000            if indexes.node_properties.is_active(key) {
2001                indexes
2002                    .node_properties
2003                    .insert_scoped(node_id, scope, key, value);
2004            }
2005        }
2006    }
2007
2008    fn unindex_node_scope_properties_if_active(
2009        &mut self,
2010        node_id: NodeId,
2011        scope: &str,
2012        properties: &Properties,
2013    ) {
2014        if self.active_node_property_index_count() == 0 {
2015            return;
2016        }
2017        let indexes = self.indexes_mut();
2018        for (key, value) in properties {
2019            if indexes.node_properties.is_active(key) {
2020                indexes
2021                    .node_properties
2022                    .remove_scoped(node_id, scope, key, value);
2023            }
2024        }
2025    }
2026
2027    fn unindex_active_node_properties<'a>(
2028        &mut self,
2029        node_id: NodeId,
2030        labels: impl IntoIterator<Item = &'a str> + Clone,
2031        properties: &Properties,
2032    ) {
2033        if self.active_node_property_index_count() == 0 {
2034            return;
2035        }
2036        let indexes = self.indexes_mut();
2037        for (key, value) in properties {
2038            if indexes.node_properties.is_active(key) {
2039                indexes
2040                    .node_properties
2041                    .remove_with_scopes(node_id, labels.clone(), key, value);
2042            }
2043        }
2044    }
2045
2046    fn index_relationship_property_if_active<'a>(
2047        &mut self,
2048        rel_id: RelationshipId,
2049        scopes: impl IntoIterator<Item = &'a str>,
2050        key: &str,
2051        value: &PropertyValue,
2052    ) {
2053        if self.active_relationship_property_index_count() == 0 {
2054            return;
2055        }
2056        let indexes = self.indexes_mut();
2057        if indexes.relationship_properties.is_active(key) {
2058            indexes
2059                .relationship_properties
2060                .insert_with_scopes(rel_id, scopes, key, value);
2061        }
2062    }
2063
2064    fn index_relationship_properties_if_active<'a>(
2065        &mut self,
2066        rel_id: RelationshipId,
2067        scopes: impl IntoIterator<Item = &'a str> + Clone,
2068        properties: &Properties,
2069    ) {
2070        if self.active_relationship_property_index_count() == 0 {
2071            return;
2072        }
2073        let indexes = self.indexes_mut();
2074        for (key, value) in properties {
2075            if indexes.relationship_properties.is_active(key) {
2076                indexes.relationship_properties.insert_with_scopes(
2077                    rel_id,
2078                    scopes.clone(),
2079                    key,
2080                    value,
2081                );
2082            }
2083        }
2084    }
2085
2086    fn unindex_relationship_property_if_active<'a>(
2087        &mut self,
2088        rel_id: RelationshipId,
2089        scopes: impl IntoIterator<Item = &'a str>,
2090        key: &str,
2091        value: &PropertyValue,
2092    ) {
2093        if self.active_relationship_property_index_count() == 0 {
2094            return;
2095        }
2096        let indexes = self.indexes_mut();
2097        if indexes.relationship_properties.is_active(key) {
2098            indexes
2099                .relationship_properties
2100                .remove_with_scopes(rel_id, scopes, key, value);
2101        }
2102    }
2103
2104    fn unindex_active_relationship_properties<'a>(
2105        &mut self,
2106        rel_id: RelationshipId,
2107        scopes: impl IntoIterator<Item = &'a str> + Clone,
2108        properties: &Properties,
2109    ) {
2110        if self.active_relationship_property_index_count() == 0 {
2111            return;
2112        }
2113        let indexes = self.indexes_mut();
2114        for (key, value) in properties {
2115            if indexes.relationship_properties.is_active(key) {
2116                indexes.relationship_properties.remove_with_scopes(
2117                    rel_id,
2118                    scopes.clone(),
2119                    key,
2120                    value,
2121                );
2122            }
2123        }
2124    }
2125
2126    pub(super) fn scan_nodes_by_property(
2127        &self,
2128        label: Option<&str>,
2129        key: &str,
2130        value: &PropertyValue,
2131    ) -> Vec<NodeRecord> {
2132        match label {
2133            Some(label) => self
2134                .nodes_by_label
2135                .get(label)
2136                .into_iter()
2137                .flat_map(|ids| ids.iter())
2138                .filter_map(|&id| self.node_at(id))
2139                .filter(|node| node.properties.get(key) == Some(value))
2140                .cloned()
2141                .collect(),
2142            None => self
2143                .iter_node_records()
2144                .filter(|node| node.properties.get(key) == Some(value))
2145                .cloned()
2146                .collect(),
2147        }
2148    }
2149
2150    pub(super) fn scan_node_ids_by_property(
2151        &self,
2152        label: Option<&str>,
2153        key: &str,
2154        value: &PropertyValue,
2155    ) -> Vec<NodeId> {
2156        match label {
2157            Some(label) => self
2158                .nodes_by_label
2159                .get(label)
2160                .into_iter()
2161                .flat_map(|ids| ids.iter())
2162                .filter_map(|&id| {
2163                    (self.node_at(id)?.properties.get(key) == Some(value)).then_some(id)
2164                })
2165                .collect(),
2166            None => self
2167                .iter_nodes()
2168                .filter_map(|(id, node)| (node.properties.get(key) == Some(value)).then_some(id))
2169                .collect(),
2170        }
2171    }
2172
2173    pub(super) fn any_node_by_property(
2174        &self,
2175        label: &str,
2176        key: &str,
2177        value: &PropertyValue,
2178    ) -> bool {
2179        self.nodes_by_label
2180            .get(label)
2181            .into_iter()
2182            .flat_map(|ids| ids.iter())
2183            .filter_map(|&id| self.node_at(id))
2184            .any(|node| node.properties.get(key) == Some(value))
2185    }
2186
2187    pub(super) fn scan_relationships_by_property(
2188        &self,
2189        rel_type: Option<&str>,
2190        key: &str,
2191        value: &PropertyValue,
2192    ) -> Vec<RelationshipRecord> {
2193        match rel_type {
2194            Some(rel_type) => self
2195                .relationships_by_type
2196                .get(rel_type)
2197                .into_iter()
2198                .flat_map(|ids| ids.iter())
2199                .filter_map(|&id| self.rel_at(id))
2200                .filter(|rel| rel.properties.get(key) == Some(value))
2201                .cloned()
2202                .collect(),
2203            None => self
2204                .iter_rel_records()
2205                .filter(|rel| rel.properties.get(key) == Some(value))
2206                .cloned()
2207                .collect(),
2208        }
2209    }
2210
2211    pub(super) fn scan_relationship_ids_by_property(
2212        &self,
2213        rel_type: Option<&str>,
2214        key: &str,
2215        value: &PropertyValue,
2216    ) -> Vec<RelationshipId> {
2217        match rel_type {
2218            Some(rel_type) => self
2219                .relationships_by_type
2220                .get(rel_type)
2221                .into_iter()
2222                .flat_map(|ids| ids.iter())
2223                .filter_map(|&id| {
2224                    (self.rel_at(id)?.properties.get(key) == Some(value)).then_some(id)
2225                })
2226                .collect(),
2227            None => self
2228                .iter_rels()
2229                .filter_map(|(id, rel)| (rel.properties.get(key) == Some(value)).then_some(id))
2230                .collect(),
2231        }
2232    }
2233
2234    pub(super) fn any_relationship_by_property(
2235        &self,
2236        rel_type: &str,
2237        key: &str,
2238        value: &PropertyValue,
2239    ) -> bool {
2240        self.relationships_by_type
2241            .get(rel_type)
2242            .into_iter()
2243            .flat_map(|ids| ids.iter())
2244            .filter_map(|&id| self.rel_at(id))
2245            .any(|rel| rel.properties.get(key) == Some(value))
2246    }
2247
2248    pub(super) fn attach_relationship(&mut self, rel: &RelationshipRecord) {
2249        self.outgoing_push(rel.src, rel.id);
2250        self.incoming_push(rel.dst, rel.id);
2251        self.insert_relationship_type_index(rel.id, &rel.rel_type);
2252    }
2253
2254    fn detach_relationship_indexes(&mut self, rel: &RelationshipRecord) {
2255        // Adjacency is now positional `Vec<Vec<RelationshipId>>` — clearing
2256        // the inner Vec leaves the slot in place (the slot is sized for the
2257        // node's lifetime, not the edge's).
2258        self.outgoing_remove(rel.src, rel.id);
2259        self.incoming_remove(rel.dst, rel.id);
2260
2261        self.remove_relationship_type_index(rel.id, &rel.rel_type);
2262    }
2263
2264    pub(super) fn relationship_ids_for_direction(
2265        &self,
2266        node_id: NodeId,
2267        direction: Direction,
2268    ) -> Vec<RelationshipId> {
2269        match direction {
2270            Direction::Left => self
2271                .incoming_at(node_id)
2272                .map(<[_]>::to_vec)
2273                .unwrap_or_default(),
2274
2275            Direction::Right => self
2276                .outgoing_at(node_id)
2277                .map(<[_]>::to_vec)
2278                .unwrap_or_default(),
2279
2280            Direction::Undirected => {
2281                let out = self.outgoing_at(node_id);
2282                let inc = self.incoming_at(node_id);
2283                let mut ids = Vec::with_capacity(
2284                    out.map(<[_]>::len).unwrap_or(0) + inc.map(<[_]>::len).unwrap_or(0),
2285                );
2286
2287                if let Some(out) = out {
2288                    ids.extend(out.iter().copied());
2289                }
2290                if let Some(inc) = inc {
2291                    for &rel_id in inc {
2292                        let Some(rel) = self.rel_at(rel_id) else {
2293                            continue;
2294                        };
2295                        if rel.src == node_id && rel.dst == node_id {
2296                            continue;
2297                        }
2298                        ids.push(rel_id);
2299                    }
2300                }
2301
2302                ids
2303            }
2304        }
2305    }
2306
2307    pub(super) fn other_endpoint(rel: &RelationshipRecord, node_id: NodeId) -> Option<NodeId> {
2308        if rel.src == node_id {
2309            Some(rel.dst)
2310        } else if rel.dst == node_id {
2311            Some(rel.src)
2312        } else {
2313            None
2314        }
2315    }
2316
2317    pub(super) fn has_incident_relationships(&self, node_id: NodeId) -> bool {
2318        self.outgoing_at(node_id)
2319            .map(|ids| !ids.is_empty())
2320            .unwrap_or(false)
2321            || self
2322                .incoming_at(node_id)
2323                .map(|ids| !ids.is_empty())
2324                .unwrap_or(false)
2325    }
2326
2327    pub(super) fn incident_relationship_ids(&self, node_id: NodeId) -> Vec<RelationshipId> {
2328        let out = self.outgoing_at(node_id);
2329        let inc = self.incoming_at(node_id);
2330        let mut rel_ids =
2331            Vec::with_capacity(out.map(<[_]>::len).unwrap_or(0) + inc.map(<[_]>::len).unwrap_or(0));
2332
2333        if let Some(ids) = out {
2334            rel_ids.extend(ids.iter().copied());
2335        }
2336        if let Some(ids) = inc {
2337            for &rel_id in ids {
2338                let Some(rel) = self.rel_at(rel_id) else {
2339                    continue;
2340                };
2341                if rel.src == node_id && rel.dst == node_id {
2342                    continue;
2343                }
2344                rel_ids.push(rel_id);
2345            }
2346        }
2347
2348        rel_ids
2349    }
2350
2351    /// Replay a node creation using the id captured in a durable mutation
2352    /// event. This intentionally does not emit a new mutation event: callers
2353    /// must invoke it before installing a recorder on the graph.
2354    #[doc(hidden)]
2355    pub fn replay_create_node(
2356        &mut self,
2357        id: NodeId,
2358        labels: Vec<String>,
2359        properties: Properties,
2360    ) -> Result<NodeRecord, String> {
2361        if self.recorder.is_some() {
2362            return Err(
2363                "cannot replay node creation while a mutation recorder is installed".into(),
2364            );
2365        }
2366        if self.node_at(id).is_some() {
2367            return Err(format!("node id {id} already exists"));
2368        }
2369        let idx = self.ensure_node_slot_checked(id)?;
2370        self.bump_next_node_id_past(id)?;
2371
2372        let labels = Self::normalize_labels(labels);
2373        let node = NodeRecord {
2374            id,
2375            labels: labels.clone(),
2376            properties,
2377        };
2378
2379        self.put_node_at_slot(idx, node.clone());
2380        // Same index maintenance as a live create: only hash indexes that
2381        // are already active (declared by a replayed CREATE INDEX /
2382        // CREATE CONSTRAINT, which backfills from the data replayed so
2383        // far) are kept current. Lookup-activated (implicit) indexes are
2384        // rebuilt lazily on first use, exactly as in a fresh process.
2385        self.on_node_created(&node);
2386
2387        Ok(node)
2388    }
2389
2390    /// Replay a relationship creation using the id captured in a durable
2391    /// mutation event. This intentionally does not emit a new mutation event:
2392    /// callers must invoke it before installing a recorder on the graph.
2393    #[doc(hidden)]
2394    pub fn replay_create_relationship(
2395        &mut self,
2396        id: RelationshipId,
2397        src: NodeId,
2398        dst: NodeId,
2399        rel_type: &str,
2400        properties: Properties,
2401    ) -> Result<RelationshipRecord, String> {
2402        if self.recorder.is_some() {
2403            return Err(
2404                "cannot replay relationship creation while a mutation recorder is installed".into(),
2405            );
2406        }
2407        if self.rel_at(id).is_some() {
2408            return Err(format!("relationship id {id} already exists"));
2409        }
2410        if self.node_at(src).is_none() {
2411            return Err(format!(
2412                "relationship {id} references missing source node {src}"
2413            ));
2414        }
2415        if self.node_at(dst).is_none() {
2416            return Err(format!(
2417                "relationship {id} references missing target node {dst}"
2418            ));
2419        }
2420
2421        let trimmed = rel_type.trim();
2422        if trimmed.is_empty() {
2423            return Err(format!("relationship {id} has an empty type"));
2424        }
2425        let idx = self.ensure_rel_slot_checked(id)?;
2426        self.bump_next_rel_id_past(id)?;
2427
2428        let rel = RelationshipRecord {
2429            id,
2430            src,
2431            dst,
2432            rel_type: trimmed.to_string(),
2433            properties,
2434        };
2435
2436        self.put_rel_at_slot(idx, rel.clone());
2437        // See `replay_create_node`: active (declared) indexes only.
2438        self.on_relationship_created(&rel);
2439
2440        Ok(rel)
2441    }
2442
2443    #[cfg(test)]
2444    pub(super) fn assert_property_indexes_match_scan(&self) {
2445        let indexes = self.indexes_read();
2446        assert_eq!(
2447            indexes.node_properties.active_keys.len(),
2448            self.active_node_property_index_count(),
2449            "node property index counter diverged from active key set"
2450        );
2451        assert_eq!(
2452            indexes.relationship_properties.active_keys.len(),
2453            self.active_relationship_property_index_count(),
2454            "relationship property index counter diverged from active key set"
2455        );
2456
2457        let mut expected_nodes = PropertyIndexState {
2458            active_keys: indexes.node_properties.active_keys.clone(),
2459            ..PropertyIndexState::default()
2460        };
2461        for (id, node) in self.iter_nodes() {
2462            for (key, value) in &node.properties {
2463                if expected_nodes.is_active(key) {
2464                    expected_nodes.insert_with_scopes(
2465                        id,
2466                        node.labels.iter().map(String::as_str),
2467                        key,
2468                        value,
2469                    );
2470                }
2471            }
2472        }
2473        assert_eq!(
2474            indexes.node_properties.values, expected_nodes.values,
2475            "node property index values diverged from scan"
2476        );
2477        assert_eq!(
2478            indexes.node_properties.scoped_values, expected_nodes.scoped_values,
2479            "node property scoped index values diverged from scan"
2480        );
2481
2482        let mut expected_relationships = PropertyIndexState {
2483            active_keys: indexes.relationship_properties.active_keys.clone(),
2484            ..PropertyIndexState::default()
2485        };
2486        for (id, rel) in self.iter_rels() {
2487            for (key, value) in &rel.properties {
2488                if expected_relationships.is_active(key) {
2489                    expected_relationships.insert_with_scopes(
2490                        id,
2491                        [rel.rel_type.as_str()],
2492                        key,
2493                        value,
2494                    );
2495                }
2496            }
2497        }
2498        assert_eq!(
2499            indexes.relationship_properties.values, expected_relationships.values,
2500            "relationship property index values diverged from scan"
2501        );
2502        assert_eq!(
2503            indexes.relationship_properties.scoped_values, expected_relationships.scoped_values,
2504            "relationship property scoped index values diverged from scan"
2505        );
2506    }
2507}