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