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::{IndexBundle, IndexRead, IndexWrite};
22use super::fulltext_index::FulltextRegistry;
23use super::hnsw::HnswParams;
24use super::index_catalog::{
25    CreateIndexError, CreateIndexOutcome, DropIndexError, DropIndexOutcome, IndexCatalog,
26    IndexDefinition, IndexRequest, StoredIndexEntity, StoredIndexKind, StoredIndexState,
27};
28use super::point_index::PointRegistry;
29#[cfg(test)]
30use super::property_index::PropertyIndexState;
31use super::property_index::{PropertyIndexKey, PropertyIndexRegistry};
32use super::secondary_index_maintenance::SecondaryIndexMutation;
33use super::sorted_property_index::SortedPropertyIndex;
34use super::stats::GraphStats;
35use super::text_index::TrigramRegistry;
36use super::vector_index::{VectorIndexProvider, VectorIndexRegistry, VectorSimilarity};
37
38/// Per-node adjacency list. Two relationship ids fit inline in the same
39/// 24 bytes a `Vec` header takes, so the low-degree nodes that make up
40/// most graphs (chains, trees, sparse social graphs) need no heap
41/// allocation for their edges: less memory, one fewer pointer chase per
42/// hop, and a graph clone that copies them with `memcpy` instead of one
43/// `malloc` per node.
44pub(super) type AdjList = smallvec::SmallVec<RelationshipId, 2>;
45
46#[derive(Default)]
47pub struct InMemoryGraph {
48    pub(super) next_node_id: NodeId,
49    pub(super) next_rel_id: RelationshipId,
50
51    /// Slot-indexed node storage: `nodes[id as usize]` is the record at `id`.
52    /// `None` slots are tombstones from deletes (we don't compact). Because
53    /// `next_node_id` is monotonic the slot at `id` is initialized exactly
54    /// when `id < next_node_id` — same identity guarantee the previous
55    /// `BTreeMap<NodeId, NodeRecord>` had, just with O(1) lookup and
56    /// cache-coherent layout.
57    ///
58    /// Records are wrapped in `Arc` so [`Self::clone`] (called on every
59    /// auto-commit write to build a working copy) is `O(N)` atomic
60    /// refcount bumps instead of `O(N)` deep record clones — for a
61    /// 100k-node graph the difference is microseconds vs. tens of
62    /// milliseconds. Mutating a record uses `Arc::make_mut`, which
63    /// clones in place when the refcount is 1 (no concurrent reader)
64    /// and falls back to a single-record clone-on-write when readers
65    /// still hold a snapshot.
66    pub(super) nodes: ChunkedVec<Option<Arc<NodeRecord>>>,
67    pub(super) relationships: ChunkedVec<Option<Arc<RelationshipRecord>>>,
68    /// Live (non-tombstoned) counts kept in sync with `put_*`/`take_*` so
69    /// `node_count` / `relationship_count` stay O(1) — without a counter
70    /// they'd have to scan the slab.
71    pub(super) live_node_count: usize,
72    pub(super) live_rel_count: usize,
73
74    /// Adjacency keyed by NodeId. `outgoing[id]` is the list of relationship
75    /// ids that leave `id`; mirrored on `incoming[id]`. Inner `Vec` instead
76    /// of `BTreeSet` because edges are inserted exactly once and traversal
77    /// only needs sequential iteration; the cache-friendly contiguous layout
78    /// shows up on every traversal hop.
79    pub(super) outgoing: ChunkedVec<AdjList>,
80    pub(super) incoming: ChunkedVec<AdjList>,
81
82    // secondary indexes
83    /// Label -> the (unique, monotonic) node ids that carry it. The inner
84    /// `Vec` instead of `BTreeSet` because every node id is inserted at most
85    /// once per label (no dedup needed) and every consumer iterates the
86    /// whole list anyway — contiguous storage iterates faster than a
87    /// tree-of-pointers, and removes via `swap_remove` stay O(degree-of-label).
88    pub(super) nodes_by_label: BTreeMap<String, ChunkedVec<NodeId>>,
89    pub(super) relationships_by_type: BTreeMap<String, ChunkedVec<RelationshipId>>,
90
91    /// All index machinery — the declared-index catalog, hash-bucket
92    /// property registry, and the per-entity-kind secondary index
93    /// registries (text, sorted, point, fulltext) plus their active
94    /// counters — collapsed into one bundle. See [`IndexBundle`] for
95    /// the rationale. The bundle is a packaging-only abstraction:
96    /// every field accessed through `self.indexes.<x>` lives at the
97    /// same address it would have as a top-level field.
98    pub(super) indexes: IndexBundle,
99
100    /// Catalog of explicitly-created constraints (CREATE CONSTRAINT).
101    /// Deliberately not part of [`IndexBundle`] — constraints describe
102    /// data invariants, not indexed access. The fact that uniqueness /
103    /// key constraints back range indexes is handled in the
104    /// constraint code path, not by the bundle's layout.
105    pub(super) constraint_catalog: RwLock<ConstraintCatalog>,
106    /// Fast-path counter for mutation-time constraint checks. Most
107    /// workloads have no constraints installed; this lets the executor
108    /// skip taking the catalog lock in that case.
109    pub(super) active_constraints: AtomicUsize,
110
111    /// Optional mutation observer. When `Some`, every committed mutation
112    /// fans out to this recorder *after* the in-memory state has been
113    /// updated. The recorder is not part of the graph's identity, so Clone
114    /// and snapshot restore both reset it to `None`.
115    pub(super) recorder: Option<Arc<dyn MutationRecorder>>,
116
117    /// Optional sink that sees each node / relationship record just before
118    /// a delete drops it. Change feeds use it to report deleted entities
119    /// without copying the graph. Like the recorder, it is not part of the
120    /// graph's identity and is dropped on clone.
121    pub(super) deleted_sink: Option<Arc<dyn DeletedRecordSink>>,
122}
123
124impl std::fmt::Debug for InMemoryGraph {
125    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
126        f.debug_struct("InMemoryGraph")
127            .field("next_node_id", &self.next_node_id)
128            .field("next_rel_id", &self.next_rel_id)
129            .field("nodes", &self.nodes)
130            .field("relationships", &self.relationships)
131            .field("outgoing", &self.outgoing)
132            .field("incoming", &self.incoming)
133            .field("nodes_by_label", &self.nodes_by_label)
134            .field("relationships_by_type", &self.relationships_by_type)
135            .field("indexes", &self.indexes)
136            .field(
137                "active_node_property_indexes",
138                &self.active_node_property_index_count(),
139            )
140            .field(
141                "active_relationship_property_indexes",
142                &self.active_relationship_property_index_count(),
143            )
144            .field(
145                "index_catalog_entries",
146                &self
147                    .indexes
148                    .catalog
149                    .read()
150                    .map(|c| c.list().len())
151                    .unwrap_or(0),
152            )
153            .field("active_constraints", &self.active_constraint_count())
154            .field(
155                "active_fulltext_indexes",
156                &self.active_fulltext_index_count(),
157            )
158            .field("recorder", &self.recorder.as_ref().map(|_| "installed"))
159            .finish()
160    }
161}
162
163impl Clone for InMemoryGraph {
164    fn clone(&self) -> Self {
165        // Deliberately drop the recorder on clone: a cloned store is a
166        // separate identity; it should not silently share the observer.
167        Self {
168            next_node_id: self.next_node_id,
169            next_rel_id: self.next_rel_id,
170            nodes: self.nodes.clone(),
171            relationships: self.relationships.clone(),
172            live_node_count: self.live_node_count,
173            live_rel_count: self.live_rel_count,
174            outgoing: self.outgoing.clone(),
175            incoming: self.incoming.clone(),
176            nodes_by_label: self.nodes_by_label.clone(),
177            relationships_by_type: self.relationships_by_type.clone(),
178            // IndexBundle::clone deep-copies every owned registry under
179            // its locks, mirroring what the old per-field clones did.
180            // The hash-bucket registry skip-on-empty optimisation is
181            // preserved: `PropertyIndexRegistry::clone` itself is cheap
182            // when no entries exist.
183            indexes: self.indexes.clone(),
184            constraint_catalog: RwLock::new(self.constraint_catalog_read().clone()),
185            active_constraints: AtomicUsize::new(self.active_constraint_count()),
186            recorder: None,
187            deleted_sink: None,
188        }
189    }
190}
191
192impl InMemoryGraph {
193    pub fn new() -> Self {
194        Self::default()
195    }
196
197    /// Kept for API compatibility. The chunked storage allocates full
198    /// chunks as the graph grows, so there is no repeated doubling for a
199    /// capacity hint to avoid.
200    pub fn with_capacity_hint(_nodes: usize, _relationships: usize) -> Self {
201        Self::default()
202    }
203
204    pub fn contains_node(&self, node_id: NodeId) -> bool {
205        self.node_at(node_id).is_some()
206    }
207
208    pub fn contains_relationship(&self, rel_id: RelationshipId) -> bool {
209        self.rel_at(rel_id).is_some()
210    }
211
212    /// Install (or clear) the mutation recorder. Passing `None` detaches any
213    /// currently-installed recorder. The recorder observes every committed
214    /// mutation *after* it has been applied.
215    pub fn set_mutation_recorder(&mut self, recorder: Option<Arc<dyn MutationRecorder>>) {
216        self.recorder = recorder;
217    }
218
219    /// Install (or clear) the [`DeletedRecordSink`].
220    pub fn set_deleted_record_sink(&mut self, sink: Option<Arc<dyn DeletedRecordSink>>) {
221        self.deleted_sink = sink;
222    }
223
224    /// Handle to the currently-installed recorder, if any.
225    pub fn mutation_recorder(&self) -> Option<&Arc<dyn MutationRecorder>> {
226        self.recorder.as_ref()
227    }
228
229    /// Emit a mutation event only if a recorder is installed. The event is
230    /// built lazily — callers pass a closure, so when no recorder is
231    /// attached we pay only a `None` check and the cost of constructing the
232    /// event (labels/properties clones) is avoided.
233    #[inline]
234    pub(super) fn emit<F: FnOnce() -> MutationEvent>(&self, build: F) {
235        if let Some(rec) = &self.recorder {
236            rec.record(build());
237        }
238    }
239
240    fn bump_next_node_id_past(&mut self, id: NodeId) -> Result<(), String> {
241        let next = id
242            .checked_add(1)
243            .ok_or_else(|| format!("node id {id} leaves no valid next node id"))?;
244        self.next_node_id = self.next_node_id.max(next);
245        Ok(())
246    }
247
248    fn bump_next_rel_id_past(&mut self, id: RelationshipId) -> Result<(), String> {
249        let next = id
250            .checked_add(1)
251            .ok_or_else(|| format!("relationship id {id} leaves no valid next relationship id"))?;
252        self.next_rel_id = self.next_rel_id.max(next);
253        Ok(())
254    }
255
256    pub(super) fn try_reserve_next_node_slot(&mut self) -> Option<(NodeId, usize)> {
257        let id = self.next_node_id;
258        let idx = self.ensure_node_slot_checked(id).ok()?;
259        self.bump_next_node_id_past(id).ok()?;
260        Some((id, idx))
261    }
262
263    pub(super) fn try_reserve_next_rel_slot(&mut self) -> Option<(RelationshipId, usize)> {
264        let id = self.next_rel_id;
265        let idx = self.ensure_rel_slot_checked(id).ok()?;
266        self.bump_next_rel_id_past(id).ok()?;
267        Some((id, idx))
268    }
269
270    // ---------- Slab access helpers ----------
271    //
272    // Stand-in for the BTreeMap API the previous storage used. They keep the
273    // call sites readable while the underlying layout is positional Vec.
274
275    #[inline]
276    pub(super) fn node_at(&self, id: NodeId) -> Option<&NodeRecord> {
277        self.nodes
278            .get(Self::slot_index(id)?)
279            .and_then(|s| s.as_ref())
280            .map(|arc| arc.as_ref())
281    }
282
283    /// Mutable handle to a node record, doing copy-on-write only when the
284    /// `Arc` is shared with a concurrent reader. With no readers (the
285    /// common case after a fresh write_store clone), `Arc::make_mut`
286    /// upgrades in place — no record clone.
287    #[inline]
288    pub(super) fn node_at_mut(&mut self, id: NodeId) -> Option<&mut NodeRecord> {
289        self.nodes
290            .get_mut(Self::slot_index(id)?)
291            .and_then(|s| s.as_mut())
292            .map(Arc::make_mut)
293    }
294
295    #[inline]
296    pub(super) fn rel_at(&self, id: RelationshipId) -> Option<&RelationshipRecord> {
297        self.relationships
298            .get(Self::slot_index(id)?)
299            .and_then(|s| s.as_ref())
300            .map(|arc| arc.as_ref())
301    }
302
303    #[inline]
304    pub(super) fn rel_at_mut(&mut self, id: RelationshipId) -> Option<&mut RelationshipRecord> {
305        self.relationships
306            .get_mut(Self::slot_index(id)?)
307            .and_then(|s| s.as_mut())
308            .map(Arc::make_mut)
309    }
310
311    /// Resize the node-keyed Vecs so `id as usize` is in range. Adjacency
312    /// lists are kept in lockstep with `nodes`, so a freshly-grown slot has
313    /// empty outgoing/incoming Vecs ready to receive edges.
314    fn slot_len_for_id(id: u64, kind: &str) -> Result<usize, String> {
315        let idx = usize::try_from(id)
316            .map_err(|_| format!("{kind} id {id} does not fit in usize on this platform"))?;
317        idx.checked_add(1)
318            .ok_or_else(|| format!("{kind} id {id} leaves no valid slab slot"))
319    }
320
321    #[inline]
322    fn slot_index(id: u64) -> Option<usize> {
323        usize::try_from(id).ok()
324    }
325
326    fn ensure_node_slot_checked(&mut self, id: NodeId) -> Result<usize, String> {
327        let target = Self::slot_len_for_id(id, "node")?;
328        if self.nodes.len() < target {
329            let additional = target - self.nodes.len();
330            self.nodes.try_reserve_exact(additional).map_err(|e| {
331                format!("node id {id} requires {target} slots, but allocation failed: {e}")
332            })?;
333            self.outgoing.try_reserve_exact(additional).map_err(|e| {
334                format!(
335                    "node id {id} requires {target} adjacency slots, but allocation failed: {e}"
336                )
337            })?;
338            self.incoming.try_reserve_exact(additional).map_err(|e| {
339                format!(
340                    "node id {id} requires {target} adjacency slots, but allocation failed: {e}"
341                )
342            })?;
343            self.nodes.resize_with(target, || None);
344            self.outgoing.resize_with(target, AdjList::new);
345            self.incoming.resize_with(target, AdjList::new);
346        }
347        Ok(target - 1)
348    }
349
350    fn ensure_rel_slot_checked(&mut self, id: RelationshipId) -> Result<usize, String> {
351        let target = Self::slot_len_for_id(id, "relationship")?;
352        if self.relationships.len() < target {
353            self.relationships
354                .try_reserve_exact(target - self.relationships.len())
355                .map_err(|e| {
356                    format!(
357                        "relationship id {id} requires {target} slots, but allocation failed: {e}"
358                    )
359                })?;
360            self.relationships.resize_with(target, || None);
361        }
362        Ok(target - 1)
363    }
364
365    pub(super) fn put_node_checked(&mut self, id: NodeId, node: NodeRecord) -> Result<(), String> {
366        let idx = self.ensure_node_slot_checked(id)?;
367        self.put_node_at_slot(idx, node);
368        Ok(())
369    }
370
371    pub(super) fn put_rel_checked(
372        &mut self,
373        id: RelationshipId,
374        rel: RelationshipRecord,
375    ) -> Result<(), String> {
376        let idx = self.ensure_rel_slot_checked(id)?;
377        self.put_rel_at_slot(idx, rel);
378        Ok(())
379    }
380
381    pub(super) fn put_node_at_slot(&mut self, idx: usize, node: NodeRecord) {
382        let was_present = self.nodes[idx].is_some();
383        self.nodes[idx] = Some(Arc::new(node));
384        if !was_present {
385            self.live_node_count += 1;
386        }
387    }
388
389    pub(super) fn put_rel_at_slot(&mut self, idx: usize, rel: RelationshipRecord) {
390        let was_present = self.relationships[idx].is_some();
391        self.relationships[idx] = Some(Arc::new(rel));
392        if !was_present {
393            self.live_rel_count += 1;
394        }
395    }
396
397    pub(super) fn take_node(&mut self, id: NodeId) -> Option<NodeRecord> {
398        let idx = Self::slot_index(id)?;
399        let removed = self.nodes.get_mut(idx).and_then(|s| s.take());
400        if removed.is_some() {
401            self.live_node_count -= 1;
402            // Also clear the per-id adjacency entries so the memory is reclaimed
403            // on the typical "delete every node" pattern. We deliberately do not
404            // shrink the outer Vec — leaving the slot lets new ids reuse the
405            // same index without growth churn (and `next_node_id` is monotonic
406            // anyway, so no immediate reuse).
407            if let Some(out) = self.outgoing.get_mut(idx) {
408                out.clear();
409            }
410            if let Some(inc) = self.incoming.get_mut(idx) {
411                inc.clear();
412            }
413        }
414        // Unwrap the Arc — `try_unwrap` returns the inner `NodeRecord`
415        // without cloning when our slab held the only reference, falling
416        // back to a clone only when concurrent readers still hold a
417        // snapshot Arc.
418        removed.map(|arc| Arc::try_unwrap(arc).unwrap_or_else(|arc| (*arc).clone()))
419    }
420
421    pub(super) fn take_rel(&mut self, id: RelationshipId) -> Option<RelationshipRecord> {
422        let idx = Self::slot_index(id)?;
423        let removed = self.relationships.get_mut(idx).and_then(|s| s.take());
424        if removed.is_some() {
425            self.live_rel_count -= 1;
426        }
427        removed.map(|arc| Arc::try_unwrap(arc).unwrap_or_else(|arc| (*arc).clone()))
428    }
429
430    #[inline]
431    pub(super) fn outgoing_at(&self, id: NodeId) -> Option<&[RelationshipId]> {
432        self.outgoing
433            .get(Self::slot_index(id)?)
434            .map(|adj| adj.as_slice())
435    }
436
437    #[inline]
438    pub(super) fn incoming_at(&self, id: NodeId) -> Option<&[RelationshipId]> {
439        self.incoming
440            .get(Self::slot_index(id)?)
441            .map(|adj| adj.as_slice())
442    }
443
444    #[inline]
445    fn try_for_each_adjacent_slice<F, E>(
446        &self,
447        node_id: NodeId,
448        types: &[String],
449        adj: &[RelationshipId],
450        skip_self_loops: bool,
451        visit: &mut F,
452    ) -> Result<(), E>
453    where
454        F: FnMut(RelationshipId, NodeId) -> Result<(), E>,
455    {
456        let single_type = match types {
457            [single] => Some(single.as_str()),
458            _ => None,
459        };
460        let has_type_filter = !types.is_empty();
461
462        for &rel_id in adj {
463            let Some(rel) = self.rel_at(rel_id) else {
464                continue;
465            };
466            if skip_self_loops && rel.src == node_id && rel.dst == node_id {
467                continue;
468            }
469            if let Some(single) = single_type {
470                if rel.rel_type != single {
471                    continue;
472                }
473            } else if has_type_filter && !types.iter().any(|t| t == &rel.rel_type) {
474                continue;
475            }
476            let Some(other_id) = Self::other_endpoint(rel, node_id) else {
477                continue;
478            };
479            visit(rel_id, other_id)?;
480        }
481        Ok(())
482    }
483
484    #[inline]
485    pub(super) fn try_for_each_adjacent_id_unchecked<F, E>(
486        &self,
487        node_id: NodeId,
488        direction: Direction,
489        types: &[String],
490        mut visit: F,
491    ) -> Result<(), E>
492    where
493        F: FnMut(RelationshipId, NodeId) -> Result<(), E>,
494    {
495        match direction {
496            Direction::Right => {
497                if let Some(adj) = self.outgoing_at(node_id) {
498                    self.try_for_each_adjacent_slice(node_id, types, adj, false, &mut visit)?;
499                }
500            }
501            Direction::Left => {
502                if let Some(adj) = self.incoming_at(node_id) {
503                    self.try_for_each_adjacent_slice(node_id, types, adj, false, &mut visit)?;
504                }
505            }
506            Direction::Undirected => {
507                if let Some(adj) = self.outgoing_at(node_id) {
508                    self.try_for_each_adjacent_slice(node_id, types, adj, false, &mut visit)?;
509                }
510                if let Some(adj) = self.incoming_at(node_id) {
511                    self.try_for_each_adjacent_slice(node_id, types, adj, true, &mut visit)?;
512                }
513            }
514        }
515
516        Ok(())
517    }
518
519    #[inline]
520    pub(super) fn try_for_each_adjacent_id<F, E>(
521        &self,
522        node_id: NodeId,
523        direction: Direction,
524        types: &[String],
525        visit: F,
526    ) -> Result<(), E>
527    where
528        F: FnMut(RelationshipId, NodeId) -> Result<(), E>,
529    {
530        if self.node_at(node_id).is_none() {
531            return Ok(());
532        }
533        self.try_for_each_adjacent_id_unchecked(node_id, direction, types, visit)
534    }
535
536    pub(super) fn iter_node_ids(&self) -> impl Iterator<Item = NodeId> + '_ {
537        self.nodes
538            .iter()
539            .enumerate()
540            .filter_map(|(i, slot)| slot.as_ref().map(|_| i as NodeId))
541    }
542
543    pub(super) fn iter_node_records(&self) -> impl Iterator<Item = &NodeRecord> + '_ {
544        self.nodes
545            .iter()
546            .filter_map(|s| s.as_ref())
547            .map(|arc| arc.as_ref())
548    }
549
550    pub(super) fn iter_rel_ids(&self) -> impl Iterator<Item = RelationshipId> + '_ {
551        self.relationships
552            .iter()
553            .enumerate()
554            .filter_map(|(i, slot)| slot.as_ref().map(|_| i as RelationshipId))
555    }
556
557    pub(super) fn iter_rel_records(&self) -> impl Iterator<Item = &RelationshipRecord> + '_ {
558        self.relationships
559            .iter()
560            .filter_map(|s| s.as_ref())
561            .map(|arc| arc.as_ref())
562    }
563
564    pub(super) fn iter_nodes(&self) -> impl Iterator<Item = (NodeId, &NodeRecord)> + '_ {
565        self.nodes
566            .iter()
567            .enumerate()
568            .filter_map(|(i, slot)| slot.as_ref().map(|n| (i as NodeId, n.as_ref())))
569    }
570
571    pub(super) fn iter_rels(
572        &self,
573    ) -> impl Iterator<Item = (RelationshipId, &RelationshipRecord)> + '_ {
574        self.relationships
575            .iter()
576            .enumerate()
577            .filter_map(|(i, slot)| slot.as_ref().map(|r| (i as RelationshipId, r.as_ref())))
578    }
579
580    /// Add `rel_id` to `node_id`'s outgoing list. Relies on the monotonic-id
581    /// invariant: relationship ids are allocated once and never re-used, so
582    /// the bucket can never see a duplicate.
583    fn outgoing_push(&mut self, node_id: NodeId, rel_id: RelationshipId) {
584        if let Ok(idx) = self.ensure_node_slot_checked(node_id) {
585            self.outgoing[idx].push(rel_id);
586        }
587    }
588
589    fn incoming_push(&mut self, node_id: NodeId, rel_id: RelationshipId) {
590        if let Ok(idx) = self.ensure_node_slot_checked(node_id) {
591            self.incoming[idx].push(rel_id);
592        }
593    }
594
595    /// Remove `rel_id` from `node_id`'s outgoing list. `swap_remove` keeps
596    /// the operation O(1) — adjacency order doesn't carry semantic meaning.
597    fn outgoing_remove(&mut self, node_id: NodeId, rel_id: RelationshipId) {
598        if let Some(v) = Self::slot_index(node_id).and_then(|idx| self.outgoing.get_mut(idx)) {
599            if let Some(pos) = v.iter().position(|&id| id == rel_id) {
600                v.swap_remove(pos);
601            }
602        }
603    }
604
605    fn incoming_remove(&mut self, node_id: NodeId, rel_id: RelationshipId) {
606        if let Some(v) = Self::slot_index(node_id).and_then(|idx| self.incoming.get_mut(idx)) {
607            if let Some(pos) = v.iter().position(|&id| id == rel_id) {
608                v.swap_remove(pos);
609            }
610        }
611    }
612
613    pub(super) fn normalize_labels(labels: Vec<String>) -> Vec<String> {
614        let mut seen = BTreeSet::new();
615
616        labels
617            .into_iter()
618            .map(|s| s.trim().to_string())
619            .filter(|s| !s.is_empty())
620            .filter(|s| seen.insert(s.clone()))
621            .collect()
622    }
623
624    pub(super) fn insert_node_label_index(&mut self, node_id: NodeId, label: &str) {
625        // Hot path: skip the `String` alloc when the label bucket already
626        // exists. The monotonic-id invariant on the create path guarantees
627        // `node_id` is unique, so we push unconditionally; the previous
628        // `contains` guard turned bulk CREATE into O(n²).
629        if let Some(bucket) = self.nodes_by_label.get_mut(label) {
630            bucket.push(node_id);
631        } else {
632            self.nodes_by_label
633                .insert(label.to_string(), std::iter::once(node_id).collect());
634        }
635    }
636
637    fn remove_node_label_index(&mut self, node_id: NodeId, label: &str) {
638        if let Some(ids) = self.nodes_by_label.get_mut(label) {
639            let pos = ids.iter().position(|&id| id == node_id);
640            if let Some(pos) = pos {
641                ids.swap_remove(pos);
642            }
643            if ids.is_empty() {
644                self.nodes_by_label.remove(label);
645            }
646        }
647    }
648
649    fn insert_relationship_type_index(&mut self, rel_id: RelationshipId, rel_type: &str) {
650        // See `insert_node_label_index` for the same hot-path rationale.
651        if let Some(bucket) = self.relationships_by_type.get_mut(rel_type) {
652            bucket.push(rel_id);
653        } else {
654            self.relationships_by_type
655                .insert(rel_type.to_string(), std::iter::once(rel_id).collect());
656        }
657    }
658
659    fn remove_relationship_type_index(&mut self, rel_id: RelationshipId, rel_type: &str) {
660        if let Some(ids) = self.relationships_by_type.get_mut(rel_type) {
661            let pos = ids.iter().position(|&id| id == rel_id);
662            if let Some(pos) = pos {
663                ids.swap_remove(pos);
664            }
665            if ids.is_empty() {
666                self.relationships_by_type.remove(rel_type);
667            }
668        }
669    }
670
671    /// Ids of `label` nodes whose `key` equals `value`, from the scoped
672    /// hash index. `None` when that index is not active for `key` or
673    /// `value` has no index image; the caller must then scan.
674    pub(super) fn indexed_node_ids(
675        &self,
676        label: &str,
677        key: &str,
678        value: &PropertyValue,
679    ) -> Option<Vec<NodeId>> {
680        super::property_index::PropertyIndexKey::from_value(value)?;
681        let indexes = self.indexes_read();
682        if !indexes.node_properties.is_active(key) {
683            return None;
684        }
685        Some(
686            indexes
687                .node_properties
688                .scoped_ids_for(label, key, value)
689                .map(|ids| ids.to_vec())
690                .unwrap_or_default(),
691        )
692    }
693
694    /// Relationship counterpart of [`Self::indexed_node_ids`].
695    pub(super) fn indexed_rel_ids(
696        &self,
697        rel_type: &str,
698        key: &str,
699        value: &PropertyValue,
700    ) -> Option<Vec<RelationshipId>> {
701        super::property_index::PropertyIndexKey::from_value(value)?;
702        let indexes = self.indexes_read();
703        if !indexes.relationship_properties.is_active(key) {
704            return None;
705        }
706        Some(
707            indexes
708                .relationship_properties
709                .scoped_ids_for(rel_type, key, value)
710                .map(|ids| ids.to_vec())
711                .unwrap_or_default(),
712        )
713    }
714
715    pub(super) fn indexes_read(&self) -> std::sync::RwLockReadGuard<'_, PropertyIndexRegistry> {
716        self.indexes
717            .properties
718            .read()
719            .unwrap_or_else(|poisoned| poisoned.into_inner())
720    }
721
722    pub(super) fn indexes_write(&self) -> RwLockWriteGuard<'_, PropertyIndexRegistry> {
723        self.indexes
724            .properties
725            .write()
726            .unwrap_or_else(|poisoned| poisoned.into_inner())
727    }
728
729    pub(super) fn indexes_mut(&mut self) -> &mut PropertyIndexRegistry {
730        self.indexes
731            .properties
732            .get_mut()
733            .unwrap_or_else(|poisoned| poisoned.into_inner())
734    }
735
736    #[inline]
737    pub(super) fn active_node_property_index_count(&self) -> usize {
738        self.indexes
739            .active_node_property_indexes
740            .load(Ordering::Relaxed)
741    }
742
743    #[inline]
744    pub(super) fn active_relationship_property_index_count(&self) -> usize {
745        self.indexes
746            .active_relationship_property_indexes
747            .load(Ordering::Relaxed)
748    }
749
750    #[inline]
751    pub(super) fn active_constraint_count(&self) -> usize {
752        self.active_constraints.load(Ordering::Relaxed)
753    }
754
755    #[inline]
756    pub(super) fn has_active_constraints(&self) -> bool {
757        self.active_constraint_count() != 0
758    }
759
760    #[inline]
761    pub(super) fn active_fulltext_index_count(&self) -> usize {
762        self.indexes.active_fulltext_indexes.load(Ordering::Relaxed)
763    }
764
765    #[inline]
766    pub(super) fn has_active_fulltext_indexes(&self) -> bool {
767        self.active_fulltext_index_count() != 0
768    }
769
770    pub(super) fn node_property_index_is_active(&mut self, key: &str) -> bool {
771        self.active_node_property_index_count() != 0
772            && self.indexes_mut().node_properties.is_active(key)
773    }
774
775    pub(super) fn relationship_property_index_is_active(&mut self, key: &str) -> bool {
776        self.active_relationship_property_index_count() != 0
777            && self.indexes_mut().relationship_properties.is_active(key)
778    }
779
780    pub(super) fn ensure_node_property_index(&self, key: &str) {
781        {
782            let indexes = self.indexes_read();
783            if indexes.node_properties.is_active(key) {
784                return;
785            }
786        }
787
788        let mut indexes = self.indexes_write();
789        if indexes.node_properties.is_active(key) {
790            return;
791        }
792
793        for (id, node) in self.iter_nodes() {
794            if let Some(value) = node.properties.get(key) {
795                indexes.node_properties.insert_with_scopes(
796                    id,
797                    node.labels.iter().map(String::as_str),
798                    key,
799                    value,
800                );
801            }
802        }
803        if indexes.node_properties.activate(key) {
804            self.indexes
805                .active_node_property_indexes
806                .fetch_add(1, Ordering::Relaxed);
807        }
808    }
809
810    pub(super) fn ensure_relationship_property_index(&self, key: &str) {
811        {
812            let indexes = self.indexes_read();
813            if indexes.relationship_properties.is_active(key) {
814                return;
815            }
816        }
817
818        let mut indexes = self.indexes_write();
819        if indexes.relationship_properties.is_active(key) {
820            return;
821        }
822
823        for (id, rel) in self.iter_rels() {
824            if let Some(value) = rel.properties.get(key) {
825                indexes.relationship_properties.insert_with_scopes(
826                    id,
827                    [rel.rel_type.as_str()],
828                    key,
829                    value,
830                );
831            }
832        }
833        if indexes.relationship_properties.activate(key) {
834            self.indexes
835                .active_relationship_property_indexes
836                .fetch_add(1, Ordering::Relaxed);
837        }
838    }
839
840    pub(super) fn index_catalog_read(&self) -> std::sync::RwLockReadGuard<'_, IndexCatalog> {
841        self.indexes
842            .catalog
843            .read()
844            .unwrap_or_else(|poisoned| poisoned.into_inner())
845    }
846
847    pub(super) fn index_catalog_write(&self) -> RwLockWriteGuard<'_, IndexCatalog> {
848        self.indexes
849            .catalog
850            .write()
851            .unwrap_or_else(|poisoned| poisoned.into_inner())
852    }
853
854    pub(super) fn constraint_catalog_read(
855        &self,
856    ) -> std::sync::RwLockReadGuard<'_, ConstraintCatalog> {
857        self.constraint_catalog
858            .read()
859            .unwrap_or_else(|poisoned| poisoned.into_inner())
860    }
861
862    pub(super) fn constraint_catalog_write(&self) -> RwLockWriteGuard<'_, ConstraintCatalog> {
863        self.constraint_catalog
864            .write()
865            .unwrap_or_else(|poisoned| poisoned.into_inner())
866    }
867
868    /// Register an explicitly-declared index in the catalog and, when
869    /// applicable, force the underlying property-index buckets to be
870    /// populated so equality lookups can use them immediately.
871    ///
872    /// Named with a `register_` prefix to avoid colliding with the
873    /// trait method `GraphStorageMut::create_index` — the trait impl
874    /// in `impls.rs` delegates here.
875    #[allow(clippy::result_large_err)]
876    pub(super) fn register_index(
877        &self,
878        request: IndexRequest,
879        if_not_exists: bool,
880    ) -> Result<CreateIndexOutcome, CreateIndexError> {
881        self.register_index_with_recording(request, if_not_exists, true)
882    }
883
884    #[allow(clippy::result_large_err)]
885    fn register_index_with_recording(
886        &self,
887        request: IndexRequest,
888        if_not_exists: bool,
889        record_event: bool,
890    ) -> Result<CreateIndexOutcome, CreateIndexError> {
891        let request_for_event = record_event.then(|| request.clone());
892        let outcome = {
893            let mut catalog = self.index_catalog_write();
894            catalog.try_create(request, if_not_exists)?
895        };
896
897        if let CreateIndexOutcome::Created(def) = &outcome {
898            self.populate_index_data(def);
899        }
900
901        // Both Created and NoOpExists are committed catalog states; we
902        // log only Created because NoOpExists implies a redundant DDL
903        // that adds nothing to durable state.
904        if matches!(outcome, CreateIndexOutcome::Created(_)) {
905            if let Some(request_for_event) = request_for_event {
906                self.emit(|| crate::MutationEvent::CreateIndex {
907                    request: request_for_event,
908                    if_not_exists,
909                });
910            }
911        }
912
913        Ok(outcome)
914    }
915
916    /// Replay a CreateIndex event against an empty graph. Mirrors the
917    /// `replay_create_node` shape: callers must invoke before installing
918    /// a recorder so we don't re-emit during recovery.
919    #[doc(hidden)]
920    pub fn replay_create_index(
921        &mut self,
922        request: IndexRequest,
923        if_not_exists: bool,
924    ) -> Result<(), String> {
925        if self.recorder.is_some() {
926            return Err("cannot replay create_index while a mutation recorder is installed".into());
927        }
928        self.register_index(request, if_not_exists)
929            .map(|_| ())
930            .map_err(|e| e.to_string())
931    }
932
933    /// Replay a DropIndex event.
934    #[doc(hidden)]
935    pub fn replay_drop_index(&mut self, name: &str, if_exists: bool) -> Result<(), String> {
936        if self.recorder.is_some() {
937            return Err("cannot replay drop_index while a mutation recorder is installed".into());
938        }
939        self.drop_named_index(name, if_exists)
940            .map(|_| ())
941            .map_err(|e| e.to_string())
942    }
943
944    /// Register a constraint. For uniqueness/key kinds this also
945    /// registers a backing RANGE index in the index catalog under the
946    /// same name. Validation of existing data is the caller's
947    /// responsibility (the enforcement layer runs a pre-create scan
948    /// before this method commits).
949    pub(super) fn register_constraint(
950        &self,
951        request: ConstraintRequest,
952        if_not_exists: bool,
953    ) -> Result<CreateConstraintOutcome, CreateConstraintError> {
954        // Constraint-level conflicts (22N65/66/67) take precedence over
955        // index-catalog conflicts: if the request collides with an
956        // existing *constraint* shape or name, we never get to the
957        // backing-index step.
958        {
959            let constraint_catalog = self.constraint_catalog_read();
960            if let Some(existing) = constraint_catalog.find_equivalent(&request) {
961                let cloned = existing.clone();
962                drop(constraint_catalog);
963                if if_not_exists {
964                    return Ok(CreateConstraintOutcome::NoOpExists(cloned));
965                }
966                return Err(CreateConstraintError::EquivalentConstraintExists(
967                    cloned.name,
968                ));
969            }
970            if let Some(existing) = constraint_catalog.get(&request.name) {
971                let cloned = existing.clone();
972                drop(constraint_catalog);
973                if if_not_exists {
974                    return Ok(CreateConstraintOutcome::NoOpExists(cloned));
975                }
976                return Err(CreateConstraintError::DuplicateName(cloned.name));
977            }
978            if let Some(existing) = constraint_catalog.find_same_schema(&request) {
979                let cloned = existing.clone();
980                drop(constraint_catalog);
981                if super::constraint_catalog::kinds_conflict_for_validation(
982                    &cloned.kind,
983                    &request.kind,
984                ) {
985                    if if_not_exists {
986                        return Ok(CreateConstraintOutcome::NoOpExists(cloned));
987                    }
988                    return Err(CreateConstraintError::ConflictingConstraint(cloned.name));
989                }
990            }
991        }
992
993        // Index-catalog conflicts only matter for constraints that need
994        // a backing range index. The catalog won't yet own one for this
995        // request — that registration happens below — so any existing
996        // entry under the same name or schema is from a foreign index.
997        if request.kind.requires_backing_index() {
998            let idx_catalog = self.index_catalog_read();
999            if idx_catalog.get(&request.name).is_some() {
1000                return Err(CreateConstraintError::DuplicateIndexName(
1001                    request.name.clone(),
1002                ));
1003            }
1004            let conflict = idx_catalog.list().into_iter().find(|def| {
1005                def.kind == StoredIndexKind::Range
1006                    && def.entity == request.entity
1007                    && def.label.as_deref() == Some(request.label.as_str())
1008                    && def.properties == request.properties
1009                    && def.name != request.name
1010            });
1011            drop(idx_catalog);
1012            if let Some(def) = conflict {
1013                return Err(CreateConstraintError::BackingIndexConflict(format!(
1014                    "(:{} {{{}}}) already covered by index `{}`",
1015                    request.label,
1016                    request.properties.join(", "),
1017                    def.name,
1018                )));
1019            }
1020        }
1021
1022        let owns_backing = request.kind.requires_backing_index();
1023        let request_for_event = request.clone();
1024        let outcome = {
1025            let mut catalog = self.constraint_catalog_write();
1026            catalog.try_create(request, if_not_exists)?
1027        };
1028
1029        if let CreateConstraintOutcome::Created(def) = &outcome {
1030            // Pre-create data scan: if the live graph already violates
1031            // the constraint, fail and roll back the catalog write.
1032            // Creating constraints when conflicting data exists fails
1033            // before the catalog change is retained (22N77/79/80).
1034            if let Err(violation) = self.validate_existing_data_for_constraint(def) {
1035                let mut catalog = self.constraint_catalog_write();
1036                let _ = catalog.try_drop(&def.name, true);
1037                return Err(CreateConstraintError::DataViolation(violation.to_string()));
1038            }
1039        }
1040
1041        if let CreateConstraintOutcome::Created(def) = &outcome {
1042            if owns_backing {
1043                // Register a backing RANGE index under the same name. This
1044                // is an implementation detail of the constraint, so WAL and
1045                // snapshot replay record only the constraint mutation.
1046                let idx_request = IndexRequest {
1047                    explicit_name: Some(def.name.clone()),
1048                    kind: StoredIndexKind::Range,
1049                    entity: def.entity,
1050                    label: Some(def.label.clone()),
1051                    additional_labels: Vec::new(),
1052                    properties: def.properties.clone(),
1053                    options: Default::default(),
1054                };
1055                // Errors from the backing-index registration unwind the
1056                // constraint registration to keep the two catalogs in
1057                // step.
1058                if let Err(err) = self.register_index_with_recording(idx_request, true, false) {
1059                    let mut catalog = self.constraint_catalog_write();
1060                    let _ = catalog.try_drop(&def.name, true);
1061                    return Err(CreateConstraintError::BackingIndexConflict(err.to_string()));
1062                }
1063            }
1064            self.emit(|| crate::MutationEvent::CreateConstraint {
1065                request: request_for_event,
1066                if_not_exists,
1067            });
1068            self.active_constraints.fetch_add(1, Ordering::Relaxed);
1069        }
1070
1071        Ok(outcome)
1072    }
1073
1074    /// Replay a CreateConstraint event against a recorder-detached graph.
1075    #[doc(hidden)]
1076    pub fn replay_create_constraint(
1077        &mut self,
1078        request: ConstraintRequest,
1079        if_not_exists: bool,
1080    ) -> Result<(), String> {
1081        if self.recorder.is_some() {
1082            return Err(
1083                "cannot replay create_constraint while a mutation recorder is installed".into(),
1084            );
1085        }
1086        self.register_constraint(request, if_not_exists)
1087            .map(|_| ())
1088            .map_err(|e| e.to_string())
1089    }
1090
1091    /// Replay a DropConstraint event.
1092    #[doc(hidden)]
1093    pub fn replay_drop_constraint(&mut self, name: &str, if_exists: bool) -> Result<(), String> {
1094        if self.recorder.is_some() {
1095            return Err(
1096                "cannot replay drop_constraint while a mutation recorder is installed".into(),
1097            );
1098        }
1099        self.drop_named_constraint(name, if_exists)
1100            .map(|_| ())
1101            .map_err(|e| e.to_string())
1102    }
1103
1104    /// Inverse of [`Self::register_constraint`]. Cascades to the backing
1105    /// range index when one is owned.
1106    pub(super) fn drop_named_constraint(
1107        &self,
1108        name: &str,
1109        if_exists: bool,
1110    ) -> Result<DropConstraintOutcome, DropConstraintError> {
1111        let outcome = {
1112            let mut catalog = self.constraint_catalog_write();
1113            catalog.try_drop(name, if_exists)?
1114        };
1115        if let DropConstraintOutcome::Dropped(def) = &outcome {
1116            if let Some(index_name) = def.owned_index.as_deref() {
1117                // The backing index is owned exclusively by the
1118                // constraint, so dropping it is unconditional.
1119                let _ = self.drop_named_index_inner(index_name, true, false);
1120            }
1121            self.active_constraints.fetch_sub(1, Ordering::Relaxed);
1122            self.emit(|| crate::MutationEvent::DropConstraint {
1123                name: name.to_string(),
1124                if_exists,
1125            });
1126        }
1127        Ok(outcome)
1128    }
1129
1130    /// Inverse of [`Self::register_index`]. Removes the catalog entry
1131    /// and (for RANGE) leaves the underlying property-index buckets in
1132    /// place — they may still be needed for lazy-activation lookups
1133    /// even after the explicit DDL declaration is gone.
1134    pub(super) fn drop_named_index(
1135        &self,
1136        name: &str,
1137        if_exists: bool,
1138    ) -> Result<DropIndexOutcome, DropIndexError> {
1139        self.drop_named_index_inner(name, if_exists, true)
1140    }
1141
1142    fn drop_named_index_inner(
1143        &self,
1144        name: &str,
1145        if_exists: bool,
1146        emit_event: bool,
1147    ) -> Result<DropIndexOutcome, DropIndexError> {
1148        if let Some(owner) = self
1149            .constraint_catalog_read()
1150            .constraint_owning_index(name)
1151            .cloned()
1152        {
1153            return Err(DropIndexError::ConstraintOwned {
1154                index: name.to_string(),
1155                constraint: owner.name,
1156            });
1157        }
1158
1159        let outcome = {
1160            let mut catalog = self.index_catalog_write();
1161            catalog.try_drop(name, if_exists)?
1162        };
1163        if let DropIndexOutcome::Dropped(def) = &outcome {
1164            // Release backing structures keyed off the dropped def.
1165            match def.kind {
1166                StoredIndexKind::Text => {
1167                    if let Some(label) = def.label.as_deref() {
1168                        for prop in &def.properties {
1169                            self.deactivate_text_scope(def.entity, label, prop);
1170                        }
1171                    }
1172                }
1173                StoredIndexKind::Range => {
1174                    if let Some(label) = def.label.as_deref() {
1175                        for prop in &def.properties {
1176                            self.deactivate_sorted_scope(def.entity, label, prop);
1177                        }
1178                    }
1179                }
1180                StoredIndexKind::Point => {
1181                    if let Some(label) = def.label.as_deref() {
1182                        for prop in &def.properties {
1183                            self.deactivate_point_scope(def.entity, label, prop);
1184                        }
1185                    }
1186                }
1187                StoredIndexKind::Lookup => {
1188                    // Lookup rides on the eagerly-maintained label/type
1189                    // indexes; nothing to release.
1190                }
1191                StoredIndexKind::Vector => {
1192                    self.deactivate_vector_index(def.entity, &def.name);
1193                }
1194                StoredIndexKind::Fulltext => {
1195                    self.deactivate_fulltext_index(def.entity, &def.name);
1196                }
1197            }
1198            if emit_event {
1199                self.emit(|| crate::MutationEvent::DropIndex {
1200                    name: name.to_string(),
1201                    if_exists,
1202                });
1203            }
1204        }
1205        Ok(outcome)
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 {
1670            for (key, values) in props {
1671                stats
1672                    .node_distinct_values
1673                    .insert((scope.clone(), key.clone()), values.len());
1674            }
1675        }
1676        for (scope, props) in &prop_indexes.relationship_properties.scoped_values {
1677            for (key, values) in props {
1678                stats
1679                    .relationship_distinct_values
1680                    .insert((scope.clone(), key.clone()), 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 rebuild_property_indexes(&mut self) {
1734        let mut indexes = PropertyIndexRegistry::default();
1735
1736        for (id, node) in self.iter_nodes() {
1737            for (key, value) in &node.properties {
1738                if PropertyIndexKey::from_value(value).is_some() {
1739                    indexes.node_properties.activate(key);
1740                    indexes.node_properties.insert_with_scopes(
1741                        id,
1742                        node.labels.iter().map(String::as_str),
1743                        key,
1744                        value,
1745                    );
1746                }
1747            }
1748        }
1749
1750        for (id, rel) in self.iter_rels() {
1751            for (key, value) in &rel.properties {
1752                if PropertyIndexKey::from_value(value).is_some() {
1753                    indexes.relationship_properties.activate(key);
1754                    indexes.relationship_properties.insert_with_scopes(
1755                        id,
1756                        [rel.rel_type.as_str()],
1757                        key,
1758                        value,
1759                    );
1760                }
1761            }
1762        }
1763
1764        let node_index_count = indexes.node_properties.active_keys.len();
1765        let relationship_index_count = indexes.relationship_properties.active_keys.len();
1766        *self.indexes_mut() = indexes;
1767        self.indexes
1768            .active_node_property_indexes
1769            .store(node_index_count, Ordering::Relaxed);
1770        self.indexes
1771            .active_relationship_property_indexes
1772            .store(relationship_index_count, Ordering::Relaxed);
1773    }
1774
1775    pub(super) fn on_node_created(&mut self, node: &NodeRecord) {
1776        for label in &node.labels {
1777            self.insert_node_label_index(node.id, label);
1778        }
1779        self.index_node_properties_if_active(
1780            node.id,
1781            node.labels.iter().map(String::as_str),
1782            &node.properties,
1783        );
1784        self.maintain_node_secondary_indexes(node, SecondaryIndexMutation::Insert);
1785    }
1786
1787    pub(super) fn on_node_replayed(&mut self, node: &NodeRecord) {
1788        for label in &node.labels {
1789            self.insert_node_label_index(node.id, label);
1790        }
1791        self.index_node_properties_eager(
1792            node.id,
1793            node.labels.iter().map(String::as_str),
1794            &node.properties,
1795        );
1796        self.maintain_node_secondary_indexes(node, SecondaryIndexMutation::Insert);
1797    }
1798
1799    pub(super) fn on_node_property_set(
1800        &mut self,
1801        node_id: NodeId,
1802        key: &str,
1803        old: Option<&PropertyValue>,
1804        new: &PropertyValue,
1805    ) {
1806        let Some(labels) = self.node_at(node_id).map(|node| node.labels.clone()) else {
1807            return;
1808        };
1809
1810        if self.node_property_index_is_active(key) {
1811            if let Some(old) = old {
1812                self.unindex_node_property_if_active(
1813                    node_id,
1814                    labels.iter().map(String::as_str),
1815                    key,
1816                    old,
1817                );
1818            }
1819            self.index_node_property_if_active(
1820                node_id,
1821                labels.iter().map(String::as_str),
1822                key,
1823                new,
1824            );
1825        }
1826
1827        self.update_secondary_property(
1828            StoredIndexEntity::Node,
1829            labels.iter().map(String::as_str),
1830            node_id,
1831            key,
1832            old,
1833            Some(new),
1834        );
1835    }
1836
1837    pub(super) fn on_node_property_removed(
1838        &mut self,
1839        node_id: NodeId,
1840        key: &str,
1841        old: &PropertyValue,
1842    ) {
1843        let Some(labels) = self.node_at(node_id).map(|node| node.labels.clone()) else {
1844            return;
1845        };
1846        if self.node_property_index_is_active(key) {
1847            self.unindex_node_property_if_active(
1848                node_id,
1849                labels.iter().map(String::as_str),
1850                key,
1851                old,
1852            );
1853        }
1854        self.update_secondary_property(
1855            StoredIndexEntity::Node,
1856            labels.iter().map(String::as_str),
1857            node_id,
1858            key,
1859            Some(old),
1860            None,
1861        );
1862    }
1863
1864    pub(super) fn on_node_label_added(&mut self, node_id: NodeId, label: &str) {
1865        self.insert_node_label_index(node_id, label);
1866
1867        let Some(properties) = self.node_at(node_id).map(|node| node.properties.clone()) else {
1868            return;
1869        };
1870        if self.active_node_property_index_count() != 0 {
1871            self.index_node_scope_properties_if_active(node_id, label, &properties);
1872        }
1873        for (key, value) in &properties {
1874            self.update_secondary_property(
1875                StoredIndexEntity::Node,
1876                [label],
1877                node_id,
1878                key,
1879                None,
1880                Some(value),
1881            );
1882        }
1883    }
1884
1885    pub(super) fn on_node_label_removed(&mut self, node_id: NodeId, label: &str) {
1886        self.remove_node_label_index(node_id, label);
1887
1888        let Some(properties) = self.node_at(node_id).map(|node| node.properties.clone()) else {
1889            return;
1890        };
1891        if self.active_node_property_index_count() != 0 {
1892            self.unindex_node_scope_properties_if_active(node_id, label, &properties);
1893        }
1894        for (key, value) in &properties {
1895            self.update_secondary_property(
1896                StoredIndexEntity::Node,
1897                [label],
1898                node_id,
1899                key,
1900                Some(value),
1901                None,
1902            );
1903        }
1904    }
1905
1906    pub(super) fn on_node_deleted(&mut self, node: &NodeRecord) {
1907        for label in &node.labels {
1908            self.remove_node_label_index(node.id, label);
1909        }
1910        self.unindex_active_node_properties(
1911            node.id,
1912            node.labels.iter().map(String::as_str),
1913            &node.properties,
1914        );
1915        self.maintain_node_secondary_indexes(node, SecondaryIndexMutation::Remove);
1916    }
1917
1918    pub(super) fn on_relationship_created(&mut self, rel: &RelationshipRecord) {
1919        self.attach_relationship(rel);
1920        self.index_relationship_properties_if_active(
1921            rel.id,
1922            [rel.rel_type.as_str()],
1923            &rel.properties,
1924        );
1925        self.maintain_relationship_secondary_indexes(rel, SecondaryIndexMutation::Insert);
1926    }
1927
1928    pub(super) fn on_relationship_replayed(&mut self, rel: &RelationshipRecord) {
1929        self.attach_relationship(rel);
1930        self.index_relationship_properties_eager(rel.id, [rel.rel_type.as_str()], &rel.properties);
1931        self.maintain_relationship_secondary_indexes(rel, SecondaryIndexMutation::Insert);
1932    }
1933
1934    pub(super) fn on_relationship_property_set(
1935        &mut self,
1936        rel_id: RelationshipId,
1937        key: &str,
1938        old: Option<&PropertyValue>,
1939        new: &PropertyValue,
1940    ) {
1941        let Some(rel_type) = self.rel_at(rel_id).map(|rel| rel.rel_type.clone()) else {
1942            return;
1943        };
1944
1945        if self.relationship_property_index_is_active(key) {
1946            if let Some(old) = old {
1947                self.unindex_relationship_property_if_active(rel_id, [rel_type.as_str()], key, old);
1948            }
1949            self.index_relationship_property_if_active(rel_id, [rel_type.as_str()], key, new);
1950        }
1951
1952        self.update_secondary_property(
1953            StoredIndexEntity::Relationship,
1954            [rel_type.as_str()],
1955            rel_id,
1956            key,
1957            old,
1958            Some(new),
1959        );
1960    }
1961
1962    pub(super) fn on_relationship_property_removed(
1963        &mut self,
1964        rel_id: RelationshipId,
1965        key: &str,
1966        old: &PropertyValue,
1967    ) {
1968        let Some(rel_type) = self.rel_at(rel_id).map(|rel| rel.rel_type.clone()) else {
1969            return;
1970        };
1971        if self.relationship_property_index_is_active(key) {
1972            self.unindex_relationship_property_if_active(rel_id, [rel_type.as_str()], key, old);
1973        }
1974        self.update_secondary_property(
1975            StoredIndexEntity::Relationship,
1976            [rel_type.as_str()],
1977            rel_id,
1978            key,
1979            Some(old),
1980            None,
1981        );
1982    }
1983
1984    pub(super) fn on_relationship_deleted(&mut self, rel: &RelationshipRecord) {
1985        self.detach_relationship_indexes(rel);
1986        self.unindex_active_relationship_properties(
1987            rel.id,
1988            [rel.rel_type.as_str()],
1989            &rel.properties,
1990        );
1991        self.maintain_relationship_secondary_indexes(rel, SecondaryIndexMutation::Remove);
1992    }
1993
1994    fn index_node_property_eager<'a>(
1995        &mut self,
1996        node_id: NodeId,
1997        labels: impl IntoIterator<Item = &'a str>,
1998        key: &str,
1999        value: &PropertyValue,
2000    ) {
2001        if PropertyIndexKey::from_value(value).is_none() {
2002            return;
2003        }
2004
2005        let activated = {
2006            let indexes = self.indexes_mut();
2007            let activated = indexes.node_properties.activate(key);
2008            indexes
2009                .node_properties
2010                .insert_with_scopes(node_id, labels, key, value);
2011            activated
2012        };
2013        if activated {
2014            self.indexes
2015                .active_node_property_indexes
2016                .fetch_add(1, Ordering::Relaxed);
2017        }
2018    }
2019
2020    fn index_relationship_property_eager<'a>(
2021        &mut self,
2022        rel_id: RelationshipId,
2023        scopes: impl IntoIterator<Item = &'a str>,
2024        key: &str,
2025        value: &PropertyValue,
2026    ) {
2027        if PropertyIndexKey::from_value(value).is_none() {
2028            return;
2029        }
2030
2031        let activated = {
2032            let indexes = self.indexes_mut();
2033            let activated = indexes.relationship_properties.activate(key);
2034            indexes
2035                .relationship_properties
2036                .insert_with_scopes(rel_id, scopes, key, value);
2037            activated
2038        };
2039        if activated {
2040            self.indexes
2041                .active_relationship_property_indexes
2042                .fetch_add(1, Ordering::Relaxed);
2043        }
2044    }
2045
2046    fn index_node_properties_eager<'a>(
2047        &mut self,
2048        node_id: NodeId,
2049        labels: impl IntoIterator<Item = &'a str> + Clone,
2050        properties: &Properties,
2051    ) {
2052        for (key, value) in properties {
2053            self.index_node_property_eager(node_id, labels.clone(), key, value);
2054        }
2055    }
2056
2057    fn index_relationship_properties_eager<'a>(
2058        &mut self,
2059        rel_id: RelationshipId,
2060        scopes: impl IntoIterator<Item = &'a str> + Clone,
2061        properties: &Properties,
2062    ) {
2063        for (key, value) in properties {
2064            self.index_relationship_property_eager(rel_id, scopes.clone(), key, value);
2065        }
2066    }
2067
2068    fn index_node_property_if_active<'a>(
2069        &mut self,
2070        node_id: NodeId,
2071        labels: impl IntoIterator<Item = &'a str>,
2072        key: &str,
2073        value: &PropertyValue,
2074    ) {
2075        if self.active_node_property_index_count() == 0 {
2076            return;
2077        }
2078        let indexes = self.indexes_mut();
2079        if indexes.node_properties.is_active(key) {
2080            indexes
2081                .node_properties
2082                .insert_with_scopes(node_id, labels, key, value);
2083        }
2084    }
2085
2086    fn index_node_properties_if_active<'a>(
2087        &mut self,
2088        node_id: NodeId,
2089        labels: impl IntoIterator<Item = &'a str> + Clone,
2090        properties: &Properties,
2091    ) {
2092        if self.active_node_property_index_count() == 0 {
2093            return;
2094        }
2095        let indexes = self.indexes_mut();
2096        for (key, value) in properties {
2097            if indexes.node_properties.is_active(key) {
2098                indexes
2099                    .node_properties
2100                    .insert_with_scopes(node_id, labels.clone(), key, value);
2101            }
2102        }
2103    }
2104
2105    fn unindex_node_property_if_active<'a>(
2106        &mut self,
2107        node_id: NodeId,
2108        labels: impl IntoIterator<Item = &'a str>,
2109        key: &str,
2110        value: &PropertyValue,
2111    ) {
2112        if self.active_node_property_index_count() == 0 {
2113            return;
2114        }
2115        let indexes = self.indexes_mut();
2116        if indexes.node_properties.is_active(key) {
2117            indexes
2118                .node_properties
2119                .remove_with_scopes(node_id, labels, key, value);
2120        }
2121    }
2122
2123    fn index_node_scope_properties_if_active(
2124        &mut self,
2125        node_id: NodeId,
2126        scope: &str,
2127        properties: &Properties,
2128    ) {
2129        if self.active_node_property_index_count() == 0 {
2130            return;
2131        }
2132        let indexes = self.indexes_mut();
2133        for (key, value) in properties {
2134            if indexes.node_properties.is_active(key) {
2135                indexes
2136                    .node_properties
2137                    .insert_scoped(node_id, scope, key, value);
2138            }
2139        }
2140    }
2141
2142    fn unindex_node_scope_properties_if_active(
2143        &mut self,
2144        node_id: NodeId,
2145        scope: &str,
2146        properties: &Properties,
2147    ) {
2148        if self.active_node_property_index_count() == 0 {
2149            return;
2150        }
2151        let indexes = self.indexes_mut();
2152        for (key, value) in properties {
2153            if indexes.node_properties.is_active(key) {
2154                indexes
2155                    .node_properties
2156                    .remove_scoped(node_id, scope, key, value);
2157            }
2158        }
2159    }
2160
2161    fn unindex_active_node_properties<'a>(
2162        &mut self,
2163        node_id: NodeId,
2164        labels: impl IntoIterator<Item = &'a str> + Clone,
2165        properties: &Properties,
2166    ) {
2167        if self.active_node_property_index_count() == 0 {
2168            return;
2169        }
2170        let indexes = self.indexes_mut();
2171        for (key, value) in properties {
2172            if indexes.node_properties.is_active(key) {
2173                indexes
2174                    .node_properties
2175                    .remove_with_scopes(node_id, labels.clone(), key, value);
2176            }
2177        }
2178    }
2179
2180    fn index_relationship_property_if_active<'a>(
2181        &mut self,
2182        rel_id: RelationshipId,
2183        scopes: impl IntoIterator<Item = &'a str>,
2184        key: &str,
2185        value: &PropertyValue,
2186    ) {
2187        if self.active_relationship_property_index_count() == 0 {
2188            return;
2189        }
2190        let indexes = self.indexes_mut();
2191        if indexes.relationship_properties.is_active(key) {
2192            indexes
2193                .relationship_properties
2194                .insert_with_scopes(rel_id, scopes, key, value);
2195        }
2196    }
2197
2198    fn index_relationship_properties_if_active<'a>(
2199        &mut self,
2200        rel_id: RelationshipId,
2201        scopes: impl IntoIterator<Item = &'a str> + Clone,
2202        properties: &Properties,
2203    ) {
2204        if self.active_relationship_property_index_count() == 0 {
2205            return;
2206        }
2207        let indexes = self.indexes_mut();
2208        for (key, value) in properties {
2209            if indexes.relationship_properties.is_active(key) {
2210                indexes.relationship_properties.insert_with_scopes(
2211                    rel_id,
2212                    scopes.clone(),
2213                    key,
2214                    value,
2215                );
2216            }
2217        }
2218    }
2219
2220    fn unindex_relationship_property_if_active<'a>(
2221        &mut self,
2222        rel_id: RelationshipId,
2223        scopes: impl IntoIterator<Item = &'a str>,
2224        key: &str,
2225        value: &PropertyValue,
2226    ) {
2227        if self.active_relationship_property_index_count() == 0 {
2228            return;
2229        }
2230        let indexes = self.indexes_mut();
2231        if indexes.relationship_properties.is_active(key) {
2232            indexes
2233                .relationship_properties
2234                .remove_with_scopes(rel_id, scopes, key, value);
2235        }
2236    }
2237
2238    fn unindex_active_relationship_properties<'a>(
2239        &mut self,
2240        rel_id: RelationshipId,
2241        scopes: impl IntoIterator<Item = &'a str> + Clone,
2242        properties: &Properties,
2243    ) {
2244        if self.active_relationship_property_index_count() == 0 {
2245            return;
2246        }
2247        let indexes = self.indexes_mut();
2248        for (key, value) in properties {
2249            if indexes.relationship_properties.is_active(key) {
2250                indexes.relationship_properties.remove_with_scopes(
2251                    rel_id,
2252                    scopes.clone(),
2253                    key,
2254                    value,
2255                );
2256            }
2257        }
2258    }
2259
2260    pub(super) fn scan_nodes_by_property(
2261        &self,
2262        label: Option<&str>,
2263        key: &str,
2264        value: &PropertyValue,
2265    ) -> Vec<NodeRecord> {
2266        match label {
2267            Some(label) => self
2268                .nodes_by_label
2269                .get(label)
2270                .into_iter()
2271                .flat_map(|ids| ids.iter())
2272                .filter_map(|&id| self.node_at(id))
2273                .filter(|node| node.properties.get(key) == Some(value))
2274                .cloned()
2275                .collect(),
2276            None => self
2277                .iter_node_records()
2278                .filter(|node| node.properties.get(key) == Some(value))
2279                .cloned()
2280                .collect(),
2281        }
2282    }
2283
2284    pub(super) fn scan_node_ids_by_property(
2285        &self,
2286        label: Option<&str>,
2287        key: &str,
2288        value: &PropertyValue,
2289    ) -> Vec<NodeId> {
2290        match label {
2291            Some(label) => self
2292                .nodes_by_label
2293                .get(label)
2294                .into_iter()
2295                .flat_map(|ids| ids.iter())
2296                .filter_map(|&id| {
2297                    (self.node_at(id)?.properties.get(key) == Some(value)).then_some(id)
2298                })
2299                .collect(),
2300            None => self
2301                .iter_nodes()
2302                .filter_map(|(id, node)| (node.properties.get(key) == Some(value)).then_some(id))
2303                .collect(),
2304        }
2305    }
2306
2307    pub(super) fn any_node_by_property(
2308        &self,
2309        label: &str,
2310        key: &str,
2311        value: &PropertyValue,
2312    ) -> bool {
2313        self.nodes_by_label
2314            .get(label)
2315            .into_iter()
2316            .flat_map(|ids| ids.iter())
2317            .filter_map(|&id| self.node_at(id))
2318            .any(|node| node.properties.get(key) == Some(value))
2319    }
2320
2321    pub(super) fn scan_relationships_by_property(
2322        &self,
2323        rel_type: Option<&str>,
2324        key: &str,
2325        value: &PropertyValue,
2326    ) -> Vec<RelationshipRecord> {
2327        match rel_type {
2328            Some(rel_type) => self
2329                .relationships_by_type
2330                .get(rel_type)
2331                .into_iter()
2332                .flat_map(|ids| ids.iter())
2333                .filter_map(|&id| self.rel_at(id))
2334                .filter(|rel| rel.properties.get(key) == Some(value))
2335                .cloned()
2336                .collect(),
2337            None => self
2338                .iter_rel_records()
2339                .filter(|rel| rel.properties.get(key) == Some(value))
2340                .cloned()
2341                .collect(),
2342        }
2343    }
2344
2345    pub(super) fn scan_relationship_ids_by_property(
2346        &self,
2347        rel_type: Option<&str>,
2348        key: &str,
2349        value: &PropertyValue,
2350    ) -> Vec<RelationshipId> {
2351        match rel_type {
2352            Some(rel_type) => self
2353                .relationships_by_type
2354                .get(rel_type)
2355                .into_iter()
2356                .flat_map(|ids| ids.iter())
2357                .filter_map(|&id| {
2358                    (self.rel_at(id)?.properties.get(key) == Some(value)).then_some(id)
2359                })
2360                .collect(),
2361            None => self
2362                .iter_rels()
2363                .filter_map(|(id, rel)| (rel.properties.get(key) == Some(value)).then_some(id))
2364                .collect(),
2365        }
2366    }
2367
2368    pub(super) fn any_relationship_by_property(
2369        &self,
2370        rel_type: &str,
2371        key: &str,
2372        value: &PropertyValue,
2373    ) -> bool {
2374        self.relationships_by_type
2375            .get(rel_type)
2376            .into_iter()
2377            .flat_map(|ids| ids.iter())
2378            .filter_map(|&id| self.rel_at(id))
2379            .any(|rel| rel.properties.get(key) == Some(value))
2380    }
2381
2382    pub(super) fn attach_relationship(&mut self, rel: &RelationshipRecord) {
2383        self.outgoing_push(rel.src, rel.id);
2384        self.incoming_push(rel.dst, rel.id);
2385        self.insert_relationship_type_index(rel.id, &rel.rel_type);
2386    }
2387
2388    fn detach_relationship_indexes(&mut self, rel: &RelationshipRecord) {
2389        // Adjacency is now positional `Vec<Vec<RelationshipId>>` — clearing
2390        // the inner Vec leaves the slot in place (the slot is sized for the
2391        // node's lifetime, not the edge's).
2392        self.outgoing_remove(rel.src, rel.id);
2393        self.incoming_remove(rel.dst, rel.id);
2394
2395        self.remove_relationship_type_index(rel.id, &rel.rel_type);
2396    }
2397
2398    pub(super) fn relationship_ids_for_direction(
2399        &self,
2400        node_id: NodeId,
2401        direction: Direction,
2402    ) -> Vec<RelationshipId> {
2403        match direction {
2404            Direction::Left => self
2405                .incoming_at(node_id)
2406                .map(<[_]>::to_vec)
2407                .unwrap_or_default(),
2408
2409            Direction::Right => self
2410                .outgoing_at(node_id)
2411                .map(<[_]>::to_vec)
2412                .unwrap_or_default(),
2413
2414            Direction::Undirected => {
2415                let out = self.outgoing_at(node_id);
2416                let inc = self.incoming_at(node_id);
2417                let mut ids = Vec::with_capacity(
2418                    out.map(<[_]>::len).unwrap_or(0) + inc.map(<[_]>::len).unwrap_or(0),
2419                );
2420
2421                if let Some(out) = out {
2422                    ids.extend(out.iter().copied());
2423                }
2424                if let Some(inc) = inc {
2425                    for &rel_id in inc {
2426                        let Some(rel) = self.rel_at(rel_id) else {
2427                            continue;
2428                        };
2429                        if rel.src == node_id && rel.dst == node_id {
2430                            continue;
2431                        }
2432                        ids.push(rel_id);
2433                    }
2434                }
2435
2436                ids
2437            }
2438        }
2439    }
2440
2441    pub(super) fn other_endpoint(rel: &RelationshipRecord, node_id: NodeId) -> Option<NodeId> {
2442        if rel.src == node_id {
2443            Some(rel.dst)
2444        } else if rel.dst == node_id {
2445            Some(rel.src)
2446        } else {
2447            None
2448        }
2449    }
2450
2451    pub(super) fn has_incident_relationships(&self, node_id: NodeId) -> bool {
2452        self.outgoing_at(node_id)
2453            .map(|ids| !ids.is_empty())
2454            .unwrap_or(false)
2455            || self
2456                .incoming_at(node_id)
2457                .map(|ids| !ids.is_empty())
2458                .unwrap_or(false)
2459    }
2460
2461    pub(super) fn incident_relationship_ids(&self, node_id: NodeId) -> Vec<RelationshipId> {
2462        let out = self.outgoing_at(node_id);
2463        let inc = self.incoming_at(node_id);
2464        let mut rel_ids =
2465            Vec::with_capacity(out.map(<[_]>::len).unwrap_or(0) + inc.map(<[_]>::len).unwrap_or(0));
2466
2467        if let Some(ids) = out {
2468            rel_ids.extend(ids.iter().copied());
2469        }
2470        if let Some(ids) = inc {
2471            for &rel_id in ids {
2472                let Some(rel) = self.rel_at(rel_id) else {
2473                    continue;
2474                };
2475                if rel.src == node_id && rel.dst == node_id {
2476                    continue;
2477                }
2478                rel_ids.push(rel_id);
2479            }
2480        }
2481
2482        rel_ids
2483    }
2484
2485    /// Replay a node creation using the id captured in a durable mutation
2486    /// event. This intentionally does not emit a new mutation event: callers
2487    /// must invoke it before installing a recorder on the graph.
2488    #[doc(hidden)]
2489    pub fn replay_create_node(
2490        &mut self,
2491        id: NodeId,
2492        labels: Vec<String>,
2493        properties: Properties,
2494    ) -> Result<NodeRecord, String> {
2495        if self.recorder.is_some() {
2496            return Err(
2497                "cannot replay node creation while a mutation recorder is installed".into(),
2498            );
2499        }
2500        if self.node_at(id).is_some() {
2501            return Err(format!("node id {id} already exists"));
2502        }
2503        let idx = self.ensure_node_slot_checked(id)?;
2504        self.bump_next_node_id_past(id)?;
2505
2506        let labels = Self::normalize_labels(labels);
2507        let node = NodeRecord {
2508            id,
2509            labels: labels.clone(),
2510            properties,
2511        };
2512
2513        self.put_node_at_slot(idx, node.clone());
2514        self.on_node_replayed(&node);
2515
2516        Ok(node)
2517    }
2518
2519    /// Replay a relationship creation using the id captured in a durable
2520    /// mutation event. This intentionally does not emit a new mutation event:
2521    /// callers must invoke it before installing a recorder on the graph.
2522    #[doc(hidden)]
2523    pub fn replay_create_relationship(
2524        &mut self,
2525        id: RelationshipId,
2526        src: NodeId,
2527        dst: NodeId,
2528        rel_type: &str,
2529        properties: Properties,
2530    ) -> Result<RelationshipRecord, String> {
2531        if self.recorder.is_some() {
2532            return Err(
2533                "cannot replay relationship creation while a mutation recorder is installed".into(),
2534            );
2535        }
2536        if self.rel_at(id).is_some() {
2537            return Err(format!("relationship id {id} already exists"));
2538        }
2539        if self.node_at(src).is_none() {
2540            return Err(format!(
2541                "relationship {id} references missing source node {src}"
2542            ));
2543        }
2544        if self.node_at(dst).is_none() {
2545            return Err(format!(
2546                "relationship {id} references missing target node {dst}"
2547            ));
2548        }
2549
2550        let trimmed = rel_type.trim();
2551        if trimmed.is_empty() {
2552            return Err(format!("relationship {id} has an empty type"));
2553        }
2554        let idx = self.ensure_rel_slot_checked(id)?;
2555        self.bump_next_rel_id_past(id)?;
2556
2557        let rel = RelationshipRecord {
2558            id,
2559            src,
2560            dst,
2561            rel_type: trimmed.to_string(),
2562            properties,
2563        };
2564
2565        self.put_rel_at_slot(idx, rel.clone());
2566        self.on_relationship_replayed(&rel);
2567
2568        Ok(rel)
2569    }
2570
2571    #[cfg(test)]
2572    pub(super) fn assert_property_indexes_match_scan(&self) {
2573        let indexes = self.indexes_read();
2574        assert_eq!(
2575            indexes.node_properties.active_keys.len(),
2576            self.active_node_property_index_count(),
2577            "node property index counter diverged from active key set"
2578        );
2579        assert_eq!(
2580            indexes.relationship_properties.active_keys.len(),
2581            self.active_relationship_property_index_count(),
2582            "relationship property index counter diverged from active key set"
2583        );
2584
2585        let mut expected_nodes = PropertyIndexState {
2586            active_keys: indexes.node_properties.active_keys.clone(),
2587            ..PropertyIndexState::default()
2588        };
2589        for (id, node) in self.iter_nodes() {
2590            for (key, value) in &node.properties {
2591                if expected_nodes.is_active(key) {
2592                    expected_nodes.insert_with_scopes(
2593                        id,
2594                        node.labels.iter().map(String::as_str),
2595                        key,
2596                        value,
2597                    );
2598                }
2599            }
2600        }
2601        assert_eq!(
2602            indexes.node_properties.values, expected_nodes.values,
2603            "node property index values diverged from scan"
2604        );
2605        assert_eq!(
2606            indexes.node_properties.scoped_values, expected_nodes.scoped_values,
2607            "node property scoped index values diverged from scan"
2608        );
2609
2610        let mut expected_relationships = PropertyIndexState {
2611            active_keys: indexes.relationship_properties.active_keys.clone(),
2612            ..PropertyIndexState::default()
2613        };
2614        for (id, rel) in self.iter_rels() {
2615            for (key, value) in &rel.properties {
2616                if expected_relationships.is_active(key) {
2617                    expected_relationships.insert_with_scopes(
2618                        id,
2619                        [rel.rel_type.as_str()],
2620                        key,
2621                        value,
2622                    );
2623                }
2624            }
2625        }
2626        assert_eq!(
2627            indexes.relationship_properties.values, expected_relationships.values,
2628            "relationship property index values diverged from scan"
2629        );
2630        assert_eq!(
2631            indexes.relationship_properties.scoped_values, expected_relationships.scoped_values,
2632            "relationship property scoped index values diverged from scan"
2633        );
2634    }
2635}