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