Skip to main content

core_api/
reader.rs

1//! Lock-free MVCC epoch readers.
2//!
3//! Each reader snapshots the db state at a fold point (every [`FOLD_EVERY_K`]
4//! commits) plus a bounded delta tail, allowing concurrent reads without holding
5//! the write lock.
6//!
7//! # Correctness guarantees
8//! 1. **Snapshot isolation**: query returns results consistent with the db state
9//!    at the moment [`GraphDb::reader`] was called.
10//! 2. **RBAC mask coherence** (constraint 2): [`ReaderSnapshot::mask_for_role`]
11//!    and [`ReaderSnapshot::query_masked`] operate on the same frozen base, so no
12//!    node can slip through a stale mask.
13//! 3. **Delta chain bounded**: at most `FOLD_EVERY_K − 1` deltas in the tail
14//!    (fold resets the counter synchronously on the write path).
15
16use std::collections::{BTreeMap, HashSet};
17use std::sync::{Arc, OnceLock};
18
19use core_query::cypher::{execute, is_write_tokens, lex, parse, plan, Params};
20use core_query::{expand, neighborhood, Dir, GraphView, ResultSet};
21use core_storage::fulltext::FulltextIndex;
22use core_storage::v8::seam::{ColumnsView, TopologyView};
23use core_storage::v8::MappedBase;
24use core_storage::wal::WalRecord;
25use core_storage::{
26    ColumnStore, Direction, EdgeProps, EdgePropsView, GraphError, IdMap, Interner, Result,
27    Topology, Value,
28};
29
30use crate::db::{EdgeInfo, NodeInfo};
31use crate::mask::{NodeMask, RoleMaskCache};
32use crate::roles::RoleDef;
33
34/// Fold trigger: every K commits, the overlay is cloned into a new `FrozenOverlay`
35/// and `delta_tail` is reset. The tail length is always ≤ K−1.
36pub const FOLD_EVERY_K: usize = 64;
37
38/// Per-commit overlay change record. Immutable after creation.
39pub struct CommitDelta {
40    /// WAL records for this commit (including `Intern` records, in WAL order).
41    pub records: Vec<WalRecord>,
42    /// Rule-derived edge **inserts** fired this commit: `(etype_sym, src_id, dst_id)`.
43    pub derived_inserts: Vec<(u32, u32, u32)>,
44    /// Rule-derived edge **retractions** this commit: `(etype_sym, src_id, dst_id)`.
45    pub derived_deletes: Vec<(u32, u32, u32)>,
46}
47
48/// Full clone of overlay state captured at fold time.
49///
50/// The V8 mmap base is not cloned here; it is `Arc`-shared in `ReaderSnapshot`.
51#[derive(Clone)]
52pub struct FrozenOverlay {
53    pub ids: IdMap,
54    pub syms: Interner,
55    pub topo: Topology,
56    pub props: ColumnStore,
57    pub labels: Vec<u32>,
58    pub edge_props: EdgeProps,
59    pub roles: Option<Vec<RoleDef>>,
60    pub fulltext: FulltextIndex,
61}
62
63/// Lock-free reader snapshot: frozen overlay + optional V8 base + pending delta tail.
64///
65/// Obtained cheaply via [`crate::SharedDb::reader`], which acquires the read lock
66/// only long enough to clone the `Arc` fields. Subsequent query operations run
67/// without any lock.
68///
69/// **Memory bound**: holds at most `FOLD_EVERY_K − 1` [`CommitDelta`] `Arc`s in
70/// its delta tail; the fold that resets the tail runs synchronously on the write path.
71pub struct ReaderSnapshot {
72    /// Most-recent fold of the overlay state.
73    pub frozen: Arc<FrozenOverlay>,
74    /// Shared mmap base (zero-copy, Arc-ref-counted). `None` for legacy stores.
75    pub base: Option<Arc<MappedBase>>,
76    /// Commits since the last fold, in arrival order. Length ≤ `FOLD_EVERY_K − 1`.
77    pub deltas: Vec<Arc<CommitDelta>>,
78    /// The store's `commit_seq` when this snapshot was taken — the version key
79    /// for the shared role-mask memo. The effective state (frozen + deltas) is
80    /// exactly the state the live handle had at this commit.
81    pub version: u64,
82    /// Role → mask memo, shared with the `GraphDb` this snapshot came from.
83    role_masks: Arc<RoleMaskCache>,
84    /// Cached materialized overlay (frozen + deltas applied).
85    ///
86    /// Computed at most once per `ReaderSnapshot` on the first call to
87    /// [`Self::effective`] when `deltas` is non-empty.  Stores `Err(String)` if
88    /// WAL application fails so the error is returned to every subsequent caller
89    /// without re-attempting.  When `deltas` is empty this field is never
90    /// populated — `effective` returns `&frozen` directly.
91    cache: OnceLock<std::result::Result<FrozenOverlay, String>>,
92}
93
94// ── Private view-building helpers ─────────────────────────────────────────────
95
96fn build_tv<'a>(topo: &'a Topology, base: &'a Option<Arc<MappedBase>>) -> TopologyView<'a> {
97    match base {
98        None => TopologyView::owned(topo),
99        Some(b) => {
100            let csr = b.topology().expect("base CSR CRC already verified at open");
101            TopologyView::with_base(topo, csr)
102        }
103    }
104}
105
106fn build_cv<'a>(props: &'a ColumnStore, base: &'a Option<Arc<MappedBase>>) -> ColumnsView<'a> {
107    match base {
108        None => ColumnsView::owned(props),
109        Some(b) => {
110            let cols = b
111                .columns()
112                .expect("base columns CRC already verified at open");
113            let strings = b
114                .string_table()
115                .transpose()
116                .expect("base strings CRC already verified at open");
117            ColumnsView::with_base_cached(props, cols, b.mixed_cache()).with_shared_strings(strings)
118        }
119    }
120}
121
122fn build_epv<'a>(
123    edge_props: &'a EdgeProps,
124    base: &'a Option<Arc<MappedBase>>,
125) -> EdgePropsView<'a> {
126    match base {
127        None => EdgePropsView::owned(edge_props),
128        Some(b) => {
129            let archived = b
130                .edge_props_section()
131                .expect("base edge_props CRC already verified at open");
132            EdgePropsView::with_base(edge_props, archived)
133        }
134    }
135}
136
137fn make_view<'a>(
138    state: &'a FrozenOverlay,
139    base: &'a Option<Arc<MappedBase>>,
140    mask: Option<&'a HashSet<u32>>,
141) -> GraphView<'a> {
142    GraphView {
143        ids: &state.ids,
144        syms: &state.syms,
145        labels: &state.labels,
146        props: build_cv(&state.props, base),
147        topo: build_tv(&state.topo, base),
148        edge_props: build_epv(&state.edge_props, base),
149        mask,
150        // MVCC reader snapshots don't carry the equality index; IndexScan
151        // falls back to a correct scan+filter on this path.
152        prop_index: None,
153    }
154}
155
156// ── Delta application ─────────────────────────────────────────────────────────
157
158/// Apply a single WAL record (recursing into `Batch`) to mutable working state.
159/// Skips rule/view records that are no-ops on the read path.
160///
161/// Note: fulltext is updated incrementally here for correctness; after all deltas
162/// are applied the caller should call `fulltext.rebuild_all` to correct drift from
163/// multi-field updates and out-of-order incremental additions.
164#[allow(clippy::too_many_arguments)]
165fn apply_one(
166    ids: &mut IdMap,
167    syms: &mut Interner,
168    topo: &mut Topology,
169    props: &mut ColumnStore,
170    edge_props: &mut EdgeProps,
171    labels: &mut Vec<u32>,
172    fulltext: &mut FulltextIndex,
173    rec: &WalRecord,
174) -> Result<()> {
175    match rec {
176        WalRecord::Intern { id, text } => {
177            let got = syms.intern(text);
178            if got != *id {
179                return Err(GraphError::Corrupt {
180                    detail: format!(
181                        "mvcc delta intern mismatch for {text:?}: expected {id}, got {got}"
182                    ),
183                });
184            }
185        }
186
187        WalRecord::InsertNodeId {
188            label,
189            key,
190            props: node_props,
191        } => {
192            let node_id = ids.try_insert(key)?;
193            if labels.len() <= node_id as usize {
194                labels.resize(node_id as usize + 1, u32::MAX);
195            }
196            labels[node_id as usize] = *label;
197            let label_str = syms
198                .resolve(*label)
199                .ok_or_else(|| GraphError::Corrupt {
200                    detail: format!("mvcc delta: unknown label sym {label}"),
201                })?
202                .to_string();
203            for (field_sym, value) in node_props {
204                let field = syms
205                    .resolve(*field_sym)
206                    .ok_or_else(|| GraphError::Corrupt {
207                        detail: format!("mvcc delta: unknown field sym {field_sym}"),
208                    })?
209                    .to_string();
210                props.set(node_id, &field, value.clone());
211                if fulltext.is_enabled(&label_str, &field) {
212                    fulltext.add_tokens(node_id, &field, value);
213                }
214            }
215        }
216
217        WalRecord::InsertNode {
218            label,
219            key,
220            props: node_props,
221        } => {
222            let label_sym = syms.intern(label);
223            let node_id = ids.try_insert(key)?;
224            if labels.len() <= node_id as usize {
225                labels.resize(node_id as usize + 1, u32::MAX);
226            }
227            labels[node_id as usize] = label_sym;
228            for (field, value) in node_props {
229                props.set(node_id, field, value.clone());
230                if fulltext.is_enabled(label, field) {
231                    fulltext.add_tokens(node_id, field, value);
232                }
233            }
234        }
235
236        WalRecord::SetPropId { id, field, value } => {
237            if let Some(field_str) = syms.resolve(*field).map(str::to_string) {
238                props.set(*id, &field_str, value.clone());
239                if let Some(&label_sym) = labels.get(*id as usize) {
240                    if let Some(label_str) = syms.resolve(label_sym) {
241                        if fulltext.is_enabled(label_str, &field_str) {
242                            fulltext.add_tokens(*id, &field_str, value);
243                        }
244                    }
245                }
246            }
247        }
248
249        WalRecord::SetProp { key, field, value } => {
250            if let Some(node_id) = ids.get(key) {
251                props.set(node_id, field, value.clone());
252                if let Some(&label_sym) = labels.get(node_id as usize) {
253                    if let Some(label_str) = syms.resolve(label_sym) {
254                        if fulltext.is_enabled(label_str, field) {
255                            fulltext.add_tokens(node_id, field, value);
256                        }
257                    }
258                }
259            }
260        }
261
262        WalRecord::RemoveProp { key, field } => {
263            if let Some(node_id) = ids.get(key) {
264                props.remove(node_id, field);
265                // A base-resident prop must be masked or ColumnsView falls
266                // through to the archived value (mirrors db.rs WAL replay).
267                // Tombstoning a prop absent from the base is a harmless
268                // false-mask: the overlay short-circuit never reaches it.
269                props.record_prop_tombstone(node_id, field);
270                fulltext.remove_node_field(node_id, field);
271            }
272        }
273
274        WalRecord::DeleteNode { key } => {
275            if let Some(node_id) = ids.delete(key) {
276                props.remove_all(node_id);
277                fulltext.remove_node(node_id);
278                // Mark the label slot as sentinel so label_of returns None.
279                if let Some(slot) = labels.get_mut(node_id as usize) {
280                    *slot = u32::MAX;
281                }
282                // Sweep all edges incident on this node to prevent phantom
283                // adjacency. db.rs deletes these edges inline without emitting
284                // DeleteEdge WAL records, so we must mirror that sweep here.
285                let etypes: Vec<u32> = topo.etypes().collect();
286                let mut doomed = Vec::new();
287                for et in &etypes {
288                    for &dst in topo.neighbors(*et, Direction::Out, node_id).as_ref() {
289                        doomed.push((*et, node_id, dst));
290                    }
291                    for &src in topo.neighbors(*et, Direction::In, node_id).as_ref() {
292                        doomed.push((*et, src, node_id));
293                    }
294                }
295                for (et, s, d) in doomed {
296                    topo.remove_edge(et, s, d);
297                    edge_props.remove_edge(et, s, d);
298                }
299            }
300        }
301
302        WalRecord::InsertEdgeId { etype, src, dst } => {
303            topo.add_edge(*etype, *src, *dst);
304        }
305
306        WalRecord::InsertEdge {
307            edge_type,
308            src_key,
309            dst_key,
310        } => {
311            let etype = syms.intern(edge_type);
312            if let (Some(src), Some(dst)) = (ids.get(src_key), ids.get(dst_key)) {
313                topo.add_edge(etype, src, dst);
314            }
315        }
316
317        WalRecord::DeleteEdge {
318            edge_type,
319            src_key,
320            dst_key,
321        } => {
322            if let Some(etype) = syms.get(edge_type) {
323                if let (Some(src), Some(dst)) = (ids.get(src_key), ids.get(dst_key)) {
324                    topo.remove_edge(etype, src, dst);
325                }
326            }
327        }
328
329        WalRecord::EnableFulltext { label, field } => {
330            fulltext.enable(label, field);
331        }
332
333        WalRecord::DisableFulltext { label, field } => {
334            fulltext.disable(label, field);
335        }
336
337        WalRecord::Batch(inner) => {
338            for r in inner {
339                apply_one(ids, syms, topo, props, edge_props, labels, fulltext, r)?;
340            }
341        }
342
343        // No-ops for the read path: rule and view management do not affect
344        // the structural overlay data that queries read.
345        WalRecord::CreateRule { .. }
346        | WalRecord::DeleteRule { .. }
347        | WalRecord::RebuildRule { .. }
348        | WalRecord::CreateView { .. }
349        | WalRecord::DeleteView { .. }
350        // Property-index declarations are no-ops in the read path: MVCC reader
351        // snapshots do not carry the equality index, so `IndexScan` falls back
352        // to a correct scan+filter for reader queries.
353        | WalRecord::EnableIndex { .. }
354        | WalRecord::DisableIndex { .. }
355        // History markers are no-ops in the read path. Rules re-derive their
356        // edges when the ReaderSnapshot queries the live engine; markers only
357        // serve edge_history / was_linked WAL scans.
358        | WalRecord::DerivedEdgeAdded { .. }
359        | WalRecord::DerivedEdgeRetracted { .. } => {}
360
361        WalRecord::RenameNode { old_key, new_key } => {
362            // Recovery-safe: if old_key is already gone (frozen overlay or a
363            // prior delta already applied the rename), skip cleanly.
364            if ids.get(old_key).is_some() {
365                ids.rename(old_key, new_key).map_err(|_| GraphError::Corrupt {
366                    detail: format!("mvcc delta RenameNode {old_key}→{new_key} failed"),
367                })?;
368            }
369        }
370    }
371    Ok(())
372}
373
374// ── ReaderSnapshot ────────────────────────────────────────────────────────────
375
376/// Resolve `role` against a frozen overlay — the reader-side twin of
377/// [`crate::db::GraphDb::mask_for_role`], and kept identical to it.
378///
379/// Takes `base` because a role carrying a `visible_where` predicate has to read
380/// properties, and a node's property may live in the mmap'd base rather than
381/// the overlay.
382fn mask_for_role_from(
383    state: &FrozenOverlay,
384    base: &Option<Arc<MappedBase>>,
385    role: &str,
386) -> Result<NodeMask> {
387    let roles = state.roles.as_ref().ok_or_else(|| GraphError::Corrupt {
388        detail: "roles.json was corrupt at open; fix the file and re-open".into(),
389    })?;
390    let def = roles
391        .iter()
392        .find(|r| r.name == role)
393        .ok_or_else(|| GraphError::KeyNotFound {
394            key: format!("role:{role}"),
395        })?;
396    let mut visible = HashSet::new();
397    // Key leg: an administrative grant, never narrowed by the predicate.
398    for key in &def.keys {
399        if let Some(id) = state.ids.get(key) {
400            visible.insert(id);
401        }
402    }
403    let props = def
404        .visible_where
405        .as_ref()
406        .map(|_| build_cv(&state.props, base));
407    for label_name in &def.labels {
408        if let Some(sym) = state.syms.get(label_name) {
409            for (i, &s) in state.labels.iter().enumerate() {
410                if s != sym {
411                    continue;
412                }
413                let id = i as u32;
414                match (&def.visible_where, &props) {
415                    (Some(pred), Some(view)) => {
416                        let value = view.get(id, &pred.field).map(|vr| vr.into_value());
417                        if pred.holds(value.as_ref()) {
418                            visible.insert(id);
419                        }
420                    }
421                    _ => {
422                        visible.insert(id);
423                    }
424                }
425            }
426        }
427    }
428    // Namespace leg — the live resolver's retain, against the frozen overlay's
429    // own `ns` column (the reader has no derived `node_ns` array: its effective
430    // state is assembled per snapshot, and reading the column it would mirror is
431    // the same answer by construction). Intersects the key leg too; see
432    // `RoleDef::namespaces`.
433    if def.namespaces.is_some() {
434        let cv = build_cv(&state.props, base);
435        visible.retain(|&id| {
436            let value = cv.get(id, core_storage::NS_PROP).map(|vr| vr.into_value());
437            def.sees_namespace(core_storage::namespace_of_value(value.as_ref()))
438        });
439    }
440    Ok(NodeMask::from_ids(visible))
441}
442
443impl ReaderSnapshot {
444    /// Apply all pending deltas to a clone of `frozen`.
445    ///
446    /// Returns the frozen state (cloned) with delta changes applied, including
447    /// rule-derived edge inserts/retracts and a rebuilt full-text index.
448    fn materialize(&self) -> Result<FrozenOverlay> {
449        let mut w = (*self.frozen).clone();
450        for delta in &self.deltas {
451            for rec in &delta.records {
452                apply_one(
453                    &mut w.ids,
454                    &mut w.syms,
455                    &mut w.topo,
456                    &mut w.props,
457                    &mut w.edge_props,
458                    &mut w.labels,
459                    &mut w.fulltext,
460                    rec,
461                )?;
462            }
463            for &(etype, src, dst) in &delta.derived_inserts {
464                w.topo.add_edge(etype, src, dst);
465            }
466            for &(etype, src, dst) in &delta.derived_deletes {
467                w.topo.remove_edge(etype, src, dst);
468            }
469        }
470        if !self.deltas.is_empty() {
471            // Rebuild full-text to correct incremental drift accumulated during
472            // delta application (add_tokens is imprecise for multi-field/deletion paths).
473            let cv = build_cv(&w.props, &self.base);
474            w.fulltext.rebuild_all(&w.ids, &w.labels, &w.syms, cv);
475        }
476        Ok(w)
477    }
478
479    /// Construct a `ReaderSnapshot` from its constituent parts.
480    ///
481    /// Used by [`crate::db::GraphDb::reader`] — the only site that builds a
482    /// snapshot — so the private `cache` field stays encapsulated here.
483    pub(crate) fn new(
484        frozen: Arc<FrozenOverlay>,
485        base: Option<Arc<MappedBase>>,
486        deltas: Vec<Arc<CommitDelta>>,
487        version: u64,
488        role_masks: Arc<RoleMaskCache>,
489    ) -> Self {
490        Self {
491            frozen,
492            base,
493            deltas,
494            version,
495            role_masks,
496            cache: OnceLock::new(),
497        }
498    }
499
500    // ── Private helpers ───────────────────────────────────────────────────────
501
502    /// Return a reference to the current effective state.
503    ///
504    /// When the delta tail is empty this is a zero-copy borrow of `frozen`.
505    /// Otherwise the delta tail is applied to a clone of `frozen` exactly once
506    /// (cached in `self.cache`) so that all operations within a single
507    /// `ReaderSnapshot` share the same materialized view (F3: no triple
508    /// materialize per request).
509    fn effective(&self) -> Result<&FrozenOverlay> {
510        if self.deltas.is_empty() {
511            return Ok(&self.frozen);
512        }
513        let cached = self
514            .cache
515            .get_or_init(|| self.materialize().map_err(|e| e.to_string()));
516        cached
517            .as_ref()
518            .map_err(|e| GraphError::Corrupt { detail: e.clone() })
519    }
520
521    // ── Public API ────────────────────────────────────────────────────────────
522
523    /// Resolve a role name to a node visibility mask.
524    ///
525    /// Coherent with [`Self::query_masked`]: both read from the same effective
526    /// state (frozen or cached materialization), so the mask is never stale
527    /// relative to the query data.
528    ///
529    /// Memoised per `(role, version)` in the cache shared with the originating
530    /// `GraphDb`, so a scoped reader taking snapshot after snapshot between two
531    /// writes resolves the role once.
532    pub fn mask_for_role(&self, role: &str) -> Result<NodeMask> {
533        self.role_masks
534            .get_or_build(role, self.version, || {
535                mask_for_role_from(self.effective()?, &self.base, role)
536            })
537            .map(|m| (*m).clone())
538    }
539
540    /// Every live node in `namespace`, as a visibility mask.
541    ///
542    /// The snapshot-reader twin of [`GraphDb::mask_for_namespace`](crate::GraphDb::mask_for_namespace):
543    /// read off the effective state's own `ns` column rather than a derived
544    /// array, exactly as the namespace leg of [`Self::mask_for_role`] is. A name
545    /// no node uses gives an empty mask — a namespace scope never widens.
546    pub fn mask_for_namespace(&self, namespace: &str) -> Result<NodeMask> {
547        let state = self.effective()?;
548        let cv = build_cv(&state.props, &self.base);
549        let mut visible = HashSet::new();
550        for (i, &sym) in state.labels.iter().enumerate() {
551            if sym == u32::MAX {
552                continue; // tombstoned: the label sentinel is what marks it gone
553            }
554            let id = i as u32;
555            let value = cv.get(id, core_storage::NS_PROP).map(|vr| vr.into_value());
556            if core_storage::namespace_of_value(value.as_ref()) == namespace {
557                visible.insert(id);
558            }
559        }
560        Ok(NodeMask::from_ids(visible))
561    }
562
563    /// Resolve a node key to its dense id.
564    ///
565    /// Checks the delta tail (via the cached materialization) so that nodes
566    /// inserted since the last fold are visible.
567    pub fn resolve_key(&self, key: &str) -> Option<u32> {
568        self.effective().ok()?.ids.get(key)
569    }
570
571    /// Execute a read-only Cypher query over the epoch snapshot.
572    pub fn query(&self, cypher: &str, params: &BTreeMap<String, Value>) -> Result<ResultSet> {
573        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
574            detail: format!("lex: {e}"),
575        })?;
576        let ast = parse(&tokens).map_err(|e| GraphError::QueryError {
577            detail: format!("parse: {e}"),
578        })?;
579        let ops = plan(&ast).map_err(|e| GraphError::QueryError {
580            detail: format!("plan: {e}"),
581        })?;
582        let state = self.effective()?;
583        let view = make_view(state, &self.base, None);
584        execute(&view, &ops, &Params(params)).map_err(|e| GraphError::QueryError {
585            detail: format!("execute: {e}"),
586        })
587    }
588
589    /// Execute a read-only Cypher query with a node visibility mask.
590    ///
591    /// Returns `Err` when `cypher` is a write statement (CREATE / MATCH…SET / DELETE).
592    pub fn query_masked(
593        &self,
594        cypher: &str,
595        params: &BTreeMap<String, Value>,
596        mask: &NodeMask,
597    ) -> Result<ResultSet> {
598        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
599            detail: format!("lex: {e}"),
600        })?;
601        if is_write_tokens(&tokens) {
602            return Err(GraphError::QueryError {
603                detail: "masked queries are read-only".into(),
604            });
605        }
606        let ast = parse(&tokens).map_err(|e| GraphError::QueryError {
607            detail: format!("parse: {e}"),
608        })?;
609        let ops = plan(&ast).map_err(|e| GraphError::QueryError {
610            detail: format!("plan: {e}"),
611        })?;
612        let state = self.effective()?;
613        let view = make_view(state, &self.base, Some(&mask.visible));
614        execute(&view, &ops, &Params(params)).map_err(|e| GraphError::QueryError {
615            detail: format!("execute: {e}"),
616        })
617    }
618
619    /// Live node info from the epoch snapshot. `None` if key is absent or tombstoned.
620    pub fn node_info(&self, key: &str) -> Option<NodeInfo> {
621        node_info_from(key, self.effective().ok()?, &self.base)
622    }
623
624    /// Every directed edge incident on `key`. `derived` is always `false` since
625    /// the reader snapshot has no rule engine.
626    ///
627    /// Unknown key → `Err(GraphError::KeyNotFound)`.
628    pub fn node_edges(&self, key: &str) -> Result<Vec<EdgeInfo>> {
629        node_edges_from(key, self.effective()?, &self.base)
630    }
631
632    /// BFS neighborhood expansion restricted to `mask`-visible nodes.
633    ///
634    /// Hidden nodes are neither returned nor used as traversal intermediaries
635    /// (never-leak invariant). Returns `None` when `key` does not exist.
636    pub fn neighborhood_masked(
637        &self,
638        key: &str,
639        depth: u32,
640        edge_types: Option<&[&str]>,
641        dir: Dir,
642        mask: &NodeMask,
643    ) -> Option<ResultSet> {
644        neighborhood_masked_from(
645            key,
646            self.effective().ok()?,
647            &self.base,
648            depth,
649            edge_types,
650            dir,
651            mask,
652        )
653    }
654}
655
656// ── Free-standing helpers that take state by reference ────────────────────────
657
658fn node_info_from(
659    key: &str,
660    state: &FrozenOverlay,
661    base: &Option<Arc<MappedBase>>,
662) -> Option<NodeInfo> {
663    let id = state.ids.get(key)?;
664    let label_sym = *state.labels.get(id as usize)?;
665    if label_sym == u32::MAX {
666        return None;
667    }
668    let label = state.syms.resolve(label_sym)?.to_string();
669    let cv = build_cv(&state.props, base);
670    let mut props = BTreeMap::new();
671    for field in cv.field_names() {
672        if let Some(vr) = cv.get(id, &field) {
673            props.insert(field, vr.into_value());
674        }
675    }
676    Some(NodeInfo {
677        key: key.to_string(),
678        label,
679        props,
680    })
681}
682
683fn node_edges_from(
684    key: &str,
685    state: &FrozenOverlay,
686    base: &Option<Arc<MappedBase>>,
687) -> Result<Vec<EdgeInfo>> {
688    let id = state
689        .ids
690        .get(key)
691        .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
692    let tv = build_tv(&state.topo, base);
693    let mut edges = Vec::new();
694    for etype in tv.etypes() {
695        let edge_type = state
696            .syms
697            .resolve(etype)
698            .ok_or_else(|| GraphError::Corrupt {
699                detail: format!("reader: topology etype {etype} not in interner"),
700            })?
701            .to_string();
702        for dir in [Direction::Out, Direction::In] {
703            for &nbr in tv.neighbors(etype, dir, id).as_ref() {
704                let (src_key, dst_key) = match dir {
705                    Direction::Out => (
706                        key.to_string(),
707                        state
708                            .ids
709                            .key_of(nbr)
710                            .ok_or_else(|| GraphError::Corrupt {
711                                detail: format!("topology id {nbr} has no key"),
712                            })?
713                            .to_string(),
714                    ),
715                    Direction::In => (
716                        state
717                            .ids
718                            .key_of(nbr)
719                            .ok_or_else(|| GraphError::Corrupt {
720                                detail: format!("topology id {nbr} has no key"),
721                            })?
722                            .to_string(),
723                        key.to_string(),
724                    ),
725                };
726                edges.push(EdgeInfo {
727                    edge_type: edge_type.clone(),
728                    src_key,
729                    dst_key,
730                    derived: false,
731                });
732            }
733        }
734    }
735    edges.sort_by(|a, b| {
736        a.edge_type
737            .cmp(&b.edge_type)
738            .then(a.src_key.cmp(&b.src_key))
739            .then(a.dst_key.cmp(&b.dst_key))
740    });
741    edges.dedup();
742    Ok(edges)
743}
744
745fn neighborhood_masked_from(
746    key: &str,
747    state: &FrozenOverlay,
748    base: &Option<Arc<MappedBase>>,
749    depth: u32,
750    edge_types: Option<&[&str]>,
751    dir: Dir,
752    mask: &NodeMask,
753) -> Option<ResultSet> {
754    let start_id = state.ids.get(key)?;
755    let view = make_view(state, base, Some(&mask.visible));
756    let resolved: Option<Vec<u32>> = edge_types.map(|names| {
757        names
758            .iter()
759            .filter_map(|name| view.syms.get(name))
760            .collect()
761    });
762    let nb = neighborhood(&view, start_id, depth, resolved.as_deref(), dir);
763    let mut rs = ResultSet::new(vec!["key".into(), "label".into(), "depth".into()]);
764    // Collect visible BFS results (start_id at depth 0, BFS nodes after).
765    let mut visited: Vec<(u32, u32)> = Vec::with_capacity(nb.nodes.len() + 1);
766    visited.push((start_id, 0));
767    for (nid, d) in &nb.nodes {
768        let k = view.key_of(*nid);
769        let lbl = view
770            .label_of(*nid)
771            .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
772        rs.push_row(vec![
773            Some(Value::Str(k.to_string())),
774            Some(Value::Str(lbl.to_string())),
775            Some(Value::Int(*d as i64)),
776        ]);
777        visited.push((*nid, *d));
778    }
779    // Stub mode: add hidden direct neighbours of each visited node as stubs.
780    // Hidden nodes are edge-endpoints only — they are not added to the BFS
781    // frontier, so the BFS never expands through them in either mode.
782    //
783    // Role-token callers always pass an Omit-mode mask (mask_for_role uses
784    // NodeMask::from_ids which defaults to Omit; intersect() hard-returns Omit),
785    // so this branch is unreachable on the role path — security is unaffected.
786    if mask.mode() == crate::mask::MaskMode::Stub {
787        let raw_view = make_view(state, base, None);
788        let mut seen: HashSet<u32> = visited.iter().map(|(id, _)| *id).collect();
789        for (node_id, node_depth) in &visited {
790            if *node_depth >= depth {
791                continue;
792            }
793            for e in expand(&raw_view, *node_id, resolved.as_deref(), dir) {
794                let nbr = if e.src == *node_id { e.dst } else { e.src };
795                if !mask.contains_id(nbr) && seen.insert(nbr) {
796                    if let Some(k) = state.ids.key_of(nbr) {
797                        rs.push_row(vec![
798                            Some(Value::Str(k.to_string())),
799                            None,
800                            Some(Value::Int((*node_depth + 1) as i64)),
801                        ]);
802                    }
803                }
804            }
805        }
806    }
807    Some(rs)
808}