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: Arc<IdMap>,
54    pub syms: Arc<Interner>,
55    pub topo: Arc<Topology>,
56    pub props: Arc<ColumnStore>,
57    pub labels: Arc<Vec<u32>>,
58    pub edge_props: Arc<EdgeProps>,
59    pub roles: Option<Arc<Vec<RoleDef>>>,
60    pub fulltext: Arc<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        // Cloning the struct now clones eight `Arc`s — refcount bumps, not data.
471        // Each `Arc::make_mut` below copies exactly one structure, once, and only
472        // because this snapshot is about to mutate its own working copy. The
473        // fold this came from keeps its allocation, which is what preserves the
474        // isolation `tests/reader_isolation.rs` pins.
475        //
476        // Before the fields were `Arc`, this deep-copied all eight every time a
477        // snapshot with a non-empty delta tail was read. Now it copies only what
478        // the deltas actually touch.
479        let mut w = (*self.frozen).clone();
480        for delta in &self.deltas {
481            for rec in &delta.records {
482                apply_one(
483                    Arc::make_mut(&mut w.ids),
484                    Arc::make_mut(&mut w.syms),
485                    Arc::make_mut(&mut w.topo),
486                    Arc::make_mut(&mut w.props),
487                    Arc::make_mut(&mut w.edge_props),
488                    Arc::make_mut(&mut w.labels),
489                    Arc::make_mut(&mut w.fulltext),
490                    rec,
491                )?;
492            }
493            for &(etype, src, dst) in &delta.derived_inserts {
494                Arc::make_mut(&mut w.topo).add_edge(etype, src, dst);
495            }
496            for &(etype, src, dst) in &delta.derived_deletes {
497                Arc::make_mut(&mut w.topo).remove_edge(etype, src, dst);
498            }
499        }
500        if !self.deltas.is_empty() {
501            // Rebuild full-text to correct incremental drift accumulated during
502            // delta application (add_tokens is imprecise for multi-field/deletion paths).
503            let cv = build_cv(&w.props, &self.base);
504            let (ids, labels, syms) = (w.ids.clone(), w.labels.clone(), w.syms.clone());
505            Arc::make_mut(&mut w.fulltext).rebuild_all(&ids, &labels, &syms, cv);
506        }
507        Ok(w)
508    }
509
510    /// Construct a `ReaderSnapshot` from its constituent parts.
511    ///
512    /// Used by [`crate::db::GraphDb::reader`] — the only site that builds a
513    /// snapshot — so the private `cache` field stays encapsulated here.
514    pub(crate) fn new(
515        frozen: Arc<FrozenOverlay>,
516        base: Option<Arc<MappedBase>>,
517        deltas: Vec<Arc<CommitDelta>>,
518        version: u64,
519        role_masks: Arc<RoleMaskCache>,
520    ) -> Self {
521        Self {
522            frozen,
523            base,
524            deltas,
525            version,
526            role_masks,
527            cache: OnceLock::new(),
528        }
529    }
530
531    // ── Private helpers ───────────────────────────────────────────────────────
532
533    /// Return a reference to the current effective state.
534    ///
535    /// When the delta tail is empty this is a zero-copy borrow of `frozen`.
536    /// Otherwise the delta tail is applied to a clone of `frozen` exactly once
537    /// (cached in `self.cache`) so that all operations within a single
538    /// `ReaderSnapshot` share the same materialized view (F3: no triple
539    /// materialize per request).
540    fn effective(&self) -> Result<&FrozenOverlay> {
541        if self.deltas.is_empty() {
542            return Ok(&self.frozen);
543        }
544        let cached = self
545            .cache
546            .get_or_init(|| self.materialize().map_err(|e| e.to_string()));
547        cached
548            .as_ref()
549            .map_err(|e| GraphError::Corrupt { detail: e.clone() })
550    }
551
552    // ── Public API ────────────────────────────────────────────────────────────
553
554    /// Resolve a role name to a node visibility mask.
555    ///
556    /// Coherent with [`Self::query_masked`]: both read from the same effective
557    /// state (frozen or cached materialization), so the mask is never stale
558    /// relative to the query data.
559    ///
560    /// Memoised per `(role, version)` in the cache shared with the originating
561    /// `GraphDb`, so a scoped reader taking snapshot after snapshot between two
562    /// writes resolves the role once.
563    pub fn mask_for_role(&self, role: &str) -> Result<NodeMask> {
564        self.role_masks
565            .get_or_build(role, self.version, || {
566                mask_for_role_from(self.effective()?, &self.base, role)
567            })
568            .map(|m| (*m).clone())
569    }
570
571    /// Every live node in `namespace`, as a visibility mask.
572    ///
573    /// The snapshot-reader twin of [`GraphDb::mask_for_namespace`](crate::GraphDb::mask_for_namespace):
574    /// read off the effective state's own `ns` column rather than a derived
575    /// array, exactly as the namespace leg of [`Self::mask_for_role`] is. A name
576    /// no node uses gives an empty mask — a namespace scope never widens.
577    pub fn mask_for_namespace(&self, namespace: &str) -> Result<NodeMask> {
578        let state = self.effective()?;
579        let cv = build_cv(&state.props, &self.base);
580        let mut visible = HashSet::new();
581        for (i, &sym) in state.labels.iter().enumerate() {
582            if sym == u32::MAX {
583                continue; // tombstoned: the label sentinel is what marks it gone
584            }
585            let id = i as u32;
586            let value = cv.get(id, core_storage::NS_PROP).map(|vr| vr.into_value());
587            if core_storage::namespace_of_value(value.as_ref()) == namespace {
588                visible.insert(id);
589            }
590        }
591        Ok(NodeMask::from_ids(visible))
592    }
593
594    /// Resolve a node key to its dense id.
595    ///
596    /// Checks the delta tail (via the cached materialization) so that nodes
597    /// inserted since the last fold are visible.
598    pub fn resolve_key(&self, key: &str) -> Option<u32> {
599        self.effective().ok()?.ids.get(key)
600    }
601
602    /// Execute a read-only Cypher query over the epoch snapshot.
603    pub fn query(&self, cypher: &str, params: &BTreeMap<String, Value>) -> Result<ResultSet> {
604        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
605            detail: format!("lex: {e}"),
606        })?;
607        let ast = parse(&tokens).map_err(|e| GraphError::QueryError {
608            detail: format!("parse: {e}"),
609        })?;
610        let ops = plan(&ast).map_err(|e| GraphError::QueryError {
611            detail: format!("plan: {e}"),
612        })?;
613        let state = self.effective()?;
614        let view = make_view(state, &self.base, None);
615        execute(&view, &ops, &Params(params)).map_err(|e| GraphError::QueryError {
616            detail: format!("execute: {e}"),
617        })
618    }
619
620    /// Execute a read-only Cypher query with a node visibility mask.
621    ///
622    /// Returns `Err` when `cypher` is a write statement (CREATE / MATCH…SET / DELETE).
623    pub fn query_masked(
624        &self,
625        cypher: &str,
626        params: &BTreeMap<String, Value>,
627        mask: &NodeMask,
628    ) -> Result<ResultSet> {
629        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
630            detail: format!("lex: {e}"),
631        })?;
632        if is_write_tokens(&tokens) {
633            return Err(GraphError::QueryError {
634                detail: "masked queries are read-only".into(),
635            });
636        }
637        let ast = parse(&tokens).map_err(|e| GraphError::QueryError {
638            detail: format!("parse: {e}"),
639        })?;
640        let ops = plan(&ast).map_err(|e| GraphError::QueryError {
641            detail: format!("plan: {e}"),
642        })?;
643        let state = self.effective()?;
644        let view = make_view(state, &self.base, Some(&mask.visible));
645        execute(&view, &ops, &Params(params)).map_err(|e| GraphError::QueryError {
646            detail: format!("execute: {e}"),
647        })
648    }
649
650    /// Live node info from the epoch snapshot. `None` if key is absent or tombstoned.
651    pub fn node_info(&self, key: &str) -> Option<NodeInfo> {
652        node_info_from(key, self.effective().ok()?, &self.base)
653    }
654
655    /// Every directed edge incident on `key`. `derived` is always `false` since
656    /// the reader snapshot has no rule engine.
657    ///
658    /// Unknown key → `Err(GraphError::KeyNotFound)`.
659    pub fn node_edges(&self, key: &str) -> Result<Vec<EdgeInfo>> {
660        node_edges_from(key, self.effective()?, &self.base)
661    }
662
663    /// BFS neighborhood expansion restricted to `mask`-visible nodes.
664    ///
665    /// Hidden nodes are neither returned nor used as traversal intermediaries
666    /// (never-leak invariant). Returns `None` when `key` does not exist.
667    pub fn neighborhood_masked(
668        &self,
669        key: &str,
670        depth: u32,
671        edge_types: Option<&[&str]>,
672        dir: Dir,
673        mask: &NodeMask,
674    ) -> Option<ResultSet> {
675        neighborhood_masked_from(
676            key,
677            self.effective().ok()?,
678            &self.base,
679            depth,
680            edge_types,
681            dir,
682            mask,
683        )
684    }
685
686    /// Every edge incident on `key` that the scope may see.
687    ///
688    /// The snapshot twin of [`GraphDb::node_edges_scoped`](crate::GraphDb::node_edges_scoped),
689    /// and the contract HTTP's role-token branch used to write by hand: the
690    /// subject is checked first, so a hidden key answers exactly as an absent
691    /// one does, and then every edge whose *other* endpoint is hidden is
692    /// dropped — a visible node must not become a window onto its hidden
693    /// neighbours.
694    ///
695    /// `derived` is always `false`, as it is on [`Self::node_edges`]: a snapshot
696    /// carries no rule engine.
697    ///
698    /// Hidden or unknown `key` → [`GraphError::KeyNotFound`].
699    pub fn node_edges_scoped(&self, key: &str, mask: &NodeMask) -> Result<Vec<EdgeInfo>> {
700        let state = self.effective()?;
701        if !state.ids.get(key).is_some_and(|id| mask.contains_id(id)) {
702            return Err(GraphError::KeyNotFound { key: key.into() });
703        }
704        let edges = node_edges_from(key, state, &self.base)?;
705        Ok(edges
706            .into_iter()
707            .filter(|e| {
708                let other = if e.src_key == key {
709                    &e.dst_key
710                } else {
711                    &e.src_key
712                };
713                state.ids.get(other).is_some_and(|id| mask.contains_id(id))
714            })
715            .collect())
716    }
717
718    /// BFS expansion from `key`, with the subject check
719    /// [`Self::neighborhood_masked`] deliberately omits.
720    ///
721    /// The snapshot twin of [`GraphDb::neighborhood_scoped`](crate::GraphDb::neighborhood_scoped).
722    /// Expansion is unchanged — hidden nodes are neither returned nor traversed
723    /// through — so a visible node reachable only through a hidden one stays
724    /// out.
725    ///
726    /// Hidden or unknown `key` → [`GraphError::KeyNotFound`].
727    pub fn neighborhood_scoped(
728        &self,
729        key: &str,
730        depth: u32,
731        edge_types: Option<&[&str]>,
732        dir: Dir,
733        mask: &NodeMask,
734    ) -> Result<ResultSet> {
735        let state = self.effective()?;
736        if !state.ids.get(key).is_some_and(|id| mask.contains_id(id)) {
737            return Err(GraphError::KeyNotFound { key: key.into() });
738        }
739        neighborhood_masked_from(key, state, &self.base, depth, edge_types, dir, mask)
740            .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })
741    }
742}
743
744// ── Free-standing helpers that take state by reference ────────────────────────
745
746fn node_info_from(
747    key: &str,
748    state: &FrozenOverlay,
749    base: &Option<Arc<MappedBase>>,
750) -> Option<NodeInfo> {
751    let id = state.ids.get(key)?;
752    let label_sym = *state.labels.get(id as usize)?;
753    if label_sym == u32::MAX {
754        return None;
755    }
756    let label = state.syms.resolve(label_sym)?.to_string();
757    let cv = build_cv(&state.props, base);
758    let mut props = BTreeMap::new();
759    for field in cv.field_names() {
760        if let Some(vr) = cv.get(id, &field) {
761            props.insert(field, vr.into_value());
762        }
763    }
764    Some(NodeInfo {
765        key: key.to_string(),
766        label,
767        props,
768    })
769}
770
771fn node_edges_from(
772    key: &str,
773    state: &FrozenOverlay,
774    base: &Option<Arc<MappedBase>>,
775) -> Result<Vec<EdgeInfo>> {
776    let id = state
777        .ids
778        .get(key)
779        .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
780    let tv = build_tv(&state.topo, base);
781    let mut edges = Vec::new();
782    for etype in tv.etypes() {
783        let edge_type = state
784            .syms
785            .resolve(etype)
786            .ok_or_else(|| GraphError::Corrupt {
787                detail: format!("reader: topology etype {etype} not in interner"),
788            })?
789            .to_string();
790        for dir in [Direction::Out, Direction::In] {
791            for &nbr in tv.neighbors(etype, dir, id).as_ref() {
792                let (src_key, dst_key) = match dir {
793                    Direction::Out => (
794                        key.to_string(),
795                        state
796                            .ids
797                            .key_of(nbr)
798                            .ok_or_else(|| GraphError::Corrupt {
799                                detail: format!("topology id {nbr} has no key"),
800                            })?
801                            .to_string(),
802                    ),
803                    Direction::In => (
804                        state
805                            .ids
806                            .key_of(nbr)
807                            .ok_or_else(|| GraphError::Corrupt {
808                                detail: format!("topology id {nbr} has no key"),
809                            })?
810                            .to_string(),
811                        key.to_string(),
812                    ),
813                };
814                edges.push(EdgeInfo {
815                    edge_type: edge_type.clone(),
816                    src_key,
817                    dst_key,
818                    derived: false,
819                });
820            }
821        }
822    }
823    edges.sort_by(|a, b| {
824        a.edge_type
825            .cmp(&b.edge_type)
826            .then(a.src_key.cmp(&b.src_key))
827            .then(a.dst_key.cmp(&b.dst_key))
828    });
829    edges.dedup();
830    Ok(edges)
831}
832
833fn neighborhood_masked_from(
834    key: &str,
835    state: &FrozenOverlay,
836    base: &Option<Arc<MappedBase>>,
837    depth: u32,
838    edge_types: Option<&[&str]>,
839    dir: Dir,
840    mask: &NodeMask,
841) -> Option<ResultSet> {
842    let start_id = state.ids.get(key)?;
843    let view = make_view(state, base, Some(&mask.visible));
844    let resolved: Option<Vec<u32>> = edge_types.map(|names| {
845        names
846            .iter()
847            .filter_map(|name| view.syms.get(name))
848            .collect()
849    });
850    let nb = neighborhood(&view, start_id, depth, resolved.as_deref(), dir);
851    let mut rs = ResultSet::new(vec!["key".into(), "label".into(), "depth".into()]);
852    // Collect visible BFS results (start_id at depth 0, BFS nodes after).
853    let mut visited: Vec<(u32, u32)> = Vec::with_capacity(nb.nodes.len() + 1);
854    visited.push((start_id, 0));
855    for (nid, d) in &nb.nodes {
856        let k = view.key_of(*nid);
857        let lbl = view
858            .label_of(*nid)
859            .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
860        rs.push_row(vec![
861            Some(Value::Str(k.to_string())),
862            Some(Value::Str(lbl.to_string())),
863            Some(Value::Int(*d as i64)),
864        ]);
865        visited.push((*nid, *d));
866    }
867    // Stub mode: add hidden direct neighbours of each visited node as stubs.
868    // Hidden nodes are edge-endpoints only — they are not added to the BFS
869    // frontier, so the BFS never expands through them in either mode.
870    //
871    // Role-token callers always pass an Omit-mode mask (mask_for_role uses
872    // NodeMask::from_ids which defaults to Omit; intersect() hard-returns Omit),
873    // so this branch is unreachable on the role path — security is unaffected.
874    if mask.mode() == crate::mask::MaskMode::Stub {
875        let raw_view = make_view(state, base, None);
876        let mut seen: HashSet<u32> = visited.iter().map(|(id, _)| *id).collect();
877        for (node_id, node_depth) in &visited {
878            if *node_depth >= depth {
879                continue;
880            }
881            for e in expand(&raw_view, *node_id, resolved.as_deref(), dir) {
882                let nbr = if e.src == *node_id { e.dst } else { e.src };
883                if !mask.contains_id(nbr) && seen.insert(nbr) {
884                    if let Some(k) = state.ids.key_of(nbr) {
885                        rs.push_row(vec![
886                            Some(Value::Str(k.to_string())),
887                            None,
888                            Some(Value::Int((*node_depth + 1) as i64)),
889                        ]);
890                    }
891                }
892            }
893        }
894    }
895    Some(rs)
896}