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 core_query::visible::VisibleSet>,
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        // The opt-in declaration is a writer-side gate; the read path never
356        // decides whether to write a record. A *count* is not a no-op and is
357        // handled below.
358        | WalRecord::SetEdgeCount { count: 0, .. }
359        // History markers are no-ops in the read path. Rules re-derive their
360        // edges when the ReaderSnapshot queries the live engine; markers only
361        // serve edge_history / was_linked WAL scans.
362        | WalRecord::DerivedEdgeAdded { .. }
363        | WalRecord::DerivedEdgeRetracted { .. } => {}
364
365        // An insert count is an edge property, so the read path carries it the
366        // way it carries any other: absolute, last write wins.
367        WalRecord::SetEdgeCount {
368            etype,
369            src,
370            dst,
371            count,
372        } => {
373            edge_props.set(
374                *etype,
375                *src,
376                *dst,
377                crate::db::EDGE_COUNT_PROP,
378                core_storage::Value::Int(*count as i64),
379            );
380        }
381
382        WalRecord::RenameNode { old_key, new_key } => {
383            // Recovery-safe: if old_key is already gone (frozen overlay or a
384            // prior delta already applied the rename), skip cleanly.
385            if ids.get(old_key).is_some() {
386                ids.rename(old_key, new_key).map_err(|_| GraphError::Corrupt {
387                    detail: format!("mvcc delta RenameNode {old_key}→{new_key} failed"),
388                })?;
389            }
390        }
391    }
392    Ok(())
393}
394
395// ── ReaderSnapshot ────────────────────────────────────────────────────────────
396
397/// Resolve `role` against a frozen overlay — the reader-side twin of
398/// [`crate::db::GraphDb::mask_for_role`], and kept identical to it.
399///
400/// Takes `base` because a role carrying a `visible_where` predicate has to read
401/// properties, and a node's property may live in the mmap'd base rather than
402/// the overlay.
403fn mask_for_role_from(
404    state: &FrozenOverlay,
405    base: &Option<Arc<MappedBase>>,
406    role: &str,
407) -> Result<NodeMask> {
408    let roles = state.roles.as_ref().ok_or_else(|| GraphError::Corrupt {
409        detail: "roles.json was corrupt at open; fix the file and re-open".into(),
410    })?;
411    let def = roles
412        .iter()
413        .find(|r| r.name == role)
414        .ok_or_else(|| GraphError::KeyNotFound {
415            key: format!("role:{role}"),
416        })?;
417    let mut visible = HashSet::new();
418    // Key leg: an administrative grant, never narrowed by the predicate.
419    for key in &def.keys {
420        if let Some(id) = state.ids.get(key) {
421            visible.insert(id);
422        }
423    }
424    let props = def
425        .visible_where
426        .as_ref()
427        .map(|_| build_cv(&state.props, base));
428    for label_name in &def.labels {
429        if let Some(sym) = state.syms.get(label_name) {
430            for (i, &s) in state.labels.iter().enumerate() {
431                if s != sym {
432                    continue;
433                }
434                let id = i as u32;
435                match (&def.visible_where, &props) {
436                    (Some(pred), Some(view)) => {
437                        let value = view.get(id, &pred.field).map(|vr| vr.into_value());
438                        if pred.holds(value.as_ref()) {
439                            visible.insert(id);
440                        }
441                    }
442                    _ => {
443                        visible.insert(id);
444                    }
445                }
446            }
447        }
448    }
449    // Namespace leg — the live resolver's retain, against the frozen overlay's
450    // own `ns` column (the reader has no derived `node_ns` array: its effective
451    // state is assembled per snapshot, and reading the column it would mirror is
452    // the same answer by construction). Intersects the key leg too; see
453    // `RoleDef::namespaces`.
454    if def.namespaces.is_some() {
455        let cv = build_cv(&state.props, base);
456        visible.retain(|&id| {
457            let value = cv.get(id, core_storage::NS_PROP).map(|vr| vr.into_value());
458            def.sees_namespace(core_storage::namespace_of_value(value.as_ref()))
459        });
460    }
461    Ok(NodeMask::from_ids(visible))
462}
463
464impl ReaderSnapshot {
465    /// Apply all pending deltas to a clone of `frozen`.
466    ///
467    /// Returns the frozen state (cloned) with delta changes applied, including
468    /// rule-derived edge inserts/retracts and a rebuilt full-text index.
469    fn materialize(&self) -> Result<FrozenOverlay> {
470        let mut w = (*self.frozen).clone();
471        for delta in &self.deltas {
472            for rec in &delta.records {
473                apply_one(
474                    &mut w.ids,
475                    &mut w.syms,
476                    &mut w.topo,
477                    &mut w.props,
478                    &mut w.edge_props,
479                    &mut w.labels,
480                    &mut w.fulltext,
481                    rec,
482                )?;
483            }
484            for &(etype, src, dst) in &delta.derived_inserts {
485                w.topo.add_edge(etype, src, dst);
486            }
487            for &(etype, src, dst) in &delta.derived_deletes {
488                w.topo.remove_edge(etype, src, dst);
489            }
490        }
491        if !self.deltas.is_empty() {
492            // Rebuild full-text to correct incremental drift accumulated during
493            // delta application (add_tokens is imprecise for multi-field/deletion paths).
494            let cv = build_cv(&w.props, &self.base);
495            w.fulltext.rebuild_all(&w.ids, &w.labels, &w.syms, cv);
496        }
497        Ok(w)
498    }
499
500    /// Construct a `ReaderSnapshot` from its constituent parts.
501    ///
502    /// Used by [`crate::db::GraphDb::reader`] — the only site that builds a
503    /// snapshot — so the private `cache` field stays encapsulated here.
504    pub(crate) fn new(
505        frozen: Arc<FrozenOverlay>,
506        base: Option<Arc<MappedBase>>,
507        deltas: Vec<Arc<CommitDelta>>,
508        version: u64,
509        role_masks: Arc<RoleMaskCache>,
510    ) -> Self {
511        Self {
512            frozen,
513            base,
514            deltas,
515            version,
516            role_masks,
517            cache: OnceLock::new(),
518        }
519    }
520
521    // ── Private helpers ───────────────────────────────────────────────────────
522
523    /// Return a reference to the current effective state.
524    ///
525    /// When the delta tail is empty this is a zero-copy borrow of `frozen`.
526    /// Otherwise the delta tail is applied to a clone of `frozen` exactly once
527    /// (cached in `self.cache`) so that all operations within a single
528    /// `ReaderSnapshot` share the same materialized view (F3: no triple
529    /// materialize per request).
530    fn effective(&self) -> Result<&FrozenOverlay> {
531        if self.deltas.is_empty() {
532            return Ok(&self.frozen);
533        }
534        let cached = self
535            .cache
536            .get_or_init(|| self.materialize().map_err(|e| e.to_string()));
537        cached
538            .as_ref()
539            .map_err(|e| GraphError::Corrupt { detail: e.clone() })
540    }
541
542    // ── Public API ────────────────────────────────────────────────────────────
543
544    /// Resolve a role name to a node visibility mask.
545    ///
546    /// Coherent with [`Self::query_masked`]: both read from the same effective
547    /// state (frozen or cached materialization), so the mask is never stale
548    /// relative to the query data.
549    ///
550    /// Memoised per `(role, version)` in the cache shared with the originating
551    /// `GraphDb`, so a scoped reader taking snapshot after snapshot between two
552    /// writes resolves the role once.
553    pub fn mask_for_role(&self, role: &str) -> Result<NodeMask> {
554        self.role_masks
555            .get_or_build(role, self.version, || {
556                mask_for_role_from(self.effective()?, &self.base, role)
557            })
558            .map(|m| (*m).clone())
559    }
560
561    /// Every live node in `namespace`, as a visibility mask.
562    ///
563    /// The snapshot-reader twin of [`GraphDb::mask_for_namespace`](crate::GraphDb::mask_for_namespace):
564    /// read off the effective state's own `ns` column rather than a derived
565    /// array, exactly as the namespace leg of [`Self::mask_for_role`] is. A name
566    /// no node uses gives an empty mask — a namespace scope never widens.
567    pub fn mask_for_namespace(&self, namespace: &str) -> Result<NodeMask> {
568        let state = self.effective()?;
569        let cv = build_cv(&state.props, &self.base);
570        let mut visible = HashSet::new();
571        for (i, &sym) in state.labels.iter().enumerate() {
572            if sym == u32::MAX {
573                continue; // tombstoned: the label sentinel is what marks it gone
574            }
575            let id = i as u32;
576            let value = cv.get(id, core_storage::NS_PROP).map(|vr| vr.into_value());
577            if core_storage::namespace_of_value(value.as_ref()) == namespace {
578                visible.insert(id);
579            }
580        }
581        Ok(NodeMask::from_ids(visible))
582    }
583
584    /// Resolve a node key to its dense id.
585    ///
586    /// Checks the delta tail (via the cached materialization) so that nodes
587    /// inserted since the last fold are visible.
588    pub fn resolve_key(&self, key: &str) -> Option<u32> {
589        self.effective().ok()?.ids.get(key)
590    }
591
592    /// Execute a read-only Cypher query over the epoch snapshot.
593    pub fn query(&self, cypher: &str, params: &BTreeMap<String, Value>) -> Result<ResultSet> {
594        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
595            detail: format!("lex: {e}"),
596        })?;
597        let ast = parse(&tokens).map_err(|e| GraphError::QueryError {
598            detail: format!("parse: {e}"),
599        })?;
600        let ops = plan(&ast).map_err(|e| GraphError::QueryError {
601            detail: format!("plan: {e}"),
602        })?;
603        let state = self.effective()?;
604        let view = make_view(state, &self.base, None);
605        execute(&view, &ops, &Params(params)).map_err(|e| GraphError::QueryError {
606            detail: format!("execute: {e}"),
607        })
608    }
609
610    /// Execute a read-only Cypher query with a node visibility mask.
611    ///
612    /// Returns `Err` when `cypher` is a write statement (CREATE / MATCH…SET / DELETE).
613    pub fn query_masked(
614        &self,
615        cypher: &str,
616        params: &BTreeMap<String, Value>,
617        mask: &NodeMask,
618    ) -> Result<ResultSet> {
619        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
620            detail: format!("lex: {e}"),
621        })?;
622        if is_write_tokens(&tokens) {
623            return Err(GraphError::QueryError {
624                detail: "masked queries are read-only".into(),
625            });
626        }
627        let ast = parse(&tokens).map_err(|e| GraphError::QueryError {
628            detail: format!("parse: {e}"),
629        })?;
630        let ops = plan(&ast).map_err(|e| GraphError::QueryError {
631            detail: format!("plan: {e}"),
632        })?;
633        let state = self.effective()?;
634        let view = make_view(state, &self.base, Some(&mask.visible));
635        execute(&view, &ops, &Params(params)).map_err(|e| GraphError::QueryError {
636            detail: format!("execute: {e}"),
637        })
638    }
639
640    /// Live node info from the epoch snapshot. `None` if key is absent or tombstoned.
641    pub fn node_info(&self, key: &str) -> Option<NodeInfo> {
642        node_info_from(key, self.effective().ok()?, &self.base)
643    }
644
645    /// Every directed edge incident on `key`. `derived` is always `false` since
646    /// the reader snapshot has no rule engine.
647    ///
648    /// Unknown key → `Err(GraphError::KeyNotFound)`.
649    pub fn node_edges(&self, key: &str) -> Result<Vec<EdgeInfo>> {
650        node_edges_from(key, self.effective()?, &self.base)
651    }
652
653    /// BFS neighborhood expansion restricted to `mask`-visible nodes.
654    ///
655    /// Hidden nodes are neither returned nor used as traversal intermediaries
656    /// (never-leak invariant). Returns `None` when `key` does not exist.
657    pub fn neighborhood_masked(
658        &self,
659        key: &str,
660        depth: u32,
661        edge_types: Option<&[&str]>,
662        dir: Dir,
663        mask: &NodeMask,
664    ) -> Option<ResultSet> {
665        neighborhood_masked_from(
666            key,
667            self.effective().ok()?,
668            &self.base,
669            depth,
670            edge_types,
671            dir,
672            mask,
673        )
674    }
675
676    /// Every edge incident on `key` that the scope may see.
677    ///
678    /// The snapshot twin of [`GraphDb::node_edges_scoped`](crate::GraphDb::node_edges_scoped),
679    /// and the contract HTTP's role-token branch used to write by hand: the
680    /// subject is checked first, so a hidden key answers exactly as an absent
681    /// one does, and then every edge whose *other* endpoint is hidden is
682    /// dropped — a visible node must not become a window onto its hidden
683    /// neighbours.
684    ///
685    /// `derived` is always `false`, as it is on [`Self::node_edges`]: a snapshot
686    /// carries no rule engine.
687    ///
688    /// Hidden or unknown `key` → [`GraphError::KeyNotFound`].
689    pub fn node_edges_scoped(&self, key: &str, mask: &NodeMask) -> Result<Vec<EdgeInfo>> {
690        let state = self.effective()?;
691        if !state.ids.get(key).is_some_and(|id| mask.contains_id(id)) {
692            return Err(GraphError::KeyNotFound { key: key.into() });
693        }
694        let edges = node_edges_from(key, state, &self.base)?;
695        Ok(edges
696            .into_iter()
697            .filter(|e| {
698                let other = if e.src_key == key {
699                    &e.dst_key
700                } else {
701                    &e.src_key
702                };
703                state.ids.get(other).is_some_and(|id| mask.contains_id(id))
704            })
705            .collect())
706    }
707
708    /// BFS expansion from `key`, with the subject check
709    /// [`Self::neighborhood_masked`] deliberately omits.
710    ///
711    /// The snapshot twin of [`GraphDb::neighborhood_scoped`](crate::GraphDb::neighborhood_scoped).
712    /// Expansion is unchanged — hidden nodes are neither returned nor traversed
713    /// through — so a visible node reachable only through a hidden one stays
714    /// out.
715    ///
716    /// Hidden or unknown `key` → [`GraphError::KeyNotFound`].
717    pub fn neighborhood_scoped(
718        &self,
719        key: &str,
720        depth: u32,
721        edge_types: Option<&[&str]>,
722        dir: Dir,
723        mask: &NodeMask,
724    ) -> Result<ResultSet> {
725        let state = self.effective()?;
726        if !state.ids.get(key).is_some_and(|id| mask.contains_id(id)) {
727            return Err(GraphError::KeyNotFound { key: key.into() });
728        }
729        neighborhood_masked_from(key, state, &self.base, depth, edge_types, dir, mask)
730            .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })
731    }
732}
733
734// ── Free-standing helpers that take state by reference ────────────────────────
735
736fn node_info_from(
737    key: &str,
738    state: &FrozenOverlay,
739    base: &Option<Arc<MappedBase>>,
740) -> Option<NodeInfo> {
741    let id = state.ids.get(key)?;
742    let label_sym = *state.labels.get(id as usize)?;
743    if label_sym == u32::MAX {
744        return None;
745    }
746    let label = state.syms.resolve(label_sym)?.to_string();
747    let cv = build_cv(&state.props, base);
748    let mut props = BTreeMap::new();
749    for field in cv.field_names() {
750        if let Some(vr) = cv.get(id, &field) {
751            props.insert(field, vr.into_value());
752        }
753    }
754    Some(NodeInfo {
755        key: key.to_string(),
756        label,
757        props,
758    })
759}
760
761fn node_edges_from(
762    key: &str,
763    state: &FrozenOverlay,
764    base: &Option<Arc<MappedBase>>,
765) -> Result<Vec<EdgeInfo>> {
766    let id = state
767        .ids
768        .get(key)
769        .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
770    let tv = build_tv(&state.topo, base);
771    let mut edges = Vec::new();
772    for etype in tv.etypes() {
773        let edge_type = state
774            .syms
775            .resolve(etype)
776            .ok_or_else(|| GraphError::Corrupt {
777                detail: format!("reader: topology etype {etype} not in interner"),
778            })?
779            .to_string();
780        for dir in [Direction::Out, Direction::In] {
781            for &nbr in tv.neighbors(etype, dir, id).as_ref() {
782                let (src_key, dst_key) = match dir {
783                    Direction::Out => (
784                        key.to_string(),
785                        state
786                            .ids
787                            .key_of(nbr)
788                            .ok_or_else(|| GraphError::Corrupt {
789                                detail: format!("topology id {nbr} has no key"),
790                            })?
791                            .to_string(),
792                    ),
793                    Direction::In => (
794                        state
795                            .ids
796                            .key_of(nbr)
797                            .ok_or_else(|| GraphError::Corrupt {
798                                detail: format!("topology id {nbr} has no key"),
799                            })?
800                            .to_string(),
801                        key.to_string(),
802                    ),
803                };
804                edges.push(EdgeInfo {
805                    edge_type: edge_type.clone(),
806                    src_key,
807                    dst_key,
808                    derived: false,
809                });
810            }
811        }
812    }
813    edges.sort_by(|a, b| {
814        a.edge_type
815            .cmp(&b.edge_type)
816            .then(a.src_key.cmp(&b.src_key))
817            .then(a.dst_key.cmp(&b.dst_key))
818    });
819    edges.dedup();
820    Ok(edges)
821}
822
823fn neighborhood_masked_from(
824    key: &str,
825    state: &FrozenOverlay,
826    base: &Option<Arc<MappedBase>>,
827    depth: u32,
828    edge_types: Option<&[&str]>,
829    dir: Dir,
830    mask: &NodeMask,
831) -> Option<ResultSet> {
832    let start_id = state.ids.get(key)?;
833    let view = make_view(state, base, Some(&mask.visible));
834    let resolved: Option<Vec<u32>> = edge_types.map(|names| {
835        names
836            .iter()
837            .filter_map(|name| view.syms.get(name))
838            .collect()
839    });
840    let nb = neighborhood(&view, start_id, depth, resolved.as_deref(), dir);
841    let mut rs = ResultSet::new(vec!["key".into(), "label".into(), "depth".into()]);
842    // Collect visible BFS results (start_id at depth 0, BFS nodes after).
843    let mut visited: Vec<(u32, u32)> = Vec::with_capacity(nb.nodes.len() + 1);
844    visited.push((start_id, 0));
845    for (nid, d) in &nb.nodes {
846        let k = view.key_of(*nid);
847        let lbl = view
848            .label_of(*nid)
849            .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
850        rs.push_row(vec![
851            Some(Value::Str(k.to_string())),
852            Some(Value::Str(lbl.to_string())),
853            Some(Value::Int(*d as i64)),
854        ]);
855        visited.push((*nid, *d));
856    }
857    // Stub mode: add hidden direct neighbours of each visited node as stubs.
858    // Hidden nodes are edge-endpoints only — they are not added to the BFS
859    // frontier, so the BFS never expands through them in either mode.
860    //
861    // Role-token callers always pass an Omit-mode mask (mask_for_role uses
862    // NodeMask::from_ids which defaults to Omit; intersect() hard-returns Omit),
863    // so this branch is unreachable on the role path — security is unaffected.
864    if mask.mode() == crate::mask::MaskMode::Stub {
865        let raw_view = make_view(state, base, None);
866        let mut seen: HashSet<u32> = visited.iter().map(|(id, _)| *id).collect();
867        for (node_id, node_depth) in &visited {
868            if *node_depth >= depth {
869                continue;
870            }
871            for e in expand(&raw_view, *node_id, resolved.as_deref(), dir) {
872                let nbr = if e.src == *node_id { e.dst } else { e.src };
873                if !mask.contains_id(nbr) && seen.insert(nbr) {
874                    if let Some(k) = state.ids.key_of(nbr) {
875                        rs.push_row(vec![
876                            Some(Value::Str(k.to_string())),
877                            None,
878                            Some(Value::Int((*node_depth + 1) as i64)),
879                        ]);
880                    }
881                }
882            }
883        }
884    }
885    Some(rs)
886}