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    base: &Option<Arc<MappedBase>>,
174    rec: &WalRecord,
175) -> Result<()> {
176    match rec {
177        WalRecord::Intern { id, text } => {
178            let got = syms.intern(text);
179            if got != *id {
180                return Err(GraphError::Corrupt {
181                    detail: format!(
182                        "mvcc delta intern mismatch for {text:?}: expected {id}, got {got}"
183                    ),
184                });
185            }
186        }
187
188        WalRecord::InsertNodeId {
189            label,
190            key,
191            props: node_props,
192        } => {
193            let node_id = ids.try_insert(key)?;
194            if labels.len() <= node_id as usize {
195                labels.resize(node_id as usize + 1, u32::MAX);
196            }
197            labels[node_id as usize] = *label;
198            let label_str = syms
199                .resolve(*label)
200                .ok_or_else(|| GraphError::Corrupt {
201                    detail: format!("mvcc delta: unknown label sym {label}"),
202                })?
203                .to_string();
204            for (field_sym, value) in node_props {
205                let field = syms
206                    .resolve(*field_sym)
207                    .ok_or_else(|| GraphError::Corrupt {
208                        detail: format!("mvcc delta: unknown field sym {field_sym}"),
209                    })?
210                    .to_string();
211                props.set(node_id, &field, value.clone());
212                if fulltext.is_enabled(&label_str, &field) {
213                    fulltext.add_tokens(node_id, &field, value);
214                }
215            }
216        }
217
218        WalRecord::InsertNode {
219            label,
220            key,
221            props: node_props,
222        } => {
223            let label_sym = syms.intern(label);
224            let node_id = ids.try_insert(key)?;
225            if labels.len() <= node_id as usize {
226                labels.resize(node_id as usize + 1, u32::MAX);
227            }
228            labels[node_id as usize] = label_sym;
229            for (field, value) in node_props {
230                props.set(node_id, field, value.clone());
231                if fulltext.is_enabled(label, field) {
232                    fulltext.add_tokens(node_id, field, value);
233                }
234            }
235        }
236
237        WalRecord::SetPropId { id, field, value } => {
238            if let Some(field_str) = syms.resolve(*field).map(str::to_string) {
239                props.set(*id, &field_str, value.clone());
240                if let Some(&label_sym) = labels.get(*id as usize) {
241                    if let Some(label_str) = syms.resolve(label_sym) {
242                        if fulltext.is_enabled(label_str, &field_str) {
243                            fulltext.add_tokens(*id, &field_str, value);
244                        }
245                    }
246                }
247            }
248        }
249
250        WalRecord::SetProp { key, field, value } => {
251            if let Some(node_id) = ids.get(key) {
252                props.set(node_id, field, value.clone());
253                if let Some(&label_sym) = labels.get(node_id as usize) {
254                    if let Some(label_str) = syms.resolve(label_sym) {
255                        if fulltext.is_enabled(label_str, field) {
256                            fulltext.add_tokens(node_id, field, value);
257                        }
258                    }
259                }
260            }
261        }
262
263        WalRecord::RemoveProp { key, field } => {
264            if let Some(node_id) = ids.get(key) {
265                props.remove(node_id, field);
266                // A base-resident prop must be masked or ColumnsView falls
267                // through to the archived value (mirrors db.rs WAL replay).
268                // Tombstoning a prop absent from the base is a harmless
269                // false-mask: the overlay short-circuit never reaches it.
270                props.record_prop_tombstone(node_id, field);
271                fulltext.remove_node_field(node_id, field);
272            }
273        }
274
275        WalRecord::DeleteNode { key } => {
276            if let Some(node_id) = ids.delete(key) {
277                props.remove_all(node_id);
278                fulltext.remove_node(node_id);
279                // Mark the label slot as sentinel so label_of returns None.
280                if let Some(slot) = labels.get_mut(node_id as usize) {
281                    *slot = u32::MAX;
282                }
283                // Sweep all edges incident on this node to prevent phantom
284                // adjacency. db.rs deletes these edges inline without emitting
285                // DeleteEdge WAL records, so we must mirror that sweep here —
286                // over the snapshot's edges as well as the overlay's, as
287                // db.rs does.
288                let mut doomed = Vec::new();
289                {
290                    let tv = match base {
291                        None => TopologyView::owned(topo),
292                        Some(b) => match b.topology() {
293                            Ok(archived) => TopologyView::with_base(topo, archived),
294                            Err(_) => TopologyView::owned(topo),
295                        },
296                    };
297                    let etypes: Vec<u32> = tv.etypes().collect();
298                    for et in &etypes {
299                        for &dst in tv.neighbors(*et, Direction::Out, node_id).as_ref() {
300                            doomed.push((*et, node_id, dst));
301                        }
302                        for &src in tv.neighbors(*et, Direction::In, node_id).as_ref() {
303                            if src != node_id {
304                                doomed.push((*et, src, node_id));
305                            }
306                        }
307                    }
308                }
309                for (et, s, d) in doomed {
310                    topo.remove_edge(et, s, d);
311                    edge_props.remove_edge(et, s, d);
312                }
313            }
314        }
315
316        WalRecord::InsertEdgeId { etype, src, dst } => {
317            topo.add_edge(*etype, *src, *dst);
318        }
319
320        WalRecord::InsertEdge {
321            edge_type,
322            src_key,
323            dst_key,
324        } => {
325            let etype = syms.intern(edge_type);
326            if let (Some(src), Some(dst)) = (ids.get(src_key), ids.get(dst_key)) {
327                topo.add_edge(etype, src, dst);
328            }
329        }
330
331        WalRecord::DeleteEdge {
332            edge_type,
333            src_key,
334            dst_key,
335        } => {
336            if let Some(etype) = syms.get(edge_type) {
337                if let (Some(src), Some(dst)) = (ids.get(src_key), ids.get(dst_key)) {
338                    topo.remove_edge(etype, src, dst);
339                }
340            }
341        }
342
343        WalRecord::EnableFulltext { label, field } => {
344            fulltext.enable(label, field);
345        }
346
347        WalRecord::DisableFulltext { label, field } => {
348            fulltext.disable(label, field);
349        }
350
351        WalRecord::Batch(inner) => {
352            for r in inner {
353                apply_one(ids, syms, topo, props, edge_props, labels, fulltext, base, r)?;
354            }
355        }
356
357        // No-ops for the read path: rule and view management do not affect
358        // the structural overlay data that queries read.
359        WalRecord::CreateRule { .. }
360        | WalRecord::DeleteRule { .. }
361        | WalRecord::RebuildRule { .. }
362        | WalRecord::CreateView { .. }
363        | WalRecord::DeleteView { .. }
364        // Property-index declarations are no-ops in the read path: MVCC reader
365        // snapshots do not carry the equality index, so `IndexScan` falls back
366        // to a correct scan+filter for reader queries.
367        | WalRecord::EnableIndex { .. }
368        | WalRecord::DisableIndex { .. }
369        // The opt-in declaration is a writer-side gate; the read path never
370        // decides whether to write a record. A *count* is not a no-op and is
371        // handled below.
372        | WalRecord::SetEdgeCount { count: 0, .. }
373        // History markers are no-ops in the read path. Rules re-derive their
374        // edges when the ReaderSnapshot queries the live engine; markers only
375        // serve edge_history / was_linked WAL scans.
376        | WalRecord::DerivedEdgeAdded { .. }
377        | WalRecord::DerivedEdgeRetracted { .. } => {}
378
379        // An insert count is an edge property, so the read path carries it the
380        // way it carries any other: absolute, last write wins.
381        WalRecord::SetEdgeCount {
382            etype,
383            src,
384            dst,
385            count,
386        } => {
387            edge_props.set(
388                *etype,
389                *src,
390                *dst,
391                crate::db::EDGE_COUNT_PROP,
392                core_storage::Value::Int(*count as i64),
393            );
394        }
395
396        WalRecord::RenameNode { old_key, new_key } => {
397            // Recovery-safe: if old_key is already gone (frozen overlay or a
398            // prior delta already applied the rename), skip cleanly.
399            if ids.get(old_key).is_some() {
400                ids.rename(old_key, new_key).map_err(|_| GraphError::Corrupt {
401                    detail: format!("mvcc delta RenameNode {old_key}→{new_key} failed"),
402                })?;
403            }
404        }
405    }
406    Ok(())
407}
408
409// ── ReaderSnapshot ────────────────────────────────────────────────────────────
410
411/// Resolve `role` against a frozen overlay — the reader-side twin of
412/// [`crate::db::GraphDb::mask_for_role`], and kept identical to it.
413///
414/// Takes `base` because a role carrying a `visible_where` predicate has to read
415/// properties, and a node's property may live in the mmap'd base rather than
416/// the overlay.
417fn mask_for_role_from(
418    state: &FrozenOverlay,
419    base: &Option<Arc<MappedBase>>,
420    role: &str,
421) -> Result<NodeMask> {
422    let roles = state.roles.as_ref().ok_or_else(|| GraphError::Corrupt {
423        detail: "roles.json was corrupt at open; fix the file and re-open".into(),
424    })?;
425    let def = roles
426        .iter()
427        .find(|r| r.name == role)
428        .ok_or_else(|| GraphError::KeyNotFound {
429            key: format!("role:{role}"),
430        })?;
431    let mut visible = HashSet::new();
432    // Key leg: an administrative grant, never narrowed by the predicate.
433    for key in &def.keys {
434        if let Some(id) = state.ids.get(key) {
435            visible.insert(id);
436        }
437    }
438    let props = def
439        .visible_where
440        .as_ref()
441        .map(|_| build_cv(&state.props, base));
442    for label_name in &def.labels {
443        if let Some(sym) = state.syms.get(label_name) {
444            for (i, &s) in state.labels.iter().enumerate() {
445                if s != sym {
446                    continue;
447                }
448                let id = i as u32;
449                match (&def.visible_where, &props) {
450                    (Some(pred), Some(view)) => {
451                        let value = view.get(id, &pred.field).map(|vr| vr.into_value());
452                        if pred.holds(value.as_ref()) {
453                            visible.insert(id);
454                        }
455                    }
456                    _ => {
457                        visible.insert(id);
458                    }
459                }
460            }
461        }
462    }
463    // Namespace leg — the live resolver's retain, against the frozen overlay's
464    // own `ns` column (the reader has no derived `node_ns` array: its effective
465    // state is assembled per snapshot, and reading the column it would mirror is
466    // the same answer by construction). Intersects the key leg too; see
467    // `RoleDef::namespaces`.
468    if def.namespaces.is_some() {
469        let cv = build_cv(&state.props, base);
470        visible.retain(|&id| {
471            let value = cv.get(id, core_storage::NS_PROP).map(|vr| vr.into_value());
472            def.sees_namespace(core_storage::namespace_of_value(value.as_ref()))
473        });
474    }
475    Ok(NodeMask::from_ids(visible))
476}
477
478impl ReaderSnapshot {
479    /// Apply all pending deltas to a clone of `frozen`.
480    ///
481    /// Returns the frozen state (cloned) with delta changes applied, including
482    /// rule-derived edge inserts/retracts and a rebuilt full-text index.
483    fn materialize(&self) -> Result<FrozenOverlay> {
484        // Cloning the struct now clones eight `Arc`s — refcount bumps, not data.
485        // Each `Arc::make_mut` below copies exactly one structure, once, and only
486        // because this snapshot is about to mutate its own working copy. The
487        // fold this came from keeps its allocation, which is what preserves the
488        // isolation `tests/reader_isolation.rs` pins.
489        //
490        // Before the fields were `Arc`, this deep-copied all eight every time a
491        // snapshot with a non-empty delta tail was read. Now it copies only what
492        // the deltas actually touch.
493        let mut w = (*self.frozen).clone();
494        for delta in &self.deltas {
495            for rec in &delta.records {
496                apply_one(
497                    Arc::make_mut(&mut w.ids),
498                    Arc::make_mut(&mut w.syms),
499                    Arc::make_mut(&mut w.topo),
500                    Arc::make_mut(&mut w.props),
501                    Arc::make_mut(&mut w.edge_props),
502                    Arc::make_mut(&mut w.labels),
503                    Arc::make_mut(&mut w.fulltext),
504                    &self.base,
505                    rec,
506                )?;
507            }
508            for &(etype, src, dst) in &delta.derived_inserts {
509                Arc::make_mut(&mut w.topo).add_edge(etype, src, dst);
510            }
511            for &(etype, src, dst) in &delta.derived_deletes {
512                Arc::make_mut(&mut w.topo).remove_edge(etype, src, dst);
513            }
514        }
515        if !self.deltas.is_empty() {
516            // Rebuild full-text to correct incremental drift accumulated during
517            // delta application (add_tokens is imprecise for multi-field/deletion paths).
518            let cv = build_cv(&w.props, &self.base);
519            let (ids, labels, syms) = (w.ids.clone(), w.labels.clone(), w.syms.clone());
520            Arc::make_mut(&mut w.fulltext).rebuild_all(&ids, &labels, &syms, cv);
521        }
522        Ok(w)
523    }
524
525    /// Construct a `ReaderSnapshot` from its constituent parts.
526    ///
527    /// Used by [`crate::db::GraphDb::reader`] — the only site that builds a
528    /// snapshot — so the private `cache` field stays encapsulated here.
529    pub(crate) fn new(
530        frozen: Arc<FrozenOverlay>,
531        base: Option<Arc<MappedBase>>,
532        deltas: Vec<Arc<CommitDelta>>,
533        version: u64,
534        role_masks: Arc<RoleMaskCache>,
535    ) -> Self {
536        Self {
537            frozen,
538            base,
539            deltas,
540            version,
541            role_masks,
542            cache: OnceLock::new(),
543        }
544    }
545
546    // ── Private helpers ───────────────────────────────────────────────────────
547
548    /// Return a reference to the current effective state.
549    ///
550    /// When the delta tail is empty this is a zero-copy borrow of `frozen`.
551    /// Otherwise the delta tail is applied to a clone of `frozen` exactly once
552    /// (cached in `self.cache`) so that all operations within a single
553    /// `ReaderSnapshot` share the same materialized view (F3: no triple
554    /// materialize per request).
555    fn effective(&self) -> Result<&FrozenOverlay> {
556        if self.deltas.is_empty() {
557            return Ok(&self.frozen);
558        }
559        let cached = self
560            .cache
561            .get_or_init(|| self.materialize().map_err(|e| e.to_string()));
562        cached
563            .as_ref()
564            .map_err(|e| GraphError::Corrupt { detail: e.clone() })
565    }
566
567    // ── Public API ────────────────────────────────────────────────────────────
568
569    /// Resolve a role name to a node visibility mask.
570    ///
571    /// Coherent with [`Self::query_masked`]: both read from the same effective
572    /// state (frozen or cached materialization), so the mask is never stale
573    /// relative to the query data.
574    ///
575    /// Memoised per `(role, version)` in the cache shared with the originating
576    /// `GraphDb`, so a scoped reader taking snapshot after snapshot between two
577    /// writes resolves the role once.
578    pub fn mask_for_role(&self, role: &str) -> Result<NodeMask> {
579        self.role_masks
580            .get_or_build(role, self.version, || {
581                mask_for_role_from(self.effective()?, &self.base, role)
582            })
583            .map(|m| (*m).clone())
584    }
585
586    /// Every live node in `namespace`, as a visibility mask.
587    ///
588    /// The snapshot-reader twin of [`GraphDb::mask_for_namespace`](crate::GraphDb::mask_for_namespace):
589    /// read off the effective state's own `ns` column rather than a derived
590    /// array, exactly as the namespace leg of [`Self::mask_for_role`] is. A name
591    /// no node uses gives an empty mask — a namespace scope never widens.
592    pub fn mask_for_namespace(&self, namespace: &str) -> Result<NodeMask> {
593        let state = self.effective()?;
594        let cv = build_cv(&state.props, &self.base);
595        let mut visible = HashSet::new();
596        for (i, &sym) in state.labels.iter().enumerate() {
597            if sym == u32::MAX {
598                continue; // tombstoned: the label sentinel is what marks it gone
599            }
600            let id = i as u32;
601            let value = cv.get(id, core_storage::NS_PROP).map(|vr| vr.into_value());
602            if core_storage::namespace_of_value(value.as_ref()) == namespace {
603                visible.insert(id);
604            }
605        }
606        Ok(NodeMask::from_ids(visible))
607    }
608
609    /// Resolve a node key to its dense id.
610    ///
611    /// Checks the delta tail (via the cached materialization) so that nodes
612    /// inserted since the last fold are visible.
613    pub fn resolve_key(&self, key: &str) -> Option<u32> {
614        self.effective().ok()?.ids.get(key)
615    }
616
617    /// Execute a read-only Cypher query over the epoch snapshot.
618    pub fn query(&self, cypher: &str, params: &BTreeMap<String, Value>) -> Result<ResultSet> {
619        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
620            detail: format!("lex: {e}"),
621        })?;
622        let ast = parse(&tokens).map_err(|e| GraphError::QueryError {
623            detail: format!("parse: {e}"),
624        })?;
625        let ops = plan(&ast).map_err(|e| GraphError::QueryError {
626            detail: format!("plan: {e}"),
627        })?;
628        let state = self.effective()?;
629        let view = make_view(state, &self.base, None);
630        execute(&view, &ops, &Params(params)).map_err(|e| GraphError::QueryError {
631            detail: format!("execute: {e}"),
632        })
633    }
634
635    /// Execute a read-only Cypher query with a node visibility mask.
636    ///
637    /// Returns `Err` when `cypher` is a write statement (CREATE / MATCH…SET / DELETE).
638    pub fn query_masked(
639        &self,
640        cypher: &str,
641        params: &BTreeMap<String, Value>,
642        mask: &NodeMask,
643    ) -> Result<ResultSet> {
644        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
645            detail: format!("lex: {e}"),
646        })?;
647        if is_write_tokens(&tokens) {
648            return Err(GraphError::QueryError {
649                detail: "masked queries are read-only".into(),
650            });
651        }
652        let ast = parse(&tokens).map_err(|e| GraphError::QueryError {
653            detail: format!("parse: {e}"),
654        })?;
655        let ops = plan(&ast).map_err(|e| GraphError::QueryError {
656            detail: format!("plan: {e}"),
657        })?;
658        let state = self.effective()?;
659        let view = make_view(state, &self.base, Some(&mask.visible));
660        execute(&view, &ops, &Params(params)).map_err(|e| GraphError::QueryError {
661            detail: format!("execute: {e}"),
662        })
663    }
664
665    /// Live node info from the epoch snapshot. `None` if key is absent or tombstoned.
666    pub fn node_info(&self, key: &str) -> Option<NodeInfo> {
667        node_info_from(key, self.effective().ok()?, &self.base)
668    }
669
670    /// Every directed edge incident on `key`. `derived` is always `false` since
671    /// the reader snapshot has no rule engine.
672    ///
673    /// Unknown key → `Err(GraphError::KeyNotFound)`.
674    pub fn node_edges(&self, key: &str) -> Result<Vec<EdgeInfo>> {
675        node_edges_from(key, self.effective()?, &self.base)
676    }
677
678    /// BFS neighborhood expansion restricted to `mask`-visible nodes.
679    ///
680    /// Hidden nodes are neither returned nor used as traversal intermediaries
681    /// (never-leak invariant). Returns `None` when `key` does not exist.
682    pub fn neighborhood_masked(
683        &self,
684        key: &str,
685        depth: u32,
686        edge_types: Option<&[&str]>,
687        dir: Dir,
688        mask: &NodeMask,
689    ) -> Option<ResultSet> {
690        neighborhood_masked_from(
691            key,
692            self.effective().ok()?,
693            &self.base,
694            depth,
695            edge_types,
696            dir,
697            mask,
698        )
699    }
700
701    /// Every edge incident on `key` that the scope may see.
702    ///
703    /// The snapshot twin of [`GraphDb::node_edges_scoped`](crate::GraphDb::node_edges_scoped),
704    /// and the contract HTTP's role-token branch used to write by hand: the
705    /// subject is checked first, so a hidden key answers exactly as an absent
706    /// one does, and then every edge whose *other* endpoint is hidden is
707    /// dropped — a visible node must not become a window onto its hidden
708    /// neighbours.
709    ///
710    /// `derived` is always `false`, as it is on [`Self::node_edges`]: a snapshot
711    /// carries no rule engine.
712    ///
713    /// Hidden or unknown `key` → [`GraphError::KeyNotFound`].
714    pub fn node_edges_scoped(&self, key: &str, mask: &NodeMask) -> Result<Vec<EdgeInfo>> {
715        let state = self.effective()?;
716        if !state.ids.get(key).is_some_and(|id| mask.contains_id(id)) {
717            return Err(GraphError::KeyNotFound { key: key.into() });
718        }
719        let edges = node_edges_from(key, state, &self.base)?;
720        Ok(edges
721            .into_iter()
722            .filter(|e| {
723                let other = if e.src_key == key {
724                    &e.dst_key
725                } else {
726                    &e.src_key
727                };
728                state.ids.get(other).is_some_and(|id| mask.contains_id(id))
729            })
730            .collect())
731    }
732
733    /// BFS expansion from `key`, with the subject check
734    /// [`Self::neighborhood_masked`] deliberately omits.
735    ///
736    /// The snapshot twin of [`GraphDb::neighborhood_scoped`](crate::GraphDb::neighborhood_scoped).
737    /// Expansion is unchanged — hidden nodes are neither returned nor traversed
738    /// through — so a visible node reachable only through a hidden one stays
739    /// out.
740    ///
741    /// Hidden or unknown `key` → [`GraphError::KeyNotFound`].
742    pub fn neighborhood_scoped(
743        &self,
744        key: &str,
745        depth: u32,
746        edge_types: Option<&[&str]>,
747        dir: Dir,
748        mask: &NodeMask,
749    ) -> Result<ResultSet> {
750        let state = self.effective()?;
751        if !state.ids.get(key).is_some_and(|id| mask.contains_id(id)) {
752            return Err(GraphError::KeyNotFound { key: key.into() });
753        }
754        neighborhood_masked_from(key, state, &self.base, depth, edge_types, dir, mask)
755            .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })
756    }
757}
758
759// ── Free-standing helpers that take state by reference ────────────────────────
760
761fn node_info_from(
762    key: &str,
763    state: &FrozenOverlay,
764    base: &Option<Arc<MappedBase>>,
765) -> Option<NodeInfo> {
766    let id = state.ids.get(key)?;
767    let label_sym = *state.labels.get(id as usize)?;
768    if label_sym == u32::MAX {
769        return None;
770    }
771    let label = state.syms.resolve(label_sym)?.to_string();
772    let cv = build_cv(&state.props, base);
773    let mut props = BTreeMap::new();
774    for field in cv.field_names() {
775        if let Some(vr) = cv.get(id, &field) {
776            props.insert(field, vr.into_value());
777        }
778    }
779    Some(NodeInfo {
780        key: key.to_string(),
781        label,
782        props,
783    })
784}
785
786fn node_edges_from(
787    key: &str,
788    state: &FrozenOverlay,
789    base: &Option<Arc<MappedBase>>,
790) -> Result<Vec<EdgeInfo>> {
791    let id = state
792        .ids
793        .get(key)
794        .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
795    let tv = build_tv(&state.topo, base);
796    let mut edges = Vec::new();
797    for etype in tv.etypes() {
798        let edge_type = state
799            .syms
800            .resolve(etype)
801            .ok_or_else(|| GraphError::Corrupt {
802                detail: format!("reader: topology etype {etype} not in interner"),
803            })?
804            .to_string();
805        for dir in [Direction::Out, Direction::In] {
806            for &nbr in tv.neighbors(etype, dir, id).as_ref() {
807                let (src_key, dst_key) = match dir {
808                    Direction::Out => (
809                        key.to_string(),
810                        state
811                            .ids
812                            .key_of(nbr)
813                            .ok_or_else(|| GraphError::Corrupt {
814                                detail: format!("topology id {nbr} has no key"),
815                            })?
816                            .to_string(),
817                    ),
818                    Direction::In => (
819                        state
820                            .ids
821                            .key_of(nbr)
822                            .ok_or_else(|| GraphError::Corrupt {
823                                detail: format!("topology id {nbr} has no key"),
824                            })?
825                            .to_string(),
826                        key.to_string(),
827                    ),
828                };
829                edges.push(EdgeInfo {
830                    edge_type: edge_type.clone(),
831                    src_key,
832                    dst_key,
833                    derived: false,
834                });
835            }
836        }
837    }
838    edges.sort_by(|a, b| {
839        a.edge_type
840            .cmp(&b.edge_type)
841            .then(a.src_key.cmp(&b.src_key))
842            .then(a.dst_key.cmp(&b.dst_key))
843    });
844    edges.dedup();
845    Ok(edges)
846}
847
848fn neighborhood_masked_from(
849    key: &str,
850    state: &FrozenOverlay,
851    base: &Option<Arc<MappedBase>>,
852    depth: u32,
853    edge_types: Option<&[&str]>,
854    dir: Dir,
855    mask: &NodeMask,
856) -> Option<ResultSet> {
857    let start_id = state.ids.get(key)?;
858    let view = make_view(state, base, Some(&mask.visible));
859    let resolved: Option<Vec<u32>> = edge_types.map(|names| {
860        names
861            .iter()
862            .filter_map(|name| view.syms.get(name))
863            .collect()
864    });
865    let nb = neighborhood(&view, start_id, depth, resolved.as_deref(), dir);
866    let mut rs = ResultSet::new(vec!["key".into(), "label".into(), "depth".into()]);
867    // Collect visible BFS results (start_id at depth 0, BFS nodes after).
868    let mut visited: Vec<(u32, u32)> = Vec::with_capacity(nb.nodes.len() + 1);
869    visited.push((start_id, 0));
870    for (nid, d) in &nb.nodes {
871        let k = view.key_of(*nid);
872        let lbl = view
873            .label_of(*nid)
874            .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
875        rs.push_row(vec![
876            Some(Value::Str(k.to_string())),
877            Some(Value::Str(lbl.to_string())),
878            Some(Value::Int(*d as i64)),
879        ]);
880        visited.push((*nid, *d));
881    }
882    // Stub mode: add hidden direct neighbours of each visited node as stubs.
883    // Hidden nodes are edge-endpoints only — they are not added to the BFS
884    // frontier, so the BFS never expands through them in either mode.
885    //
886    // Role-token callers always pass an Omit-mode mask (mask_for_role uses
887    // NodeMask::from_ids which defaults to Omit; intersect() hard-returns Omit),
888    // so this branch is unreachable on the role path — security is unaffected.
889    if mask.mode() == crate::mask::MaskMode::Stub {
890        let raw_view = make_view(state, base, None);
891        let mut seen: HashSet<u32> = visited.iter().map(|(id, _)| *id).collect();
892        for (node_id, node_depth) in &visited {
893            if *node_depth >= depth {
894                continue;
895            }
896            for e in expand(&raw_view, *node_id, resolved.as_deref(), dir) {
897                let nbr = if e.src == *node_id { e.dst } else { e.src };
898                if !mask.contains_id(nbr) && seen.insert(nbr) {
899                    if let Some(k) = state.ids.key_of(nbr) {
900                        rs.push_row(vec![
901                            Some(Value::Str(k.to_string())),
902                            None,
903                            Some(Value::Int((*node_depth + 1) as i64)),
904                        ]);
905                    }
906                }
907            }
908        }
909    }
910    Some(rs)
911}