Skip to main content

core_api/
db.rs

1use crate::ingest::{IngestOptions, IngestReport};
2use crate::roles::{RoleDef, RolesFile, WriteScope};
3use crate::subscription::{
4    event_matches, DbEvent, SubEntry, SubFilter, SubInner, Subscription, DEFAULT_SUB_CAPACITY,
5};
6use core_query::cypher::ast::{ret_val_label, ArithOp};
7use core_query::cypher::{
8    execute, execute_union, is_subscribable, is_write_tokens, lex, parse, parse_read, parse_write,
9    plan, MatchDeleteNodeStmt, NodePat, Operand, Params, Pattern, PlanOp, Query, RetItem, RetVal,
10    WriteStatement,
11};
12use core_query::{eval_filter, expand, neighborhood, Dir, Filter, GraphView, ResultSet};
13use core_rules::{
14    decode_rule_def, evaluate, EngineEdgeDelta, GraphMut, NodeView, Predicate, RuleDef, RuleEngine,
15    ViewDef, ViewStore,
16};
17use core_storage::fs::{FileId, Fs, FsIntrospect, RealFs};
18use core_storage::fulltext::FulltextIndex;
19use core_storage::property_index::PropertyIndex;
20use core_storage::v8::encode::{
21    archived_hnsw_to_owned, archived_rules_meta_to_owned, archived_to_idmap, archived_to_interner,
22    archived_views_to_owned, decode_last_change_bytes, decode_meta, encode_v8, V8Meta,
23};
24use core_storage::v8::seam::TopologyView;
25use core_storage::wal::{decode_all, encode_record, WalRecord};
26use core_storage::EdgePropsView;
27use core_storage::{
28    ColumnStore, Direction, EdgeProps, GraphError, IdMap, Interner, Result, Topology, Value,
29};
30use serde::{Deserialize, Serialize};
31use std::collections::{BTreeMap, BTreeSet, HashMap};
32use std::sync::Arc;
33
34/// Print a timing checkpoint when MUSHROOMDB_TRACE_OPEN is set.
35/// Zero-cost when the env var is absent (the var check is O(1) after first call).
36macro_rules! trace_open {
37    ($phase:literal, $t:expr) => {
38        if std::env::var("MUSHROOMDB_TRACE_OPEN").is_ok() {
39            eprintln!(
40                "[MUSHROOMDB_TRACE_OPEN] {:40} {:>9.3?}",
41                $phase,
42                $t.elapsed()
43            );
44        }
45    };
46}
47
48/// Print a migration phase checkpoint when MUSHROOMDB_TRACE_MIGRATE is set.
49/// Zero-cost when the env var is absent (the var check is O(1) after first call).
50macro_rules! trace_migrate {
51    ($phase:literal, $t:expr) => {
52        if std::env::var("MUSHROOMDB_TRACE_MIGRATE").is_ok() {
53            eprintln!(
54                "[MUSHROOMDB_TRACE_MIGRATE] {:40} {:>9.3?}",
55                $phase,
56                $t.elapsed()
57            );
58        }
59    };
60}
61
62// Test-only: counts how many times `pending_deltas_since().to_vec()` actually
63// executes (i.e., at least one view is defined). Used to verify the fast-path
64// guard skips the allocation when `view_store.is_empty()`.
65#[cfg(test)]
66thread_local! {
67    static DELTA_COPY_COUNT: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
68}
69
70// Per-thread count of query-subscription `execute` calls in `distribute_events`.
71//
72// Incremented each time a query subscription actually runs its plan (i.e.,
73// the label-skip fast-path did not fire). Because `distribute_events` is
74// called synchronously on the writer thread, this thread-local correctly
75// isolates each test thread's count even when integration tests run in
76// parallel. Read via [`query_sub_exec_count`].
77thread_local! {
78    static QUERY_SUB_EXECS_TL: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
79}
80
81/// Return the number of query-subscription re-executions logged on this
82/// thread since the process started (or since last reset via
83/// [`reset_query_sub_exec_count`]).
84///
85/// Primarily for integration tests that verify the label-skip fast-path.
86#[doc(hidden)]
87pub fn query_sub_exec_count() -> usize {
88    QUERY_SUB_EXECS_TL.with(|c| c.get())
89}
90
91/// Reset the per-thread query-subscription execution counter to zero.
92#[doc(hidden)]
93pub fn reset_query_sub_exec_count() {
94    QUERY_SUB_EXECS_TL.with(|c| c.set(0));
95}
96
97/// Internal state for a single `subscribe_query` subscription.
98///
99/// On every commit, `distribute_events` re-executes `ops` against the current
100/// graph state, diffs the result against `prev_rows`, and pushes
101/// `DbEvent::QueryRowAdded` / `QueryRowRemoved` events to `inner`.
102///
103/// **Full re-run per commit; use LIMIT to bound execution cost.**
104/// (Differential evaluation is roadmap / Phase 5.)
105pub(crate) struct QuerySubEntry {
106    /// Compiled plan for the subscribed Cypher query.
107    ops: Vec<PlanOp>,
108    /// Column names from the first execution (fixed for the subscription lifetime).
109    columns: Vec<String>,
110    /// Serialized (JSON) row key → row data, representing the result set at
111    /// the end of the last commit. Used to diff against the new result.
112    prev_row_map: std::collections::HashMap<String, Vec<Option<Value>>>,
113    /// Weak pointer to the subscriber queue; dead Weak → subscription dropped.
114    inner: std::sync::Weak<SubInner>,
115    /// Interned label sym captured at subscribe time from the plan's leading scan
116    /// (`ScanLabel`, `IndexScan`, or `IndexIntersect` with a concrete label).
117    ///
118    /// `None` means the plan has an `Expand` op (or no recognizable leading scan
119    /// with a concrete label), and this subscription must re-execute on every
120    /// commit without skipping. This is the conservative v0.4.3 boundary: Expand
121    /// queries are never skipped because edges can alter join results regardless
122    /// of which node labels were written.
123    scan_label: Option<u32>,
124}
125
126/// A post-commit mutation notification.
127///
128/// Emitted from `log_then_apply` after the WAL append, fsync, and
129/// in-memory `apply` all succeed. Never emitted for rejected operations
130/// (validation errors, [`GraphError::RuleOwned`], duplicate keys, no-op
131/// deletes/removes). Event payloads carry user keys and rule names, never
132/// internal ids.
133///
134/// **Replay:** [`GraphDb::open`] / [`GraphDb::open_with`] replay the WAL via
135/// `apply` only. Emission lives exclusively in `log_then_apply`, so
136/// recovery is silent even if a sink were installed (it cannot be: the
137/// sink is in-memory and set after open).
138///
139/// **Ordering:** a `Batch` WAL frame emits one event per inner record, then
140/// [`MutationEvent::BatchApplied`]. An ingest commit emits those same inner
141/// events, then [`MutationEvent::Ingested`] (not `BatchApplied`). An empty
142/// or all-noop batch writes no WAL and emits nothing (including no summary).
143///
144/// **Derived edges:** rule-created or retracted edges are not individually
145/// evented — they are recoverable from the triggering mutation plus the live
146/// rule set. Only the triggering record is emitted.
147///
148/// **Wire form:** externally tagged snake_case JSON
149/// (`{"node_inserted":{"label":"A","key":"k"}}`).
150#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
151#[serde(rename_all = "snake_case")]
152pub enum MutationEvent {
153    NodeInserted {
154        label: String,
155        key: String,
156    },
157    PropSet {
158        key: String,
159        field: String,
160    },
161    PropRemoved {
162        key: String,
163        field: String,
164    },
165    EdgeInserted {
166        edge_type: String,
167        src: String,
168        dst: String,
169    },
170    EdgeDeleted {
171        edge_type: String,
172        src: String,
173        dst: String,
174    },
175    NodeDeleted {
176        key: String,
177    },
178    RuleCreated {
179        name: String,
180    },
181    RuleDeleted {
182        name: String,
183    },
184    RuleRebuilt {
185        name: String,
186    },
187    BatchApplied {
188        ops: usize,
189    },
190    Ingested {
191        label: String,
192        inserted: usize,
193    },
194}
195
196fn event_from_record(rec: &WalRecord, intern: &Interner, ids: &IdMap) -> Option<MutationEvent> {
197    match rec {
198        WalRecord::InsertNode { label, key, .. } => Some(MutationEvent::NodeInserted {
199            label: label.clone(),
200            key: key.clone(),
201        }),
202        WalRecord::InsertNodeId { label, key, .. } => Some(MutationEvent::NodeInserted {
203            label: intern.resolve(*label)?.to_string(),
204            key: key.clone(),
205        }),
206        WalRecord::SetProp { key, field, .. } => Some(MutationEvent::PropSet {
207            key: key.clone(),
208            field: field.clone(),
209        }),
210        WalRecord::SetPropId { id, field, .. } => Some(MutationEvent::PropSet {
211            key: ids.key_of(*id)?.to_string(),
212            field: intern.resolve(*field)?.to_string(),
213        }),
214        WalRecord::RemoveProp { key, field } => Some(MutationEvent::PropRemoved {
215            key: key.clone(),
216            field: field.clone(),
217        }),
218        WalRecord::InsertEdge {
219            edge_type,
220            src_key,
221            dst_key,
222        } => Some(MutationEvent::EdgeInserted {
223            edge_type: edge_type.clone(),
224            src: src_key.clone(),
225            dst: dst_key.clone(),
226        }),
227        WalRecord::InsertEdgeId { etype, src, dst } => Some(MutationEvent::EdgeInserted {
228            edge_type: intern.resolve(*etype)?.to_string(),
229            src: ids.key_of(*src)?.to_string(),
230            dst: ids.key_of(*dst)?.to_string(),
231        }),
232        WalRecord::DeleteEdge {
233            edge_type,
234            src_key,
235            dst_key,
236        } => Some(MutationEvent::EdgeDeleted {
237            edge_type: edge_type.clone(),
238            src: src_key.clone(),
239            dst: dst_key.clone(),
240        }),
241        WalRecord::DeleteNode { key } => Some(MutationEvent::NodeDeleted { key: key.clone() }),
242        WalRecord::CreateRule { def_bytes } => {
243            let def: RuleDef = decode_rule_def(def_bytes).ok()?;
244            Some(MutationEvent::RuleCreated { name: def.name })
245        }
246        WalRecord::DeleteRule { name } => Some(MutationEvent::RuleDeleted { name: name.clone() }),
247        WalRecord::RebuildRule { name } => Some(MutationEvent::RuleRebuilt { name: name.clone() }),
248        WalRecord::Batch(_)
249        | WalRecord::CreateView { .. }
250        | WalRecord::DeleteView { .. }
251        | WalRecord::EnableFulltext { .. }
252        | WalRecord::DisableFulltext { .. }
253        | WalRecord::EnableIndex { .. }
254        | WalRecord::DisableIndex { .. }
255        | WalRecord::Intern { .. }
256        // History markers are no-ops for mutation events — they carry no new
257        // state and rules re-derive deterministically on replay.
258        | WalRecord::DerivedEdgeAdded { .. }
259        | WalRecord::DerivedEdgeRetracted { .. }
260        // RenameNode carries no node/edge count change; no special event.
261        | WalRecord::RenameNode { .. } => None,
262    }
263}
264
265/// Database-wide counters plus per-rule budget/fire stats.
266#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
267pub struct Stats {
268    pub nodes_live: usize,
269    pub nodes_tombstoned: usize,
270    pub edges: u64,
271    pub rules: Vec<RuleStats>,
272    /// How many writes hit the rule-chaining depth cap with work still pending,
273    /// since this handle was opened. Non-zero means some derived edges beyond
274    /// the cap are stale and no single later write will repair them: split the
275    /// rule chain or shorten it. Never persisted, so it resets on reopen.
276    #[serde(default)]
277    pub chain_truncations: u64,
278}
279
280/// One rule's provenance size, trip latch, and fire counter.
281///
282/// `tripped` is a one-way latch: once set, the engine adds no new edges for
283/// that rule until [`GraphDb::rebuild_rule`] (and only if the full desired
284/// set then fits). `fires` counts `on_node_changed` evaluations plus
285/// backfill/rebuild participant ticks (rebuild counts even when it is a
286/// provenance no-op).
287#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
288pub struct RuleStats {
289    pub name: String,
290    pub edges: u64,
291    pub tripped: bool,
292    pub fires: u64,
293    /// Whether this rule uses the approximate IVF-Flat candidate path.
294    pub approximate: bool,
295}
296
297/// One entry in the slow-query ring buffer.
298#[derive(Debug, Clone, Serialize)]
299pub struct SlowQueryEntry {
300    /// Execution time in whole milliseconds.
301    pub ms: u64,
302    /// The Cypher query string that was slow.
303    pub query: String,
304    /// The commit sequence number at the time the query ran.
305    pub at_commit: u64,
306}
307
308/// Snapshot of the slow-query log returned by [`GraphDb::slow_query_snapshot`].
309#[derive(Debug, Clone, Serialize)]
310pub struct SlowQuerySnapshot {
311    /// Current threshold in milliseconds (0 = disabled).
312    pub threshold_ms: u64,
313    /// Total number of slow queries ever recorded (not capped by ring size).
314    pub count: u64,
315    /// Most-recent slow queries (up to 16), oldest first.
316    pub last: Vec<SlowQueryEntry>,
317}
318
319/// Internal ring-buffer state protected by a `Mutex` so `query(&self)` can
320/// write to it without a mutable borrow.
321struct SlowQueryLog {
322    entries: std::collections::VecDeque<SlowQueryEntry>,
323    total: u64,
324}
325
326/// Maximum number of entries kept in the slow-query ring buffer.
327const SLOW_QUERY_RING_CAP: usize = 16;
328
329/// Wire summary of a [`Predicate`]. JSON only — `Explanation` is never
330/// bincode-persisted (WAL/snapshots store `RuleDef` bytes, not this type).
331#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
332pub struct PredicateSummary {
333    pub kind: String,
334    pub fields: Vec<String>,
335    pub min: Option<f64>,
336    pub tolerance: Option<f64>,
337    pub km: Option<f64>,
338    pub parts: Option<Vec<PredicateSummary>>,
339    /// True when the owning rule has `approximate=true` (IVF-Flat candidate path).
340    /// Always false for predicates reported without rule context (sub-predicates in `parts`).
341    #[serde(default)]
342    pub approximate: bool,
343}
344
345impl From<&Predicate> for PredicateSummary {
346    fn from(p: &Predicate) -> Self {
347        match p {
348            Predicate::KeyMatch { field } => PredicateSummary {
349                kind: "key_match".into(),
350                fields: vec![field.clone()],
351                min: None,
352                tolerance: None,
353                km: None,
354                parts: None,
355                approximate: false,
356            },
357            Predicate::FieldEqual { field } => PredicateSummary {
358                kind: "field_equal".into(),
359                fields: vec![field.clone()],
360                min: None,
361                tolerance: None,
362                km: None,
363                parts: None,
364                approximate: false,
365            },
366            Predicate::Overlap { field, min } => PredicateSummary {
367                kind: "overlap".into(),
368                fields: vec![field.clone()],
369                min: Some(*min),
370                tolerance: None,
371                km: None,
372                parts: None,
373                approximate: false,
374            },
375            Predicate::NumericWithin { field, tolerance } => PredicateSummary {
376                kind: "numeric_within".into(),
377                fields: vec![field.clone()],
378                min: None,
379                tolerance: Some(*tolerance),
380                km: None,
381                parts: None,
382                approximate: false,
383            },
384            Predicate::GeoRadius { field, km } => PredicateSummary {
385                kind: "geo_radius".into(),
386                fields: vec![field.clone()],
387                min: None,
388                tolerance: None,
389                km: Some(*km),
390                parts: None,
391                approximate: false,
392            },
393            Predicate::VectorSimilar { field, min } => PredicateSummary {
394                kind: "vector_similar".into(),
395                fields: vec![field.clone()],
396                min: Some(*min),
397                tolerance: None,
398                km: None,
399                parts: None,
400                approximate: false,
401            },
402            Predicate::All(inner) => {
403                let parts: Vec<PredicateSummary> = inner.iter().map(Self::from).collect();
404                let mut fields = Vec::new();
405                for part in &parts {
406                    for f in &part.fields {
407                        if !fields.contains(f) {
408                            fields.push(f.clone());
409                        }
410                    }
411                }
412                PredicateSummary {
413                    kind: "all".into(),
414                    fields,
415                    min: None,
416                    tolerance: None,
417                    km: None,
418                    parts: Some(parts),
419                    approximate: false,
420                }
421            }
422            Predicate::Any(inner) => {
423                let parts: Vec<PredicateSummary> = inner.iter().map(Self::from).collect();
424                let mut fields = Vec::new();
425                for part in &parts {
426                    for f in &part.fields {
427                        if !fields.contains(f) {
428                            fields.push(f.clone());
429                        }
430                    }
431                }
432                PredicateSummary {
433                    kind: "any".into(),
434                    fields,
435                    min: None,
436                    tolerance: None,
437                    km: None,
438                    parts: Some(parts),
439                    approximate: false,
440                }
441            }
442        }
443    }
444}
445
446/// Snapshot of a live node's key, label, and columnar properties.
447///
448/// `props` is a [`BTreeMap`] so field order is deterministic (sorted by name)
449/// regardless of insert order or the columnar store's `HashMap` iteration.
450///
451/// Deliberately does not derive `Serialize`: `Value`'s serde form is
452/// internally tagged. Wire JSON is built by `value_to_json` in the server.
453#[derive(Debug, Clone, PartialEq)]
454pub struct NodeInfo {
455    pub key: String,
456    pub label: String,
457    pub props: BTreeMap<String, Value>,
458}
459
460/// Counts returned by [`GraphDb::delete_node`].
461#[derive(Debug, Clone, PartialEq, Eq, Default)]
462pub struct DeleteReport {
463    /// Number of manual (user-inserted) edges removed.
464    pub manual_edges: u64,
465    /// Number of derived (rule-owned) edges retracted.
466    pub derived_edges: u64,
467}
468
469/// One directed edge incident on a node, with provenance membership.
470///
471/// `derived` is true iff `(edge_type, src, dst)` is in the rule engine's
472/// Plan-8 `by_node` provenance index.
473#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
474pub struct EdgeInfo {
475    pub edge_type: String,
476    pub src_key: String,
477    pub dst_key: String,
478    pub derived: bool,
479}
480
481/// One directed edge incident on a node at a point in WAL history, with the
482/// rule that derived it when it is rule-owned.
483///
484/// Returned by [`GraphDb::edges_at`] (sorted by `(edge_type, src_key, dst_key)`)
485/// and by [`GraphDb::what_if_set_prop`].
486#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
487pub struct EdgeAt {
488    pub edge_type: String,
489    pub src_key: String,
490    pub dst_key: String,
491    /// `true` when a rule wrote the edge (`DerivedEdgeAdded` in the WAL, or a
492    /// live provenance entry).
493    pub derived: bool,
494    /// The rule that derived the edge. `None` for a manual edge.
495    pub rule: Option<String>,
496}
497
498/// The derived edges a hypothetical property change would retract and derive.
499///
500/// Returned by [`GraphDb::what_if_set_prop`]. Both lists are sorted by
501/// `(edge_type, src_key, dst_key)` and every entry is rule-derived.
502#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
503pub struct WhatIf {
504    /// Derived edges that exist now and would be retracted.
505    pub lost: Vec<EdgeAt>,
506    /// Derived edges that do not exist now and would be derived.
507    pub gained: Vec<EdgeAt>,
508}
509
510/// An edge with mask-aware endpoint visibility.
511///
512/// Returned by [`GraphDb::node_edges_masked`] in [`crate::mask::MaskMode::Stub`]
513/// mode — hidden endpoints carry `*_restricted: true`.
514#[derive(Debug, Clone, PartialEq, Eq)]
515pub struct MaskedEdge {
516    pub edge_type: String,
517    pub src_key: String,
518    /// `true` when `src_key` is in the DB but hidden from the mask.
519    pub src_restricted: bool,
520    pub dst_key: String,
521    /// `true` when `dst_key` is in the DB but hidden from the mask.
522    pub dst_restricted: bool,
523    pub derived: bool,
524}
525
526/// Result of a mask-aware node lookup via [`GraphDb::node_info_masked`].
527///
528/// `None` from that method means the key does not exist (→ 404).
529/// `Some(Restricted)` is only produced when `mask.mode() == MaskMode::Stub`.
530#[derive(Debug, PartialEq)]
531pub enum MaskedNodeResult {
532    Visible(NodeInfo),
533    /// Node exists in the DB but is hidden from this mask.
534    Restricted,
535}
536
537/// One rule-owned edge between two nodes, with the rule name, edge type,
538/// direction (src_key → dst_key), and weight if the rule stores one.
539#[derive(Debug, Clone, PartialEq, Serialize)]
540pub struct Explanation {
541    pub rule: String,
542    pub edge_type: String,
543    pub src_key: String,
544    pub dst_key: String,
545    pub weight: Option<f64>,
546    pub predicate: PredicateSummary,
547    /// For a via-hop rule, the edge type the rule hops over to reach its
548    /// candidates. `None` for a plain two-node rule. A via-hop rule whose
549    /// `via_edge` is itself rule-derived is the chaining case: the hop edge
550    /// was written by another rule in the same commit.
551    #[serde(default)]
552    pub via_edge: Option<String>,
553}
554
555/// Report returned by [`GraphDb::backup_to`].
556#[derive(Debug, Clone)]
557pub struct BackupReport {
558    /// Filenames copied into the destination directory (sorted ascending).
559    pub files: Vec<String>,
560    /// Total bytes written across all copied files.
561    pub bytes: u64,
562    /// `true` when the destination opened cleanly and passed post-copy checks.
563    ///
564    /// For stores that have a `snapshot.bin` this means: all V8 section CRCs
565    /// matched **and** the destination opened without error.
566    ///
567    /// For WAL-only stores (no `snapshot.bin`) there is no snapshot to
568    /// CRC-check; `verified` is `true` when the destination opened and
569    /// replayed the WAL without error (record-level checksums in the WAL
570    /// provide the integrity signal, not section CRCs).
571    pub verified: bool,
572}
573
574/// One directed edge in export form, with optional rule attribution for derived edges.
575///
576/// Returned by [`GraphDb::all_edges_for_export`].
577///
578/// Does not derive `Eq`/`Ord`: `weight` is an `f64` and NaN breaks a total
579/// order. Callers that need a stable edge ordering already sort by
580/// `(edge_type, src, dst)` explicitly (see `all_edges_for_export`).
581#[derive(Debug, Clone, PartialEq, PartialOrd)]
582pub struct ExportEdge {
583    pub edge_type: String,
584    pub src: String,
585    pub dst: String,
586    pub derived: bool,
587    /// Rule name that created this edge, if derived. `None` for manual edges.
588    pub rule: Option<String>,
589    /// The creating rule's declared `weight_prop`, read off this edge, when
590    /// derived and numeric (`Int`/`Float`). `None` for manual edges, derived
591    /// edges whose rule declares no `weight_prop`, or a non-numeric value.
592    pub weight: Option<f64>,
593}
594
595/// One edge type's shape, as [`GraphDb::edge_type_census`] counts it.
596///
597/// Deliberately per *type* and not per edge: everything here is a summary a
598/// caller can print in one line, and none of it costs a record per edge.
599#[derive(Debug, Clone, PartialEq, Eq)]
600pub struct EdgeTypeCensus {
601    pub edge_type: String,
602    /// Directed edges of this type. Counted the way
603    /// [`GraphDb::edge_count`] counts: each edge once, from its source.
604    pub edges: u64,
605    /// Every label seen on a source of this type, sorted.
606    pub src_labels: Vec<String>,
607    /// Every label seen on a destination of this type, sorted.
608    pub dst_labels: Vec<String>,
609    /// The rules that declare this `edge_type`, sorted. Empty for a type
610    /// written by hand.
611    pub rules: Vec<String>,
612    /// `(src key, dst key)` of the first edge of this type in the store's own
613    /// id order — a real pair to quote in an example.
614    pub sample: Option<(String, String)>,
615}
616
617/// Construct the standard write-query result set (columns: created, properties_set, deleted).
618fn write_result_set() -> ResultSet {
619    ResultSet::new(vec![
620        "created".into(),
621        "properties_set".into(),
622        "deleted".into(),
623    ])
624}
625
626fn resolve_merge_set_value(op: &Operand, params: &BTreeMap<String, Value>) -> Result<Value> {
627    match op {
628        Operand::Lit(v) => Ok(v.clone()),
629        Operand::Param(name) => params
630            .get(name)
631            .cloned()
632            .ok_or_else(|| GraphError::QueryError {
633                detail: format!("missing parameter `{name}`"),
634            }),
635        _ => Err(GraphError::QueryError {
636            detail: "ON CREATE/ON MATCH SET value must be a literal or $parameter".into(),
637        }),
638    }
639}
640
641fn operand_node_vars(op: &Operand, out: &mut Vec<String>) {
642    match op {
643        Operand::Prop { var, .. } | Operand::Var(var) => {
644            if !out.contains(var) {
645                out.push(var.clone());
646            }
647        }
648        Operand::FuncCall { args, .. } => {
649            for arg in args {
650                operand_node_vars(arg, out);
651            }
652        }
653        Operand::BinArith { left, right, .. } => {
654            operand_node_vars(left, out);
655            operand_node_vars(right, out);
656        }
657        Operand::Case { branches, default } => {
658            // Branch conditions reference vars already bound (and mask-filtered)
659            // by the MATCH phase, so collecting from the value operands + ELSE
660            // is sufficient for RETURN-projection var discovery.
661            for (_, value) in branches {
662                operand_node_vars(value, out);
663            }
664            if let Some(d) = default {
665                operand_node_vars(d, out);
666            }
667        }
668        Operand::Index { base, index } => {
669            operand_node_vars(base, out);
670            operand_node_vars(index, out);
671        }
672        Operand::Lit(_) | Operand::Param(_) => {}
673    }
674}
675
676fn ret_node_vars(items: &[RetItem]) -> Vec<String> {
677    let mut out = Vec::new();
678    for item in items {
679        match &item.value {
680            RetVal::Var(v) | RetVal::Prop { var: v, .. } => {
681                if !out.contains(v) {
682                    out.push(v.clone());
683                }
684            }
685            RetVal::FuncCall { args, .. } => {
686                for arg in args {
687                    operand_node_vars(arg, &mut out);
688                }
689            }
690            RetVal::ScalarExpr(op) => operand_node_vars(op, &mut out),
691            RetVal::Agg { .. } => {}
692        }
693    }
694    out
695}
696
697fn add_var(out: &mut Vec<String>, v: &str) {
698    if !out.iter().any(|x| x == v) {
699        out.push(v.to_string());
700    }
701}
702
703fn pattern_node_vars(pats: &[Pattern]) -> Vec<String> {
704    let mut out = Vec::new();
705    for p in pats {
706        if let Some(v) = &p.start.var {
707            add_var(&mut out, v);
708        }
709        for (_, dest) in &p.chain {
710            if let Some(v) = &dest.var {
711                add_var(&mut out, v);
712            }
713        }
714    }
715    out
716}
717
718fn pattern_rel_vars(pats: &[Pattern]) -> Vec<String> {
719    let mut out = Vec::new();
720    for p in pats {
721        for (rel, _) in &p.chain {
722            if rel.hops.is_none() {
723                if let Some(v) = &rel.var {
724                    add_var(&mut out, v);
725                }
726            }
727        }
728    }
729    out
730}
731
732fn rel_type_alias(var: &str) -> String {
733    format!("__rt_{var}")
734}
735
736fn ret_column_name(item: &RetItem) -> String {
737    if let Some(alias) = &item.alias {
738        return alias.clone();
739    }
740    // The same naming rule the planner and the executor use, so a
741    // write-statement RETURN names its columns exactly as a read query does.
742    // An aggregate is not legal in a write-statement RETURN; it keeps the
743    // placeholder it always had.
744    ret_val_label(&item.value).unwrap_or_else(|| "<agg>".to_string())
745}
746
747fn eval_set_return_operand<F: Fs>(
748    db: &GraphDb<F>,
749    match_rs: &ResultSet,
750    row: usize,
751    rel_vars: &[String],
752    op: &Operand,
753    params: &BTreeMap<String, Value>,
754) -> Result<Option<Value>> {
755    match op {
756        Operand::Lit(v) => Ok(Some(v.clone())),
757        Operand::Param(name) => params.get(name).cloned().ok_or_else(|| GraphError::QueryError {
758            detail: format!("missing parameter `{name}`"),
759        }).map(Some),
760        Operand::Var(name) if rel_vars.iter().any(|r| r == name) => Err(GraphError::QueryError {
761            detail: format!(
762                "cannot return relationship variable '{name}' bare; return its properties ({name}.field) instead"
763            ),
764        }),
765        Operand::Var(name) => Ok(match_rs.get(row, name).cloned()),
766        Operand::Prop { var, field } => {
767            if rel_vars.iter().any(|r| r == var) {
768                return Ok(None);
769            }
770            let Some(Value::Str(key)) = match_rs.get(row, var) else {
771                return Ok(None);
772            };
773            Ok(db.get_prop(key, field))
774        }
775        Operand::FuncCall { name, args } => {
776            eval_set_return_func(db, match_rs, row, rel_vars, name, args, params)
777        }
778        Operand::BinArith { op, left, right } => {
779            let lv = eval_set_return_operand(db, match_rs, row, rel_vars, left, params)?;
780            let rv = eval_set_return_operand(db, match_rs, row, rel_vars, right, params)?;
781            eval_set_return_arith(op, lv, rv)
782        }
783        // CASE is supported in read-query RETURN; in a write-statement RETURN
784        // projection (CREATE/MERGE/SET … RETURN) it is not yet wired.
785        Operand::Case { .. } => Err(GraphError::QueryError {
786            detail: "CASE is not supported in a write-statement RETURN projection; \
787                     use a read query"
788                .into(),
789        }),
790        // Same as CASE: a list subscript is supported in a read-query RETURN
791        // but not yet in a write-statement RETURN projection.
792        Operand::Index { .. } => Err(GraphError::QueryError {
793            detail: "a list subscript is not supported in a write-statement RETURN \
794                     projection; use a read query"
795                .into(),
796        }),
797    }
798}
799
800fn eval_set_return_arith(
801    op: &ArithOp,
802    lv: Option<Value>,
803    rv: Option<Value>,
804) -> Result<Option<Value>> {
805    match (lv, rv) {
806        (None, _) | (_, None) => Ok(None),
807        (Some(Value::Int(a)), Some(Value::Int(b))) => {
808            let result = match op {
809                ArithOp::Sub => a.saturating_sub(b),
810                ArithOp::Mul => a.saturating_mul(b),
811                ArithOp::Add => a.saturating_add(b),
812                ArithOp::Div => {
813                    if b == 0 {
814                        return Err(GraphError::QueryError {
815                            detail: "division by zero".into(),
816                        });
817                    }
818                    a.checked_div(b).unwrap_or(i64::MAX)
819                }
820            };
821            Ok(Some(Value::Int(result)))
822        }
823        (Some(lv), Some(rv)) => {
824            let a = match &lv {
825                Value::Float(f) => *f,
826                Value::Int(i) => *i as f64,
827                _ => {
828                    return Err(GraphError::QueryError {
829                        detail: format!("arithmetic operand must be numeric, got {lv:?}"),
830                    })
831                }
832            };
833            let b = match &rv {
834                Value::Float(f) => *f,
835                Value::Int(i) => *i as f64,
836                _ => {
837                    return Err(GraphError::QueryError {
838                        detail: format!("arithmetic operand must be numeric, got {rv:?}"),
839                    })
840                }
841            };
842            let result = match op {
843                ArithOp::Sub => a - b,
844                ArithOp::Mul => a * b,
845                ArithOp::Add => a + b,
846                ArithOp::Div => {
847                    if b == 0.0 {
848                        return Err(GraphError::QueryError {
849                            detail: "division by zero".into(),
850                        });
851                    }
852                    a / b
853                }
854            };
855            Ok(Some(Value::Float(result)))
856        }
857    }
858}
859
860fn eval_set_return_func<F: Fs>(
861    db: &GraphDb<F>,
862    match_rs: &ResultSet,
863    row: usize,
864    rel_vars: &[String],
865    name: &str,
866    args: &[Operand],
867    params: &BTreeMap<String, Value>,
868) -> Result<Option<Value>> {
869    let norm = name.to_ascii_lowercase();
870    if norm == "type" {
871        if args.len() != 1 {
872            return Err(GraphError::QueryError {
873                detail: format!("type() requires exactly 1 argument, got {}", args.len()),
874            });
875        }
876        let Operand::Var(rel) = &args[0] else {
877            return Err(GraphError::QueryError {
878                detail: "type() argument must be a relationship variable (e.g. type(r))".into(),
879            });
880        };
881        return Ok(match_rs.get(row, &rel_type_alias(rel)).cloned());
882    }
883    if norm == "key" {
884        if args.len() != 1 {
885            return Err(GraphError::QueryError {
886                detail: format!("key() requires exactly 1 argument, got {}", args.len()),
887            });
888        }
889        let Operand::Var(var) = &args[0] else {
890            return Err(GraphError::QueryError {
891                detail: "key() argument must be a node variable (e.g. key(n))".into(),
892            });
893        };
894        if rel_vars.iter().any(|r| r == var) {
895            return Err(GraphError::QueryError {
896                detail: format!("key() argument `{var}` is a relationship, not a node"),
897            });
898        }
899        // MATCH rows bind node variables to their key string, so the column
900        // value *is* the key.
901        return Ok(match_rs.get(row, var).cloned());
902    }
903    let mut vals = Vec::with_capacity(args.len());
904    for arg in args {
905        vals.push(eval_set_return_operand(
906            db, match_rs, row, rel_vars, arg, params,
907        )?);
908    }
909    match norm.as_str() {
910        "tolower" => {
911            if vals.len() != 1 {
912                return Err(GraphError::QueryError {
913                    detail: format!("toLower() requires exactly 1 argument, got {}", vals.len()),
914                });
915            }
916            Ok(vals[0].clone().map(|val| match val {
917                Value::Str(s) => Value::Str(s.to_ascii_lowercase()),
918                other => other,
919            }))
920        }
921        "toupper" => {
922            if vals.len() != 1 {
923                return Err(GraphError::QueryError {
924                    detail: format!("toUpper() requires exactly 1 argument, got {}", vals.len()),
925                });
926            }
927            Ok(vals[0].clone().map(|val| match val {
928                Value::Str(s) => Value::Str(s.to_ascii_uppercase()),
929                other => other,
930            }))
931        }
932        "size" => match vals.first().cloned().flatten() {
933            None => Ok(None),
934            Some(Value::Str(s)) => Ok(Some(Value::Int(s.len() as i64))),
935            Some(Value::List(items)) => Ok(Some(Value::Int(items.len() as i64))),
936            Some(_) => Ok(None),
937        },
938        "coalesce" => Ok(vals.into_iter().flatten().next()),
939        "abs" => match vals.first().cloned().flatten() {
940            None => Ok(None),
941            Some(Value::Int(n)) => Ok(Some(Value::Int(n.saturating_abs()))),
942            Some(Value::Float(f)) => Ok(Some(Value::Float(f.abs()))),
943            Some(_) => Ok(None),
944        },
945        "round" => match vals.first().cloned().flatten() {
946            None => Ok(None),
947            Some(Value::Float(f)) => Ok(Some(Value::Float(f.round()))),
948            Some(Value::Int(n)) => Ok(Some(Value::Int(n))),
949            Some(_) => Ok(None),
950        },
951        "decay" => {
952            if vals.len() != 3 {
953                return Err(GraphError::QueryError {
954                    detail: format!("decay() requires exactly 3 arguments, got {}", vals.len()),
955                });
956            }
957            match (vals[0].clone(), vals[1].clone(), vals[2].clone()) {
958                (None, _, _) | (_, None, _) | (_, _, None) => Ok(None),
959                (Some(b), Some(a), Some(h)) => {
960                    let numeric = |v: Value| -> Result<f64> {
961                        match v {
962                            Value::Int(n) => Ok(n as f64),
963                            Value::Float(f) => Ok(f),
964                            other => Err(GraphError::QueryError {
965                                detail: format!(
966                                    "decay() requires numeric arguments, got {other:?}"
967                                ),
968                            }),
969                        }
970                    };
971                    let b = numeric(b)?;
972                    let a = numeric(a)?;
973                    let h = numeric(h)?;
974                    if h <= 0.0 {
975                        return Err(GraphError::QueryError {
976                            detail: "decay() requires halflife > 0".into(),
977                        });
978                    }
979                    Ok(Some(Value::Float(b * 0.5f64.powf(a / h))))
980                }
981            }
982        }
983        _ => Err(GraphError::QueryError {
984            detail: format!(
985                "unknown function `{name}`; supported: toLower, toUpper, size, coalesce, type, abs, round, decay, key"
986            ),
987        }),
988    }
989}
990
991fn eval_set_return_item<F: Fs>(
992    db: &GraphDb<F>,
993    match_rs: &ResultSet,
994    row: usize,
995    rel_vars: &[String],
996    item: &RetItem,
997    params: &BTreeMap<String, Value>,
998) -> Result<Option<Value>> {
999    match &item.value {
1000        RetVal::Var(v) => eval_set_return_operand(
1001            db,
1002            match_rs,
1003            row,
1004            rel_vars,
1005            &Operand::Var(v.clone()),
1006            params,
1007        ),
1008        RetVal::Prop { var, field } => eval_set_return_operand(
1009            db,
1010            match_rs,
1011            row,
1012            rel_vars,
1013            &Operand::Prop {
1014                var: var.clone(),
1015                field: field.clone(),
1016            },
1017            params,
1018        ),
1019        RetVal::FuncCall { name, args } => {
1020            eval_set_return_func(db, match_rs, row, rel_vars, name, args, params)
1021        }
1022        RetVal::ScalarExpr(op) => eval_set_return_operand(db, match_rs, row, rel_vars, op, params),
1023        RetVal::Agg { .. } => Err(GraphError::QueryError {
1024            detail: "aggregates are not supported in MATCH … SET … RETURN".into(),
1025        }),
1026    }
1027}
1028
1029/// Project user RETURN from original MATCH rows after SET. No rematch.
1030fn project_set_return_rows<F: Fs>(
1031    db: &GraphDb<F>,
1032    rel_vars: &[String],
1033    match_rs: &ResultSet,
1034    returns: &[RetItem],
1035    params: &BTreeMap<String, Value>,
1036) -> Result<ResultSet> {
1037    let columns: Vec<String> = returns.iter().map(ret_column_name).collect();
1038    let mut out = ResultSet::new(columns);
1039    for row in 0..match_rs.len() {
1040        let mut cells = Vec::with_capacity(returns.len());
1041        for item in returns {
1042            cells.push(eval_set_return_item(
1043                db, match_rs, row, rel_vars, item, params,
1044            )?);
1045        }
1046        out.push_row(cells);
1047    }
1048    Ok(out)
1049}
1050
1051/// Single construction point for a `GraphMut` view over the split-borrowed graph fields.
1052/// Callers use `std::mem::take` on the engine before calling this, then restore it after.
1053/// Extract a `Vec<f64>` from a `Value::List` whose items are all numeric.
1054/// Returns `None` for non-list values or lists with non-numeric elements.
1055fn value_as_float_list(v: &Value) -> Option<Vec<f64>> {
1056    match v {
1057        Value::List(items) => items
1058            .iter()
1059            .map(|item| match item {
1060                Value::Float(f) => Some(*f),
1061                Value::Int(i) => Some(*i as f64),
1062                _ => None,
1063            })
1064            .collect(),
1065        _ => None,
1066    }
1067}
1068
1069fn make_graph_mut<'a>(
1070    ids: &'a IdMap,
1071    syms: &'a mut Interner,
1072    labels: &'a [u32],
1073    props: core_storage::v8::seam::ColumnsView<'a>,
1074    topo: &'a mut Topology,
1075    base: &'a Option<std::sync::Arc<core_storage::v8::MappedBase>>,
1076    edge_props: &'a mut EdgeProps,
1077) -> GraphMut<'a> {
1078    GraphMut {
1079        ids,
1080        syms,
1081        labels,
1082        props,
1083        topo,
1084        base_topo: base_csr(base),
1085        edge_props,
1086    }
1087}
1088
1089/// The archived CSR of an open V8 snapshot, for the rule engine's graph reads.
1090///
1091/// A store opened from a snapshot keeps its edges in the mapping and its
1092/// overlay empty, so a rule that reads the graph's shape has to see both.
1093fn base_csr(
1094    base: &Option<std::sync::Arc<core_storage::v8::MappedBase>>,
1095) -> Option<&core_storage::v8::layout::ArchivedCsr> {
1096    base.as_ref().map(|b| {
1097        b.topology()
1098            .expect("base topology section bounds validated at open")
1099    })
1100}
1101
1102/// Build a `ColumnsView` from the disjoint `props` overlay and optional V8 base.
1103///
1104/// Takes explicit field references rather than `&self` so the caller can hold
1105/// simultaneous mutable borrows of other fields (e.g. `syms`, `topo`).
1106fn build_props_view<'a>(
1107    props: &'a ColumnStore,
1108    base: &'a Option<std::sync::Arc<core_storage::v8::MappedBase>>,
1109) -> core_storage::v8::seam::ColumnsView<'a> {
1110    match base {
1111        None => core_storage::v8::seam::ColumnsView::owned(props),
1112        Some(b) => {
1113            let archived = b
1114                .columns()
1115                .expect("base columns section bounds validated at open");
1116            core_storage::v8::seam::ColumnsView::with_base_cached(props, archived, b.mixed_cache())
1117        }
1118    }
1119}
1120
1121fn build_topo_view<'a>(
1122    overlay: &'a Topology,
1123    base: &'a Option<std::sync::Arc<core_storage::v8::MappedBase>>,
1124) -> core_storage::v8::seam::TopologyView<'a> {
1125    match base {
1126        None => core_storage::v8::seam::TopologyView::owned(overlay),
1127        Some(b) => {
1128            let archived_csr = b
1129                .topology()
1130                .expect("base topology section bounds validated at open");
1131            core_storage::v8::seam::TopologyView::with_base(overlay, archived_csr)
1132        }
1133    }
1134}
1135
1136/// When [`GraphDb`] calls `Fs::sync` after a WAL append.
1137///
1138/// Default is [`Strict`](FsyncPolicy::Strict): every `log_then_apply_with`
1139/// fsyncs (single `insert_node` / `set_prop`). Ingest and `write_batch`
1140/// emit one `WalRecord::Batch` and fsync once at that frame (Batched).
1141/// [`Relaxed`](FsyncPolicy::Relaxed) skips WAL sync; [`GraphDb::snapshot`]
1142/// is still durable via `write_atomic`. Crash-recovery DST stays Strict.
1143#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
1144pub enum FsyncPolicy {
1145    /// Every WAL commit calls `fs.sync` (today's behavior).
1146    #[default]
1147    Strict,
1148    /// Sync only at a `Batch` frame end. Single-op path stays Strict unless
1149    /// this policy is set on the database.
1150    Batched,
1151    /// Never call `fs.sync`. [`GraphDb::snapshot`] still syncs via `write_atomic`.
1152    Relaxed,
1153}
1154
1155/// A precondition for a compare-and-set batch write.
1156///
1157/// All preconditions in a [`GraphDb::write_batch_cas`] or
1158/// [`crate::SharedDb::submit_batch_cas`] call are checked atomically before
1159/// any operation in the batch is applied.  If any precondition fails, the
1160/// entire batch is rejected with [`GraphError::CasConflict`] and no WAL frame
1161/// is written.
1162///
1163/// # Touch definition
1164///
1165/// A node's last-change commit (`last_changed`) is updated when any of the
1166/// following state-changing WAL records touch it:
1167///
1168/// - `InsertNode` / `InsertNodeId` — the newly-inserted node.
1169/// - `SetProp` / `SetPropId` / `RemoveProp` — the property-bearing node.
1170/// - `InsertEdge` / `InsertEdgeId` / `DeleteEdge` — **both** src and dst
1171///   endpoints (an edge change touches both sides).
1172/// - `DeleteNode` — the node is tombstoned; `last_changed` returns `None`
1173///   for deleted keys so the pre-deletion entry is never observed.
1174///
1175/// History markers (`DerivedEdgeAdded` / `DerivedEdgeRetracted`) are
1176/// state no-ops.  The underlying mutation that triggered rule firing already
1177/// updated the relevant nodes' last-change entries.  Rule-management records
1178/// (`CreateRule`, `DeleteRule`, `RebuildRule`) and view/full-text declarations
1179/// do not touch any node's last-change.
1180#[derive(Debug, Clone, PartialEq, Eq)]
1181pub enum Precondition {
1182    /// The node's last-change commit must equal `expected`.
1183    ///
1184    /// Fails with [`GraphError::CasConflict`] when:
1185    /// - The node does not exist (`last_changed` returns `None`), or
1186    /// - The recorded commit seq does not match `expected`.
1187    NodeUnchangedSince { key: String, expected: u64 },
1188    /// The node must not exist (not inserted, or already deleted).
1189    ///
1190    /// Fails with [`GraphError::CasConflict`] (expected=`u64::MAX`,
1191    /// actual=`last_changed(key).unwrap_or(0)`) when the node is live.
1192    NodeAbsent { key: String },
1193}
1194
1195pub struct GraphDb<F: Fs> {
1196    fs: F,
1197    ids: IdMap,
1198    syms: Interner,
1199    topo: Topology,
1200    props: ColumnStore,
1201    labels: Vec<u32>, // node id -> label symbol
1202    edge_props: EdgeProps,
1203    engine: RuleEngine,
1204    view_store: ViewStore,
1205    /// Incremental inverted index for full-text-lite search.
1206    /// Rebuild-on-open: populated from WAL replay + rebuild_all at open end.
1207    fulltext: FulltextIndex,
1208    /// Opt-in equality index over scalar node properties.
1209    /// Rebuild-on-open: declarations replay from the WAL, postings rebuild at
1210    /// open end (mirrors `fulltext`).
1211    prop_index: PropertyIndex,
1212    event_sink: Option<Box<dyn Fn(MutationEvent) + Send + Sync>>,
1213    /// WAL fsync cadence. Default [`FsyncPolicy::Strict`].
1214    fsync: FsyncPolicy,
1215    /// Monotonically increasing per-commit counter.  A single `log_then_apply_with`
1216    /// call increments this once; all events emitted from that call share the same
1217    /// `commit_seq` value.
1218    commit_seq: u64,
1219    /// RBAC role definitions loaded from `roles.json` at open.
1220    ///
1221    /// `Some(roles)` — loaded successfully (may be empty when no roles are defined).
1222    /// `None` — `roles.json` was present but corrupt; `mask_for_role` returns
1223    /// `Err` for any request (fail-loud, never silently grant empty visibility).
1224    roles: Option<Vec<RoleDef>>,
1225    /// Live subscriptions.  Entries with a dead `Weak` are pruned on the next
1226    /// distribute_events call.
1227    subscriptions: Vec<SubEntry>,
1228    /// Live query subscriptions. Re-executed on every commit when non-empty.
1229    /// Dead `Weak` entries are pruned inside `distribute_events`.
1230    query_subscriptions: Vec<QuerySubEntry>,
1231    /// Queue capacity for new subscriptions created by this db.  Default is
1232    /// [`DEFAULT_SUB_CAPACITY`]; can be overridden via [`set_sub_capacity`]
1233    /// to test Lagged behaviour with small queues.
1234    sub_capacity: usize,
1235    /// True for as-of instances opened via [`GraphDb::open_at`].
1236    /// Every mutation method and `snapshot()` returns [`GraphError::ReadOnly`]
1237    /// when this flag is set.
1238    read_only: bool,
1239    /// Total WAL commit count at the time [`open_at`] was called.
1240    /// 0 for normal (non-as-of) instances.
1241    total_wal_commits: u64,
1242    /// Immutable mmap-backed base snapshot (V8).  When `Some`, `self.topo` is
1243    /// the WAL-replay overlay (empty at open time, populated by apply()) and
1244    /// reads go through a merged `TopologyView`.  `self.props` is always
1245    /// fully materialized (base + WAL replay) for HNSW/IVF and view compat.
1246    base: Option<Arc<core_storage::v8::MappedBase>>,
1247    // ── MVCC epoch reader state ───────────────────────────────────────────────
1248    /// Most-recent full overlay clone.  Initialized at end of `open_with` /
1249    /// `open_at_with`; refreshed every `FOLD_EVERY_K` commits.
1250    /// `None` only between struct creation and the first fold.
1251    fold_overlay: Option<Arc<crate::reader::FrozenOverlay>>,
1252    /// Per-commit deltas accumulated since the last fold.
1253    delta_tail: Vec<Arc<crate::reader::CommitDelta>>,
1254    /// How many commits have occurred since the last fold.
1255    commits_since_fold: usize,
1256    /// When true, `log_then_apply_with` buffers event notifications instead of
1257    /// firing them immediately.  Used by the group-commit drain thread to defer
1258    /// events until after the group fsync (R2: durability before notification).
1259    /// Cleared to false once the drain thread flushes or discards the buffer.
1260    defer_events: bool,
1261    /// Buffered events accumulated while `defer_events` is true.
1262    deferred_events: Vec<DeferredEvent>,
1263    /// Set to true by the group-commit drain thread when a group fsync fails
1264    /// after WAL truncation.  All subsequent mutation attempts return an IO
1265    /// error until the database is reopened.
1266    degraded: bool,
1267    /// Set to `true` after `ensure_v8_base_sections_loaded` has read provenance,
1268    /// HNSW, and IVF sections from the mmap base into the engine's retained
1269    /// fields.  `false` on all opens until first use; always `true` for non-V8
1270    /// opens (base is None, fast-path sets flag immediately).
1271    v8_sections_loaded: std::sync::atomic::AtomicBool,
1272    /// Serializes the one-time section population in `ensure_v8_base_sections_loaded`.
1273    v8_sections_mutex: std::sync::Mutex<()>,
1274    /// Per-node last-change commit sequence.  `last_change[node_id] = seq` means
1275    /// the node was last modified by commit `seq`.
1276    ///
1277    /// Loaded from V8 section 11 at open; updated on every state-changing commit
1278    /// and WAL replay frame.  V5-V7 stores start with an empty map; pre-WAL-horizon
1279    /// nodes return `None` from `last_changed` until they are next mutated.
1280    ///
1281    /// See [`Precondition`] for the full touch definition.
1282    last_change: HashMap<u32, u64>,
1283    /// WAL archive retention policy set by [`set_wal_archive_retention`].
1284    /// `None` = unlimited (keep all archives); `Some(N)` = keep N newest archives,
1285    /// pruning older ones at snapshot time.  0 is treated as unlimited.
1286    wal_archive_retention: Option<u32>,
1287    /// Global frame index of the first commit that is still reachable through
1288    /// surviving archives.  Persisted to `wal.floor` sidecar when pruning occurs.
1289    /// Default 0 = all history reachable.
1290    wal_horizon_floor: u64,
1291    /// True when the surviving archive chain forms a continuous WAL history
1292    /// starting from the store's first commit (the genesis chain).
1293    ///
1294    /// `open_at` may replay archive-resident commits from empty state only when
1295    /// this flag is true AND `wal_horizon_floor == 0`.  Cleared whenever:
1296    ///   - a WAL-truncating snapshot (`keep_wal=false`) is taken after archives
1297    ///     already exist (breaks the chain for subsequent archives), or
1298    ///   - any archive is pruned (floor advances past zero).
1299    ///
1300    /// Persisted via the `wal.genesis` marker file; loaded from it at open.
1301    archive_genesis_chain: bool,
1302    /// Transient write-authz context set by `write_batch_authz` /
1303    /// `query_write_authz` for the duration of ONE mutation call.
1304    /// Always `None` at rest.  Never serialized, never WAL-replayed.
1305    pending_write_authz: Option<WriteAuthz>,
1306    /// Slow-query threshold in milliseconds.  0 = disabled.
1307    /// Seeded from `MUSHROOMDB_SLOW_QUERY_MS` at open; override via
1308    /// [`GraphDb::set_slow_query_threshold_ms`] (tests must use the setter
1309    /// — env vars are process-global and race parallel test threads).
1310    slow_query_threshold_ms: u64,
1311    /// Ring buffer of recent slow queries (interior-mutable so `query(&self)`
1312    /// can record entries without requiring `&mut self`).
1313    slow_queries: std::sync::Mutex<SlowQueryLog>,
1314    /// Instant at which the database was opened (used by `/metrics` uptime).
1315    started_at: std::time::Instant,
1316    // ── Multi-process state (cross-process lock + WAL tailing) ────────────────
1317    /// Byte offset of the WAL prefix already applied to in-memory state.
1318    ///
1319    /// Advanced by exactly the encoded length of every frame this handle
1320    /// appends, and by the decoded byte count of every tail
1321    /// [`refresh`](GraphDb::refresh) absorbs. Rewound by
1322    /// [`set_wal_consumed`](GraphDb::set_wal_consumed) when the group-commit
1323    /// drain thread truncates a failed group. Compared against the WAL's
1324    /// on-disk length to decide staleness.
1325    wal_consumed: u64,
1326    /// Identity of the snapshot this handle's base state came from, as
1327    /// `(len, mtime_nanos)`. A different value means another process replaced
1328    /// the snapshot and the WAL no longer continues our state: refresh reloads.
1329    snapshot_ident: Option<(u64, u64)>,
1330    /// The options this handle was opened with. Replayed verbatim when
1331    /// `refresh` has to rebuild from disk.
1332    open_opts: OpenOptions,
1333    /// True when this handle holds the cross-process write lock for its whole
1334    /// lifetime (a plain read-write open). Per-write lock acquisition is a
1335    /// no-op on such a handle, and never releases the lock.
1336    holds_lifetime_lock: bool,
1337    /// True between a failed lock acquisition and the end of the write scope
1338    /// that failed. Makes every WAL-appending mutation in that scope return
1339    /// [`GraphError::Busy`] instead of writing.
1340    lock_denied: bool,
1341    /// True for an as-of view opened via [`GraphDb::open_at`]. Such a view is
1342    /// pinned to one commit, so it is never stale and never refreshes — later
1343    /// commits by any process are deliberately invisible to it.
1344    pinned: bool,
1345}
1346
1347/// One group of deferred event notifications, held until the group fsync
1348/// completes.  Replayed by [`GraphDb::flush_deferred_events`].
1349struct DeferredEvent {
1350    rec: core_storage::WalRecord,
1351    engine_deltas: Vec<EngineEdgeDelta>,
1352    seq: u64,
1353    ingest: Option<(String, usize)>,
1354}
1355
1356/// Options for [`GraphDb::open_with_options`].
1357#[derive(Clone, Copy, Debug)]
1358pub struct OpenOptions {
1359    /// Rewrite an old-format snapshot to the current VERSION after a
1360    /// successful load (default `true`). The old snapshot is kept as
1361    /// `snapshot.bin.bak` until the next clean open at the current version,
1362    /// at which point the `.bak` is deleted.
1363    ///
1364    /// Set to `false` to open a store without touching any on-disk files
1365    /// (useful for read-only inspection of a store at an older format).
1366    pub auto_migrate: bool,
1367
1368    /// Write the valid WAL prefix back over a torn tail on open (default
1369    /// `true`). Truncating a genuinely torn tail is correct crash recovery.
1370    ///
1371    /// Set to `false` for an unattended reader. The valid prefix is still
1372    /// decoded and replayed in memory, but nothing is written: a reader that
1373    /// opens while another process is mid-append would otherwise discard a
1374    /// frame that writer believes durable. `mushroomdb recall`, which runs on
1375    /// every prompt, passes `false` for exactly this reason.
1376    pub repair_wal: bool,
1377
1378    /// Open without ever writing to the store (default `false`).
1379    ///
1380    /// A read-only handle:
1381    /// - returns [`GraphError::ReadOnly`] from every mutation and from
1382    ///   `snapshot()`;
1383    /// - performs no disk write at open — no WAL repair write-back and no
1384    ///   auto-migration rewrite, whatever the other two flags say;
1385    /// - never takes the cross-process write lock, so it opens immediately even
1386    ///   while another process is writing, and never makes a writer wait.
1387    ///
1388    /// [`refresh`](GraphDb::refresh) and [`is_stale`](GraphDb::is_stale) work
1389    /// normally, so a read-only handle can follow another process's commits.
1390    pub read_only: bool,
1391}
1392
1393impl Default for OpenOptions {
1394    fn default() -> Self {
1395        Self {
1396            auto_migrate: true,
1397            repair_wal: true,
1398            read_only: false,
1399        }
1400    }
1401}
1402
1403/// How long a writer polls for the cross-process write lock before giving up
1404/// with [`GraphError::Busy`].
1405///
1406/// Long enough to ride out another process's commit (a batch apply plus one
1407/// fsync), short enough that a stuck peer surfaces as an error rather than a
1408/// hang.
1409pub const WRITE_LOCK_WAIT: std::time::Duration = std::time::Duration::from_secs(2);
1410
1411/// Interval between poll attempts while waiting for the cross-process lock.
1412pub(crate) const LOCK_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(10);
1413
1414/// Why `load_from_disk` is running, which decides whether it may repair.
1415#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1416enum LoadOrigin {
1417    /// A fresh open. Crash recovery is this handle's job: a torn WAL tail is
1418    /// the signature of a crash and truncating it is correct, and archives
1419    /// orphaned by an interrupted prune can be swept.
1420    Open,
1421    /// A reload driven by [`GraphDb::refresh`], because another process
1422    /// replaced the snapshot. Nothing here is crash recovery — the store is
1423    /// live and someone else is writing it — so this origin writes nothing.
1424    Reload,
1425}
1426
1427/// Authorization context carried by `write_batch_authz` / `query_write_authz`.
1428///
1429/// `None` at the call site = full authority (today's zero-cost behavior).
1430/// `Some(WriteAuthz)` = role-scoped: the decision table (plan §"authz decision
1431/// table") is evaluated per-op inside `commit_logged_batch` BEFORE any WAL
1432/// record is built.  A denial returns an error with no WAL frame written.
1433///
1434/// The mask is ALWAYS `Omit`-mode: role-token paths must never acknowledge
1435/// hidden-node existence to callers.
1436#[derive(Clone, Debug)]
1437pub struct WriteAuthz {
1438    pub role: String,
1439    pub scope: WriteScope,
1440    /// Resolved by `mask_for_role` under the same write guard as the mutation.
1441    /// Always `Omit`-mode — never `Stub`.
1442    pub mask: crate::mask::NodeMask,
1443}
1444
1445/// Write `bytes` to `snapshot.bin.bak` atomically with full fsync.
1446///
1447/// Uses [`RealFs::write_atomic`] which applies `F_FULLFSYNC` on macOS and
1448/// `sync_all` on other platforms, then renames the `.tmp` file into place and
1449/// syncs the directory entry. This is the only correct path for writing the
1450/// `.bak` — plain `std::fs::write + sync_all` misses both `F_FULLFSYNC` and
1451/// the directory sync.
1452pub fn write_snapshot_bak(dir: &std::path::Path, bytes: &[u8]) -> crate::Result<()> {
1453    use core_storage::fs::{FileId, Fs as _};
1454    RealFs::new(dir)
1455        .map_err(core_storage::GraphError::Io)?
1456        .write_atomic(FileId::SnapshotBak, bytes)
1457        .map_err(core_storage::GraphError::Io)
1458}
1459
1460/// Return the on-disk snapshot format version without decoding the full snapshot.
1461///
1462/// Reads only the 6-byte header (magic + version LE). Returns `None` when no
1463/// snapshot file exists (WAL-only store). Returns an error if the header is
1464/// malformed.
1465pub fn snapshot_version_at(dir: &std::path::Path) -> crate::Result<Option<u16>> {
1466    use std::io::Read as _;
1467    let path = dir.join("snapshot.bin");
1468    let mut header = [0u8; 6];
1469    let n = match std::fs::File::open(&path) {
1470        Ok(mut f) => f.read(&mut header).map_err(core_storage::GraphError::Io)?,
1471        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1472        Err(e) => return Err(core_storage::GraphError::Io(e)),
1473    };
1474    core_storage::snapshot::peek_version(&header[..n])
1475}
1476
1477/// Options for [`GraphDb::snapshot_with`].
1478#[derive(Debug, Clone, Default)]
1479pub struct SnapshotOptions {
1480    /// When `true`, the WAL is preserved after the snapshot write.
1481    /// Pre-snapshot commits remain reachable via [`GraphDb::open_at`].
1482    /// When `false` (the default), the WAL is truncated to a minimal
1483    /// baseline so cold-start replay stays fast.
1484    pub keep_wal: bool,
1485    /// When `true`, the current WAL is renamed to `wal.<commit_seq>.archive`
1486    /// before a fresh WAL baseline is written (history-preserving snapshot).
1487    ///
1488    /// This is the feature opt-in: `false` (the default) leaves the existing
1489    /// truncation / keep-wal behaviour byte-identical.  `archive_wal` takes
1490    /// precedence over `keep_wal` when both are set.
1491    ///
1492    /// Archives can be scanned by [`GraphDb::node_history`],
1493    /// [`GraphDb::edge_history`], [`GraphDb::was_linked`], and
1494    /// [`GraphDb::open_at`], extending the reachable history horizon across
1495    /// snapshot boundaries.
1496    pub archive_wal: bool,
1497}
1498
1499/// Derive the scan-label sym for the commit-skip fast-path.
1500///
1501/// Walks `ops` to find the plan's leading scan op (`ScanLabel`, `IndexScan`,
1502/// or `IndexIntersect`) with a concrete label string, then interns it.
1503///
1504/// Returns `None` in all cases where skipping is unsafe:
1505/// - Any `Expand` op is present (edge traversal; edges change results regardless
1506///   of node labels).
1507/// - The leading scan has no label (`ScanLabel { label: None }` — full scan).
1508/// - No recognizable leading scan op is found.
1509///
1510/// This is the conservative v0.4.3 boundary. The caller stores the result in
1511/// [`QuerySubEntry::scan_label`] at subscribe time; `None` means always execute.
1512fn extract_scan_label(ops: &[PlanOp], syms: &mut Interner) -> Option<u32> {
1513    // Any Expand → must always re-execute (edges can change join results).
1514    if ops.iter().any(|op| matches!(op, PlanOp::Expand { .. })) {
1515        return None;
1516    }
1517    for op in ops {
1518        match op {
1519            PlanOp::ScanLabel {
1520                label: Some(label), ..
1521            } => return Some(syms.intern(label)),
1522            PlanOp::IndexScan {
1523                label: Some(label), ..
1524            } => return Some(syms.intern(label)),
1525            PlanOp::IndexIntersect {
1526                label: Some(label), ..
1527            } => return Some(syms.intern(label)),
1528            _ => {}
1529        }
1530    }
1531    None
1532}
1533
1534impl GraphDb<RealFs> {
1535    /// Open the database at `dir` with default options.
1536    ///
1537    /// Equivalent to `open_with_options(dir, OpenOptions::default())`.
1538    /// Old-format snapshots (V5, V6) are automatically migrated to the
1539    /// current version on a successful load (see [`OpenOptions::auto_migrate`]).
1540    pub fn open(dir: &std::path::Path) -> Result<Self> {
1541        Self::open_with_options(dir, OpenOptions::default())
1542    }
1543
1544    /// Open the database at `dir` with explicit options.
1545    ///
1546    /// When `opts.auto_migrate` is `true` (the default) and the on-disk
1547    /// snapshot is an older format version, this function:
1548    ///   1. Copies the current `snapshot.bin` to `snapshot.bin.bak` (atomic
1549    ///      + fsynced) before any modification.
1550    ///   2. Rewrites `snapshot.bin` at the current format version via
1551    ///      [`GraphDb::snapshot_with`] with `keep_wal: true` (WAL preserved).
1552    ///
1553    /// If migration fails the error is returned and the original files are
1554    /// intact (the `.bak` was written before the new snapshot was attempted).
1555    ///
1556    /// A clean open that finds the snapshot already at the current version
1557    /// deletes any leftover `.bak` file.
1558    ///
1559    /// WAL-only stores (no snapshot) are never auto-migrated on open.
1560    ///
1561    /// `opts.repair_wal` controls the other write this function can make; see
1562    /// [`OpenOptions::repair_wal`]. With both flags `false` the open touches
1563    /// no file on disk.
1564    pub fn open_with_options(dir: &std::path::Path, opts: OpenOptions) -> Result<Self> {
1565        Self::open_dir(dir, opts, true)
1566    }
1567
1568    /// Open without taking the cross-process write lock for the handle's
1569    /// lifetime.
1570    ///
1571    /// Only [`SharedDb`](crate::SharedDb) uses this: a long-lived server holds
1572    /// its handle open indefinitely, so it takes the lock per write instead of
1573    /// keeping every other process out of the store for as long as it runs.
1574    pub(crate) fn open_unlocked(dir: &std::path::Path) -> Result<Self> {
1575        Self::open_dir(dir, OpenOptions::default(), false)
1576    }
1577
1578    fn open_dir(dir: &std::path::Path, opts: OpenOptions, hold_lock: bool) -> Result<Self> {
1579        // Header-only peek — 6 bytes, no full decode.
1580        let snap_version = snapshot_version_at(dir)?;
1581
1582        // Full load: decode snapshot + replay WAL + rebuild indexes.
1583        let mut db = Self::open_generic(RealFs::new(dir)?, opts, hold_lock)?;
1584
1585        // A read-only handle writes nothing at open, so it never migrates —
1586        // the old-format snapshot is loaded and left exactly as it is.
1587        if opts.auto_migrate && !opts.read_only {
1588            match snap_version {
1589                Some(ver) if ver < core_storage::snapshot::VERSION => {
1590                    let _tm = std::time::Instant::now();
1591                    // Copy the original snapshot to .bak at OS level — no in-memory
1592                    // buffer required for a 2+ GiB file.
1593                    //
1594                    // Crash-safety: snapshot.bin remains intact (write_atomic inside
1595                    // snapshot_with uses a .tmp+rename) until the V8 write succeeds.
1596                    // A torn .bak on crash is acceptable because the original
1597                    // snapshot.bin is the authoritative source until after the rename.
1598                    std::fs::copy(dir.join("snapshot.bin"), dir.join("snapshot.bin.bak"))
1599                        .map_err(core_storage::GraphError::Io)?;
1600                    trace_migrate!("bak copy done", _tm);
1601                    // Rewrite snapshot at current version; keep WAL intact.
1602                    db.snapshot_with(SnapshotOptions {
1603                        keep_wal: true,
1604                        ..SnapshotOptions::default()
1605                    })?;
1606                    trace_migrate!("snapshot_with done", _tm);
1607                }
1608                Some(_) => {
1609                    // Already current version: remove any leftover .bak.
1610                    let bak = dir.join("snapshot.bin.bak");
1611                    if bak.exists() {
1612                        std::fs::remove_file(&bak).map_err(core_storage::GraphError::Io)?;
1613                    }
1614                }
1615                None => {
1616                    // WAL-only store — nothing to migrate on open.
1617                }
1618            }
1619        }
1620
1621        Ok(db)
1622    }
1623
1624    /// Open a read-only view of the database as it existed after `commit`.
1625    ///
1626    /// Commit indices are 0-based over the current WAL: commit 0 is the state
1627    /// after the first WAL frame, commit N-1 is the state after the N-th (most
1628    /// recent) frame.  Call [`GraphDb::open`] to read the full current state.
1629    ///
1630    /// **Replay base.** [`GraphDb::snapshot`] truncates the WAL when it runs,
1631    /// so as-of can only reach commits recorded in the current WAL (those
1632    /// written after the most recent snapshot, or all commits if no snapshot
1633    /// was ever taken).  Commit 0 in `open_at` always refers to the first
1634    /// frame in the WAL that exists on disk, not the first ever write to the
1635    /// database.  When the on-disk snapshot recorded that it truncated the
1636    /// WAL (V7, default `keep_wal: false`), it is loaded as the base state
1637    /// before frame replay, so the as-of view includes all pre-snapshot data.
1638    /// Snapshots written with `keep_wal: true` (and legacy V5/V6 snapshots)
1639    /// are ignored and replay is WAL-only, as before.
1640    ///
1641    /// **Read-only.** Every mutation method and `snapshot()` on the returned
1642    /// instance returns [`GraphError::ReadOnly`].  Queries, `explain()`, and
1643    /// `stats()` work normally.
1644    ///
1645    /// # Errors
1646    /// - [`GraphError::CommitOutOfRange`] if `commit >= wal_commit_count` (including
1647    ///   when the WAL is empty after a snapshot).
1648    pub fn open_at(dir: &std::path::Path, commit: u64) -> Result<Self> {
1649        Self::open_at_with(RealFs::new(dir)?, commit)
1650    }
1651
1652    /// Run a **read-only** Cypher query against the graph as it existed at
1653    /// `commit` — the "time-travel" / agent-replay query. Opens a temporal view
1654    /// of this store's directory at that commit and executes the read there.
1655    ///
1656    /// The current instance is unaffected. Write statements are rejected (the
1657    /// temporal view is read-only). `commit` is a 0-based WAL commit index;
1658    /// `commit == wal_commit_count` (or `open_at`'s range) yields the newest
1659    /// state. Prefer this over holding many historical instances open.
1660    ///
1661    /// # Errors
1662    /// - [`GraphError::CommitOutOfRange`] if `commit` is past the WAL horizon.
1663    /// - A query error for a malformed or write query.
1664    pub fn query_at(
1665        &self,
1666        commit: u64,
1667        cypher: &str,
1668        params: &std::collections::BTreeMap<String, Value>,
1669    ) -> Result<ResultSet> {
1670        let dir = self.fs.dir().to_path_buf();
1671        let temporal = Self::open_at(&dir, commit)?;
1672        if is_write_tokens(&lex(cypher).map_err(|e| GraphError::QueryError {
1673            detail: format!("lex: {e}"),
1674        })?) {
1675            return Err(GraphError::QueryError {
1676                detail: "query_at is read-only: write statements are not permitted in a \
1677                         time-travel query"
1678                    .into(),
1679            });
1680        }
1681        temporal.query(cypher, params)
1682    }
1683}
1684
1685impl<F: Fs> GraphDb<F> {
1686    /// Open over an arbitrary [`Fs`], repairing a torn WAL tail as usual.
1687    pub fn open_with(fs: F) -> Result<Self> {
1688        Self::open_with_repair(fs, true)
1689    }
1690
1691    /// As [`GraphDb::open_with`], but `repair_wal: false` decodes the valid WAL
1692    /// prefix without writing the truncation back. See
1693    /// [`OpenOptions::repair_wal`].
1694    pub fn open_with_repair(fs: F, repair_wal: bool) -> Result<Self> {
1695        Self::open_generic(
1696            fs,
1697            OpenOptions {
1698                repair_wal,
1699                ..OpenOptions::default()
1700            },
1701            true,
1702        )
1703    }
1704
1705    /// Shared open path.
1706    ///
1707    /// `hold_lock` requests the cross-process write lock for the whole handle
1708    /// lifetime — the right behaviour for a plain read-write `GraphDb`, whose
1709    /// owner writes through it directly. [`SharedDb`](crate::SharedDb) passes
1710    /// `false` and takes the lock per write instead, so that a long-lived
1711    /// server does not keep every other process out of the store.
1712    ///
1713    /// A read-only open never takes the lock regardless of `hold_lock`.
1714    fn open_generic(fs: F, opts: OpenOptions, hold_lock: bool) -> Result<Self> {
1715        let mut db = Self::new_empty(fs, opts);
1716        db.read_only = opts.read_only;
1717        if hold_lock && !opts.read_only {
1718            if !db.poll_lock(WRITE_LOCK_WAIT)? {
1719                return Err(GraphError::Busy { holder: None });
1720            }
1721            db.holds_lifetime_lock = true;
1722        }
1723        db.load_from_disk(LoadOrigin::Open)?;
1724        Ok(db)
1725    }
1726
1727    /// A handle with no state loaded: every field at its empty value, the
1728    /// filesystem and options in place. Only [`load_from_disk`] makes it
1729    /// usable.
1730    fn new_empty(fs: F, opts: OpenOptions) -> Self {
1731        Self {
1732            fs,
1733            ids: IdMap::new(),
1734            syms: Interner::new(),
1735            topo: Topology::new(),
1736            props: ColumnStore::new(),
1737            labels: Vec::new(),
1738            edge_props: EdgeProps::new(),
1739            engine: RuleEngine::new(),
1740            view_store: ViewStore::new(),
1741            fulltext: FulltextIndex::new(),
1742            prop_index: PropertyIndex::new(),
1743            event_sink: None,
1744            fsync: FsyncPolicy::Strict,
1745            commit_seq: 0,
1746            roles: Some(vec![]),
1747            subscriptions: Vec::new(),
1748            query_subscriptions: Vec::new(),
1749            sub_capacity: DEFAULT_SUB_CAPACITY,
1750            read_only: false,
1751            total_wal_commits: 0,
1752            base: None,
1753            fold_overlay: None,
1754            delta_tail: Vec::new(),
1755            commits_since_fold: 0,
1756            defer_events: false,
1757            deferred_events: Vec::new(),
1758            degraded: false,
1759            v8_sections_loaded: std::sync::atomic::AtomicBool::new(false),
1760            v8_sections_mutex: std::sync::Mutex::new(()),
1761            last_change: HashMap::new(),
1762            wal_archive_retention: None,
1763            wal_horizon_floor: 0,
1764            archive_genesis_chain: false,
1765            pending_write_authz: None,
1766            slow_query_threshold_ms: std::env::var("MUSHROOMDB_SLOW_QUERY_MS")
1767                .ok()
1768                .and_then(|v| v.parse().ok())
1769                .unwrap_or(100),
1770            slow_queries: std::sync::Mutex::new(SlowQueryLog {
1771                entries: std::collections::VecDeque::new(),
1772                total: 0,
1773            }),
1774            started_at: std::time::Instant::now(),
1775            wal_consumed: 0,
1776            snapshot_ident: None,
1777            open_opts: opts,
1778            holds_lifetime_lock: false,
1779            lock_denied: false,
1780            pinned: false,
1781        }
1782    }
1783
1784    /// Return every field describing stored graph state to its empty value,
1785    /// leaving this handle's own identity alone.
1786    ///
1787    /// Preserved on purpose: the filesystem, open options, lock ownership, the
1788    /// event sink and subscriptions, fsync policy, degraded flag, and the
1789    /// slow-query configuration and log. A caller that registered a sink or a
1790    /// subscription keeps it across a reload.
1791    fn reset_for_reload(&mut self) {
1792        self.ids = IdMap::new();
1793        self.syms = Interner::new();
1794        self.topo = Topology::new();
1795        self.props = ColumnStore::new();
1796        self.labels = Vec::new();
1797        self.edge_props = EdgeProps::new();
1798        self.engine = RuleEngine::new();
1799        self.view_store = ViewStore::new();
1800        self.fulltext = FulltextIndex::new();
1801        self.prop_index = PropertyIndex::new();
1802        self.commit_seq = 0;
1803        self.roles = Some(vec![]);
1804        self.total_wal_commits = 0;
1805        self.base = None;
1806        self.fold_overlay = None;
1807        self.delta_tail = Vec::new();
1808        self.commits_since_fold = 0;
1809        self.deferred_events = Vec::new();
1810        self.v8_sections_loaded
1811            .store(false, std::sync::atomic::Ordering::Release);
1812        self.last_change = HashMap::new();
1813        self.wal_horizon_floor = 0;
1814        self.archive_genesis_chain = false;
1815        self.pending_write_authz = None;
1816        self.wal_consumed = 0;
1817        self.snapshot_ident = None;
1818    }
1819
1820    /// Load the snapshot base and replay the WAL into an empty handle — the
1821    /// whole of what opening a store does after the struct exists.
1822    ///
1823    /// Split out of the open path so that [`refresh`](GraphDb::refresh) can
1824    /// rebuild a handle in place, without ownership of `F`, when another
1825    /// process replaces the snapshot underneath it.
1826    ///
1827    /// `origin` decides whether the two repair writes this function can make
1828    /// are appropriate; see [`LoadOrigin`].
1829    fn load_from_disk(&mut self, origin: LoadOrigin) -> Result<usize> {
1830        // Both writes below are crash recovery, and only an open is entitled to
1831        // perform them. A read-only handle promises to touch nothing, and a
1832        // reload driven by `refresh` is looking at a store another process is
1833        // actively writing: what looks like a torn tail there is a peer
1834        // mid-append, and what looks like an orphaned archive may be one that
1835        // peer is about to reference.
1836        let may_repair = origin == LoadOrigin::Open && !self.open_opts.read_only;
1837        let repair_wal = self.open_opts.repair_wal && may_repair;
1838        let db = self;
1839        db.wal_horizon_floor = db.fs.read_horizon_floor()?;
1840        db.archive_genesis_chain = db.fs.has_genesis_marker();
1841        // Opening cleanup: remove orphaned archives — archives whose frames all
1842        // fall below the horizon floor.  Orphans arise when a crash interrupted
1843        // the retention-prune sequence after the floor was written but before
1844        // all surplus archives were deleted.  Safe to delete: floor already
1845        // accounts for their frames.
1846        if may_repair {
1847            db.cleanup_orphaned_archives()?;
1848        }
1849        let _t0 = std::time::Instant::now();
1850        // Peek 6 bytes to determine snapshot version without reading the full
1851        // file. For RealFs this is a true partial read (O(1)); for SimFs the
1852        // default impl reads all bytes and truncates (still correct).
1853        let snap_header = db.fs.read_prefix(FileId::Snapshot, 6)?;
1854        let is_v8 = snap_header.len() >= 6
1855            && &snap_header[0..4] == b"GDB1"
1856            && u16::from_le_bytes([snap_header[4], snap_header[5]])
1857                == core_storage::snapshot::VERSION_8;
1858        if is_v8 {
1859            // V8: map the file zero-copy (RealFs) or read full bytes (SimFs).
1860            // No 2.4GB heap Vec is allocated on RealFs.
1861            let mapped = Arc::new(
1862                if let Some(snap_path) = db.fs.snapshot_path() {
1863                    core_storage::v8::MappedBase::map(&snap_path)
1864                } else {
1865                    let snap_bytes = db.fs.read(FileId::Snapshot)?;
1866                    core_storage::v8::MappedBase::from_bytes(snap_bytes)
1867                }
1868                .map_err(|e| GraphError::Corrupt {
1869                    detail: format!("v8: mmap open: {e:?}"),
1870                })?,
1871            );
1872            db.restore_v8_base(Arc::clone(&mapped))?;
1873            trace_open!("restore_v8_base", _t0);
1874            db.base = Some(mapped);
1875            trace_open!("base assigned", _t0);
1876        } else if !snap_header.is_empty() {
1877            // Legacy V5-V7: full read required for decode.
1878            let snap_bytes = db.fs.read(FileId::Snapshot)?;
1879            if let Some(state) = core_storage::snapshot::decode(&snap_bytes)? {
1880                db.restore_snapshot_state(state)?;
1881            }
1882        }
1883        // else: snap_header is empty = no snapshot file, fresh store.
1884        //
1885        // Seed commit_seq from the highest seq persisted in last_change so that
1886        // WAL-replay frames (which start at commit_seq+1) always exceed any seq
1887        // already stored in the snapshot.  Without this, a db with one snapshot
1888        // commit would save last_change["a"]=1, then on reopen the first WAL
1889        // frame would replay at seq=1 again — colliding and making WAL-tail
1890        // mutations indistinguishable from the snapshot baseline.
1891        //
1892        // Safety invariant (seq-recycling):
1893        //   Recycled seqs (those below the seeded baseline) were NEVER stored in
1894        //   last_change because they belonged to a previous db lifetime — a new
1895        //   db starts at commit_seq=0 with an empty last_change.  Therefore no
1896        //   CAS precondition can carry a recycled seq as its `expected` value
1897        //   and accidentally match a live node's last_change entry.
1898        //
1899        // `expected:0` on a deleted-then-reinserted node:
1900        //   After deletion, last_changed() returns None; callers that call
1901        //   last_changed() and then use NodeUnchangedSince get None.unwrap_or(0)
1902        //   = 0.  The reinserted node gets seq > 0, so a subsequent CAS with
1903        //   expected=0 correctly conflicts.  The only way to observe actual=0 in
1904        //   a CasConflict would be a caller that invented expected=0 without ever
1905        //   calling last_changed() — unreachable via the documented API contract.
1906        if let Some(&max_seq) = db.last_change.values().max() {
1907            db.commit_seq = db.commit_seq.max(max_seq);
1908        }
1909        let bytes = db.fs.read(FileId::Wal)?;
1910        let (records, valid_len) = decode_all(&bytes);
1911        // The valid prefix is replayed either way; `repair_wal` only decides
1912        // whether the truncation is written back. A reader that races a live
1913        // appender must not persist a truncation the writer never asked for.
1914        if valid_len < bytes.len() && repair_wal {
1915            db.fs.write_atomic(FileId::Wal, &bytes[..valid_len])?;
1916        }
1917        // WAL-present path: build indexes eagerly BEFORE replay so that the
1918        // first replayed record does not trigger the lazy-init guard (which
1919        // would call reindex_all_load_ivf on an empty graph, defeating the
1920        // point of restoring IVF/HNSW blobs from the snapshot).
1921        if !records.is_empty() {
1922            db.ensure_v8_base_sections_loaded();
1923            trace_open!("lazy sections loaded (WAL path)", _t0);
1924        }
1925        let replayed = db.apply_frames(records)?;
1926        // The cursor sits at the end of the valid prefix, not the end of the
1927        // file: a torn or still-being-written tail is unconsumed by definition
1928        // and stays visible to `is_stale` until it decodes.
1929        db.wal_consumed = valid_len as u64;
1930        db.snapshot_ident = db.fs.snapshot_ident().map_err(GraphError::Io)?;
1931        trace_open!("wal replay done", _t0);
1932        // Rebuild view values after WAL replay only when there is no V8 base.
1933        // With a V8 base, view values are correct in the snapshot and are updated
1934        // incrementally during WAL replay (on_edge_changed / on_prop_changed).
1935        // A full rebuild would read overlay-only props (empty after restore_v8_base)
1936        // and overwrite correct base values with wrong results (e.g. NeighborAgg
1937        // Sum reads no "score" in overlay → writes 0.0, shadowing the correct
1938        // base value).
1939        if db.base.is_none() {
1940            let topo_view = TopologyView::owned(&db.topo);
1941            db.view_store
1942                .rebuild_all(&mut db.props, &topo_view, &db.ids, &db.syms, &db.labels);
1943        }
1944        // Rebuild full-text index after WAL replay.  Corrects drift from
1945        // per-record incremental apply during replay.
1946        db.fulltext.rebuild_all(
1947            &db.ids,
1948            &db.labels,
1949            &db.syms,
1950            build_props_view(&db.props, &db.base),
1951        );
1952        db.prop_index.rebuild_all(
1953            &db.ids,
1954            &db.labels,
1955            &db.syms,
1956            build_props_view(&db.props, &db.base),
1957        );
1958        // Load roles sidecar. Missing file = no roles (Some(vec![])).
1959        // Corrupt/unparseable = poisoned (None); mask_for_role will fail-loud.
1960        db.roles = Self::load_roles_from_fs(&db.fs)?;
1961        // Capture the initial MVCC fold so reader() is ready immediately.
1962        db.fold_now();
1963        trace_open!("open_with complete", _t0);
1964        Ok(replayed)
1965    }
1966
1967    /// Apply decoded WAL frames to in-memory state, exactly as the open-path
1968    /// replay does — same `apply` calls, same per-frame delta drain, same
1969    /// commit-seq and last-change bookkeeping. Rules therefore fire and derived
1970    /// edges appear identically whether a frame arrives at open, from a local
1971    /// commit, or from another process by way of [`refresh`](GraphDb::refresh).
1972    ///
1973    /// Returns the number of frames applied.
1974    ///
1975    /// Deltas are drained and discarded per frame: replayed frames are already
1976    /// reflected on disk, so they are not news to a subscriber, and draining
1977    /// inside the loop keeps `pending_deltas` O(1) over a large WAL (I-2).
1978    fn apply_frames(&mut self, records: Vec<WalRecord>) -> Result<usize> {
1979        if records.is_empty() {
1980            return Ok(0);
1981        }
1982        // Materialize any state retained in the mmap base before the first
1983        // frame lands, so a replayed record cannot trip the lazy-init guard and
1984        // rebuild indexes from an empty graph. Both calls are idempotent.
1985        self.ensure_v8_base_sections_loaded();
1986        self.engine.consume_retained_state_eager(
1987            &self.ids,
1988            &self.syms,
1989            &self.labels,
1990            build_props_view(&self.props, &self.base),
1991        );
1992        let applied = records.len();
1993        for rec in records {
1994            self.apply(&rec)?;
1995            let _ = self.engine.drain_deltas();
1996            // Track commit_seq during replay so last_change entries are
1997            // consistent with the seqs assigned by log_then_apply_with on
1998            // subsequent live commits.  After N replayed frames, commit_seq=N;
1999            // live commits begin at N+1.
2000            self.commit_seq += 1;
2001            let replay_seq = self.commit_seq;
2002            self.update_last_change_from_rec(&rec, replay_seq);
2003        }
2004        // Enforce I-2: if the per-frame drain above is ever removed or skipped,
2005        // this assert catches the regression in debug builds immediately.
2006        debug_assert_eq!(
2007            self.engine.pending_delta_count(),
2008            0,
2009            "pending_deltas non-empty after replay — \
2010             per-frame drain must run inside the loop to keep memory O(1)"
2011        );
2012        // T2 note: the per-frame drain IS the suppression seam for replay.
2013        // Any future as-of replay path (Plan-15 T2) must drain here to feed
2014        // replaying subscribers; the mechanism is already in place.
2015        let _ = self.engine.drain_deltas(); // belt-and-braces no-op after loop drain
2016        Ok(applied)
2017    }
2018
2019    // ── Multi-process safety: cross-process write lock + WAL tailing ──────────
2020    //
2021    // mushroomdb is many-readers / one-writer across processes. Writers take an
2022    // advisory exclusive lock on the store's `LOCK` file; readers never do.
2023    // Every handle tracks how much of the WAL it has consumed, so it can pick
2024    // up another process's commits by decoding only the new tail rather than
2025    // reopening. See `docs/site/concurrency.md`.
2026
2027    /// Whether the store on disk has moved ahead of (or out from under) this
2028    /// handle's in-memory state.
2029    ///
2030    /// True when the WAL's length differs from this handle's cursor — another
2031    /// process committed, or is mid-append — or when the snapshot file's
2032    /// identity changed. Costs two metadata lookups and reads no file contents,
2033    /// so it is cheap enough for a read path to call.
2034    ///
2035    /// Always false for an as-of view from [`GraphDb::open_at`]: such a view is
2036    /// pinned to one commit and later commits are deliberately invisible to it.
2037    pub fn is_stale(&self) -> Result<bool> {
2038        if self.pinned {
2039            return Ok(false);
2040        }
2041        if self.fs.wal_len().map_err(GraphError::Io)? != self.wal_consumed {
2042            return Ok(true);
2043        }
2044        Ok(self.fs.snapshot_ident().map_err(GraphError::Io)? != self.snapshot_ident)
2045    }
2046
2047    /// Bring this handle up to date with every commit other processes have made,
2048    /// and return how many frames were applied.
2049    ///
2050    /// The WAL tail is decoded from this handle's cursor and applied through the
2051    /// same path the open replay uses, so rules fire and derived edges appear
2052    /// exactly as they would on a fresh open. Interners, id maps and indexes
2053    /// stay valid for the same reason.
2054    ///
2055    /// A frame another process is still writing is left alone: a trailing
2056    /// partial frame is a wait, not a corruption, and the handle stays stale
2057    /// until that frame is complete. Nothing is written to disk, so a read-only
2058    /// handle can refresh freely.
2059    ///
2060    /// When the snapshot file's identity changed, or the WAL is shorter than
2061    /// this handle's cursor, the WAL no longer continues our state — another
2062    /// process snapshotted or archived. The handle is then rebuilt from disk
2063    /// with the options it was opened with, and the return value is the number
2064    /// of frames in the new WAL.
2065    ///
2066    /// Returns 0 for an as-of view, which never follows later commits.
2067    ///
2068    /// # Errors
2069    ///
2070    /// An error here leaves the handle **degraded**: it got partway through
2071    /// applying the tail, or partway through a reload, so its in-memory state
2072    /// no longer matches any point on disk. Further mutations are refused and
2073    /// the handle must be reopened. Nothing on disk was damaged — the store
2074    /// itself is fine, and a fresh open recovers it.
2075    pub fn refresh(&mut self) -> Result<u64> {
2076        if self.pinned {
2077            return Ok(0);
2078        }
2079        let disk_ident = self.fs.snapshot_ident().map_err(GraphError::Io)?;
2080        let wal_len = self.fs.wal_len().map_err(GraphError::Io)?;
2081        if disk_ident != self.snapshot_ident || wal_len < self.wal_consumed {
2082            // The WAL no longer continues our state: rebuild from disk. State
2083            // is cleared first, so a failed load leaves an empty handle — mark
2084            // it degraded rather than let a caller read an empty graph as if
2085            // it were the store's contents.
2086            self.reset_for_reload();
2087            return match self.load_from_disk(LoadOrigin::Reload) {
2088                Ok(frames) => Ok(frames as u64),
2089                Err(e) => {
2090                    self.degraded = true;
2091                    Err(e)
2092                }
2093            };
2094        }
2095        if wal_len == self.wal_consumed {
2096            return Ok(0);
2097        }
2098        let tail = self
2099            .fs
2100            .read_range(FileId::Wal, self.wal_consumed)
2101            .map_err(GraphError::Io)?;
2102        let (records, valid_len) = decode_all(&tail);
2103        let applied = match self.apply_frames(records) {
2104            Ok(n) => n,
2105            Err(e) => {
2106                // Some frames landed and some did not, and the cursor cannot
2107                // say how many. Advancing it would skip the rest; leaving it
2108                // would replay what already applied. Neither is recoverable in
2109                // place, so refuse further writes and require a reopen.
2110                self.degraded = true;
2111                return Err(e);
2112            }
2113        };
2114        // Advance by the bytes actually decoded, never by the file length: an
2115        // incomplete trailing frame stays unconsumed for the next refresh.
2116        self.wal_consumed += valid_len as u64;
2117        if applied > 0 {
2118            // Peer commits must reach `reader()` snapshots taken from here on.
2119            // A full fold is what open does; refresh does not build per-commit
2120            // deltas, so there is nothing cheaper that stays correct.
2121            self.fold_now();
2122        }
2123        Ok(applied as u64)
2124    }
2125
2126    /// Byte offset of the WAL prefix this handle has applied.
2127    ///
2128    /// Exposed for tests that assert the cursor tracks appended bytes exactly.
2129    #[doc(hidden)]
2130    pub fn wal_consumed(&self) -> u64 {
2131        self.wal_consumed
2132    }
2133
2134    /// Rewind the WAL cursor after the group-commit drain thread truncated a
2135    /// failed group off the tail, so the cursor still describes the file.
2136    pub(crate) fn set_wal_consumed(&mut self, len: u64) {
2137        self.wal_consumed = len;
2138    }
2139
2140    /// One non-blocking attempt at the cross-process write lock.
2141    ///
2142    /// Takes `&self` so a caller can poll for the lock *before* it acquires the
2143    /// in-process write guard. That ordering is what keeps a busy peer in
2144    /// another process from stalling this process's readers.
2145    ///
2146    /// A handle that owns the lock for its lifetime always succeeds.
2147    pub(crate) fn try_cross_process_lock(&self) -> Result<bool> {
2148        if self.holds_lifetime_lock {
2149            return Ok(true);
2150        }
2151        self.fs.try_lock_exclusive().map_err(GraphError::Io)
2152    }
2153
2154    /// Poll for the cross-process write lock until `wait` elapses.
2155    ///
2156    /// One attempt is always made, so a zero wait is a single try. Returns
2157    /// `false` when the lock is still held elsewhere at the deadline; nothing
2158    /// has been written and retrying later is safe.
2159    ///
2160    /// Only the plain-`GraphDb` open path uses this, where the caller owns the
2161    /// handle outright. [`SharedDb`](crate::SharedDb) polls
2162    /// [`try_cross_process_lock`](GraphDb::try_cross_process_lock) itself so
2163    /// that it holds no in-process guard while it waits.
2164    fn poll_lock(&self, wait: std::time::Duration) -> Result<bool> {
2165        let deadline = std::time::Instant::now() + wait;
2166        loop {
2167            if self.try_cross_process_lock()? {
2168                return Ok(true);
2169            }
2170            let now = std::time::Instant::now();
2171            if now >= deadline {
2172                return Ok(false);
2173            }
2174            std::thread::sleep(LOCK_POLL_INTERVAL.min(deadline.saturating_duration_since(now)));
2175        }
2176    }
2177
2178    /// Open a cross-process write scope, given the outcome of an already-made
2179    /// lock attempt.
2180    ///
2181    /// The caller polls for the lock first — outside any in-process guard — and
2182    /// passes what it got. On success this refreshes, so the writes about to
2183    /// happen land on top of every other process's commits. On failure the
2184    /// handle refuses WAL-appending mutations and `snapshot()` with
2185    /// [`GraphError::Busy`] until [`end_write_lock`](GraphDb::end_write_lock)
2186    /// closes the scope, so a caller holding a guard cannot write behind
2187    /// another process's back.
2188    ///
2189    /// A handle that already owns the lock for its lifetime skips the refresh:
2190    /// no other process can have written, so there is nothing to pick up.
2191    pub(crate) fn enter_write_scope(&mut self, acquired: bool) -> Result<()> {
2192        self.lock_denied = !acquired;
2193        if !acquired || self.holds_lifetime_lock {
2194            return Ok(());
2195        }
2196        if let Err(e) = self.refresh() {
2197            // Do not hold a lock we cannot use: release it and let the caller
2198            // see the underlying failure.
2199            let _ = self.fs.unlock();
2200            self.lock_denied = true;
2201            return Err(e);
2202        }
2203        Ok(())
2204    }
2205
2206    /// Close a cross-process write scope opened by
2207    /// [`enter_write_scope`](GraphDb::enter_write_scope): release the lock and
2208    /// clear the Busy latch. Safe to call when the lock was never taken.
2209    pub(crate) fn end_write_lock(&mut self) {
2210        self.lock_denied = false;
2211        if !self.holds_lifetime_lock {
2212            // Releasing a lock we do not hold is a no-op; a failure to release
2213            // is reported by the OS closing the descriptor at handle drop.
2214            let _ = self.fs.unlock();
2215        }
2216    }
2217
2218    /// As-of replay for [`GraphDb::open_at`]: snapshot base (only when the
2219    /// snapshot truncated the WAL) plus the first `commit + 1` WAL frames;
2220    /// see [`GraphDb::open_at`] for the semantics.  The per-frame drain
2221    /// mirrors `open_with` exactly so pending_delta_count is 0 on exit.
2222    /// Restore all persisted state from a decoded snapshot. Shared by
2223    /// `open_with` and (when the snapshot truncated the WAL) `open_at_with`.
2224    fn restore_snapshot_state(
2225        &mut self,
2226        state: core_storage::snapshot::SnapshotState,
2227    ) -> Result<()> {
2228        self.ids = state.ids;
2229        self.syms = state.syms;
2230        self.topo = state.topo;
2231        self.props = state.props;
2232        self.labels = state.labels;
2233        self.edge_props = state.edge_props;
2234        // Cross-section label integrity for V5/V7 snapshots: same invariants as
2235        // restore_v8_base.  A crafted bincode snapshot with a short `labels` vec,
2236        // out-of-range sym ids, or a sentinel label on a live node would otherwise
2237        // open successfully and panic later in `NodeRef::label()` or
2238        // `neighborhood_masked()`.  Catching it here turns those into typed
2239        // `GraphError::Corrupt` at open time.
2240        {
2241            let ids_len = self.ids.len();
2242            if self.labels.len() != ids_len {
2243                return Err(GraphError::Corrupt {
2244                    detail: format!(
2245                        "snapshot: labels vec has {} entries but id table has {} total slots",
2246                        self.labels.len(),
2247                        ids_len,
2248                    ),
2249                });
2250            }
2251            let syms_len = self.syms.len() as u32;
2252            for (i, &sym) in self.labels.iter().enumerate() {
2253                let is_tombstoned = self.ids.is_tombstoned(i as u32);
2254                if sym == u32::MAX {
2255                    if !is_tombstoned {
2256                        return Err(GraphError::Corrupt {
2257                            detail: format!(
2258                                "snapshot: live node at id slot {i} has sentinel label (u32::MAX)"
2259                            ),
2260                        });
2261                    }
2262                } else if sym >= syms_len {
2263                    return Err(GraphError::Corrupt {
2264                        detail: format!(
2265                            "snapshot: label at id slot {i} references sym {sym} \
2266                             which is out of interner range ({syms_len})"
2267                        ),
2268                    });
2269                }
2270            }
2271        }
2272        let defs: Vec<RuleDef> = state
2273            .rule_defs
2274            .iter()
2275            .map(|b| {
2276                decode_rule_def(b).map_err(|e| GraphError::Corrupt {
2277                    detail: format!("snapshot rule_def deserialize: {e}"),
2278                })
2279            })
2280            .collect::<Result<Vec<_>>>()?;
2281        self.engine =
2282            RuleEngine::from_persist(defs, state.provenance, state.rule_tripped, state.rule_fires);
2283        // Candidate indexes are rebuilt lazily on the first mutation (see
2284        // RuleEngine::on_node_changed).  HNSW blobs and IVF centroids from the
2285        // snapshot are retained without deserializing so that:
2286        //   - clean-open (empty WAL): indexes stay empty; blobs load on first
2287        //     ANN query via ensure_hnsw_loaded, or on first mutation via the
2288        //     lazy-init guard which calls reindex_all_load_ivf + load_hnsw_state.
2289        //   - WAL-present: open_with calls consume_retained_state_eager before
2290        //     replay so HNSW/IVF are live before any record fires the hooks.
2291        let ivf_bytes = if state.ivf_state.is_empty() {
2292            Vec::new()
2293        } else {
2294            bincode::serialize(&state.ivf_state).expect("IVF state serialize cannot fail")
2295        };
2296        // Store blobs without eagerly deserializing them.
2297        self.engine
2298            .store_snapshot_state(state.hnsw_state, ivf_bytes);
2299        // Restore view defs from snapshot (V5).
2300        // The ColumnStore already contains view values from the snapshot;
2301        // use restore_view (no collision check, no backfill) so the store
2302        // is aware of the definitions.  rebuild_all runs after WAL replay.
2303        for def_bytes in &state.view_defs {
2304            let def: ViewDef =
2305                bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
2306                    detail: format!("snapshot view_def deserialize: {e}"),
2307                })?;
2308            self.view_store
2309                .restore_view(def)
2310                .map_err(|e| GraphError::Corrupt {
2311                    detail: format!("snapshot view restore: {e}"),
2312                })?;
2313        }
2314        Ok(())
2315    }
2316
2317    /// Restore all persisted state from a V8 `MappedBase` snapshot, **except**
2318    /// topology (`self.topo` stays empty and serves as the WAL-replay overlay).
2319    ///
2320    /// `self.props` IS fully materialised from the base so that HNSW/IVF blob
2321    /// deserialization and view rebuild have access to all column data.
2322    fn restore_v8_base(&mut self, mapped: Arc<core_storage::v8::MappedBase>) -> Result<()> {
2323        self.ids = archived_to_idmap(mapped.ids().map_err(|e| GraphError::Corrupt {
2324            detail: format!("v8: ids section: {e:?}"),
2325        })?);
2326        self.syms = archived_to_interner(mapped.syms().map_err(|e| GraphError::Corrupt {
2327            detail: format!("v8: syms section: {e:?}"),
2328        })?);
2329
2330        // C1: self.props is left as an empty overlay. Column reads go through
2331        // props_view() (ColumnsView::with_base), which consults the archived base
2332        // section zero-copy. This avoids the O(columns) heap copy at every open.
2333
2334        // self.topo deliberately left as Topology::new() — overlay path.
2335
2336        let meta = decode_meta(mapped.meta_bytes().map_err(|e| GraphError::Corrupt {
2337            detail: format!("v8: meta section: {e:?}"),
2338        })?)
2339        .map_err(|e| GraphError::Corrupt {
2340            detail: format!("v8: meta decode: {e:?}"),
2341        })?;
2342        self.labels = meta.labels;
2343        // Cross-section label integrity: labels must cover every id slot (live
2344        // and tombstoned), every non-sentinel sym must be within the interner's
2345        // bound, and no live (non-tombstoned) node may carry the u32::MAX
2346        // sentinel label.  Without this check, a crafted snapshot where the META
2347        // section (small, CRC-validated) holds a short `labels` vec, out-of-range
2348        // sym ids, or a sentinel label on a live node, would open successfully
2349        // and then panic in `NodeRef::label()`, `neighborhood_masked()`, and
2350        // related read paths.  Catching the inconsistency here converts those
2351        // panics into typed `GraphError::Corrupt` at open time.
2352        {
2353            let ids_len = self.ids.len();
2354            if self.labels.len() != ids_len {
2355                return Err(GraphError::Corrupt {
2356                    detail: format!(
2357                        "v8: labels section has {} entries but id table has {} total slots",
2358                        self.labels.len(),
2359                        ids_len,
2360                    ),
2361                });
2362            }
2363            let syms_len = self.syms.len() as u32;
2364            for (i, &sym) in self.labels.iter().enumerate() {
2365                let is_tombstoned = self.ids.is_tombstoned(i as u32);
2366                if sym == u32::MAX {
2367                    // Sentinel is only valid for tombstoned slots.
2368                    if !is_tombstoned {
2369                        return Err(GraphError::Corrupt {
2370                            detail: format!(
2371                                "v8: live node at id slot {i} has sentinel label (u32::MAX)"
2372                            ),
2373                        });
2374                    }
2375                } else if sym >= syms_len {
2376                    return Err(GraphError::Corrupt {
2377                        detail: format!(
2378                            "v8: label at id slot {i} references sym {sym} \
2379                             which is out of interner range ({syms_len})"
2380                        ),
2381                    });
2382                }
2383            }
2384        }
2385        // C3: self.edge_props stays as an empty overlay.  Reads go through
2386        // edge_props_view() which consults the mmap'd base section zero-copy
2387        // via EdgePropsView::with_base.  No heap decode at open time.
2388
2389        // Restore rule engine.
2390        let (rule_def_bytes, rule_tripped, rule_fires) =
2391            archived_rules_meta_to_owned(mapped.rules_meta_section().map_err(|e| {
2392                GraphError::Corrupt {
2393                    detail: format!("v8: rules_meta section: {e:?}"),
2394                }
2395            })?);
2396        let defs: Vec<RuleDef> = rule_def_bytes
2397            .iter()
2398            .map(|b| {
2399                decode_rule_def(b).map_err(|e| GraphError::Corrupt {
2400                    detail: format!("v8: rule_def deserialize: {e}"),
2401                })
2402            })
2403            .collect::<Result<Vec<_>>>()?;
2404        self.engine = RuleEngine::from_persist(defs, BTreeMap::new(), rule_tripped, rule_fires);
2405        // C4+C5: provenance, HNSW, and IVF sections are NOT read here.
2406        // `ensure_v8_base_sections_loaded` reads them on first use from
2407        // `self.base` (set by the caller immediately after this returns).
2408        // A clean open touches only: header + IDS + SYMS + META + RULES_META.
2409
2410        // Restore view definitions.
2411        let view_defs =
2412            archived_views_to_owned(mapped.views_section().map_err(|e| GraphError::Corrupt {
2413                detail: format!("v8: views section: {e:?}"),
2414            })?);
2415        for def_bytes in &view_defs {
2416            let def: ViewDef =
2417                bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
2418                    detail: format!("v8: view_def deserialize: {e}"),
2419                })?;
2420            self.view_store
2421                .restore_view(def)
2422                .map_err(|e| GraphError::Corrupt {
2423                    detail: format!("v8: view restore: {e}"),
2424                })?;
2425        }
2426        // Load the last-change map from section 11 (small section; load eagerly).
2427        // Pre-Task-3 snapshots lack this section; `last_change_bytes` returns &[]
2428        // in that case and `decode_last_change_bytes` returns an empty map.
2429        let last_change_raw = mapped
2430            .last_change_bytes()
2431            .map_err(|e| GraphError::Corrupt {
2432                detail: format!("v8: last_change section: {e:?}"),
2433            })?;
2434        self.last_change = decode_last_change_bytes(last_change_raw);
2435
2436        // Validate that all deferred sections (provenance, HNSW, IVF) fit within
2437        // the file.  Pure bounds check — no bytes read, no page faults triggered.
2438        // Catches truncated snapshots at open time before the lazy deferred reads.
2439        mapped.validate_section_bounds().map_err(|e| match e {
2440            GraphError::Corrupt { detail } => GraphError::Corrupt {
2441                detail: format!("v8: section bounds: {detail}"),
2442            },
2443            other => other,
2444        })?;
2445        Ok(())
2446    }
2447
2448    /// Read provenance, HNSW, and IVF sections from the mmap base into the
2449    /// engine's retained fields on first call.  Subsequent calls are a no-op
2450    /// (AtomicBool fast-path).
2451    ///
2452    /// Must be called before any code path that reads or mutates engine
2453    /// provenance, HNSW, or IVF state:
2454    /// - WAL replay (before `consume_retained_state_eager`)
2455    /// - First mutation (`log_then_apply_with`)
2456    /// - Read-only paths (`stats`, `explain`, `node_edges`)
2457    /// - Snapshot (`snapshot_with`)
2458    ///
2459    /// No-op for fresh stores and V5-V7 opens (`self.base` is `None`).
2460    fn ensure_v8_base_sections_loaded(&self) {
2461        use std::sync::atomic::Ordering;
2462        if self.v8_sections_loaded.load(Ordering::Acquire) {
2463            return;
2464        }
2465        let _guard = self
2466            .v8_sections_mutex
2467            .lock()
2468            .expect("v8 sections mutex poisoned");
2469        if self.v8_sections_loaded.load(Ordering::Acquire) {
2470            return; // another caller populated while we waited
2471        }
2472        let _t = std::time::Instant::now();
2473        if let Some(base) = &self.base {
2474            // Provenance: raw rkyv bytes; CRC validated inside section_bytes.
2475            // Bounds are already validated at open time (restore_v8_base →
2476            // validate_section_bounds) — unreachable post-validate_section_bounds;
2477            // unwrap_or_default is a safety belt against impossible errors.
2478            let prov_bytes = base
2479                .provenance_raw_bytes()
2480                .map(|b| b.to_vec())
2481                .unwrap_or_default();
2482            self.engine.store_provenance_bytes(prov_bytes);
2483            // HNSW: decode rkyv blobs into owned map.
2484            let hnsw_state = base
2485                .hnsw_section()
2486                .map(archived_hnsw_to_owned)
2487                .unwrap_or_default();
2488            // IVF: raw bincode bytes; deserialized on first mutation/query.
2489            let ivf_bytes = base.ivf_bytes().map(|b| b.to_vec()).unwrap_or_default();
2490            self.engine.store_snapshot_state(hnsw_state, ivf_bytes);
2491        }
2492        self.v8_sections_loaded.store(true, Ordering::Release);
2493        if std::env::var("MUSHROOMDB_TRACE_OPEN").is_ok() {
2494            eprintln!(
2495                "[MUSHROOMDB_TRACE_OPEN] ensure_v8_base_sections_loaded: {:>9.3?}",
2496                _t.elapsed()
2497            );
2498        }
2499    }
2500
2501    /// Return a `TopologyView` that merges the mmap'd base (when present) with
2502    /// the in-memory WAL overlay.  Used by all read paths in db.rs that need
2503    /// the full merged topology without going through `self.view()`.
2504    fn topo_view(&self) -> TopologyView<'_> {
2505        match self.base {
2506            None => TopologyView::owned(&self.topo),
2507            Some(ref base) => {
2508                // SAFETY: base lives as long as self; section bounds validated at open.
2509                // topology() uses access_unchecked; all field reads are bounds-checked in seam.rs.
2510                let archived = base
2511                    .topology()
2512                    .expect("base topology section bounds validated at open");
2513                TopologyView::with_base(&self.topo, archived)
2514            }
2515        }
2516    }
2517
2518    /// Return a `ColumnsView` that merges the mmap'd base columns (when a V8
2519    /// snapshot is open) with the in-memory WAL overlay.  Reads consult the
2520    /// overlay first, then fall through to the archived base section zero-copy.
2521    fn props_view(&self) -> core_storage::v8::seam::ColumnsView<'_> {
2522        match self.base {
2523            None => core_storage::v8::seam::ColumnsView::owned(&self.props),
2524            Some(ref base) => {
2525                // columns() uses access_unchecked; field reads are bounds-checked in seam.rs.
2526                let archived = base
2527                    .columns()
2528                    .expect("base columns section bounds validated at open");
2529                core_storage::v8::seam::ColumnsView::with_base_cached(
2530                    &self.props,
2531                    archived,
2532                    base.mixed_cache(),
2533                )
2534            }
2535        }
2536    }
2537
2538    /// Return an `EdgePropsView` that merges the mmap'd base edge-props section
2539    /// (when a V8 snapshot is open) with the in-memory WAL overlay.
2540    ///
2541    /// Reads consult the overlay first (for post-snapshot mutations), then fall
2542    /// through to the archived base section zero-copy.  Tombstones in the
2543    /// overlay mask deleted-from-base entries.
2544    fn edge_props_view(&self) -> EdgePropsView<'_> {
2545        match self.base {
2546            None => EdgePropsView::owned(&self.edge_props),
2547            Some(ref base) => {
2548                // edge_props_section() uses access_unchecked; field reads bounds-checked in seam.rs.
2549                let archived = base
2550                    .edge_props_section()
2551                    .expect("base edge_props section bounds validated at open");
2552                EdgePropsView::with_base(&self.edge_props, archived)
2553            }
2554        }
2555    }
2556
2557    fn open_at_with(fs: F, commit: u64) -> Result<Self> {
2558        // An as-of view never writes and is pinned to one commit: it takes no
2559        // cross-process lock and does not follow later commits.
2560        let mut db = Self::new_empty(
2561            fs,
2562            OpenOptions {
2563                repair_wal: false,
2564                auto_migrate: false,
2565                read_only: true,
2566            },
2567        );
2568        db.pinned = true; // read_only is set after replay, but pinning is immediate
2569        db.wal_horizon_floor = db.fs.read_horizon_floor()?;
2570        db.archive_genesis_chain = db.fs.has_genesis_marker();
2571        // Same orphaned-archive cleanup as open_with: floor was written first
2572        // during pruning, so a crash may have left stale archives below floor.
2573        db.cleanup_orphaned_archives()?;
2574        // Collect archive frames (oldest-first) and live WAL frames.
2575        // Archives represent pre-snapshot history; the snapshot captures the
2576        // cumulative state at the time of archiving.  Crash-window guarantee:
2577        //   A: crash before rename → WAL intact, no archive. Reopen: normal.
2578        //   B: crash after rename, before new WAL → archive present, WAL
2579        //      absent. Reopen: snapshot loaded (full state), no WAL replay.
2580        //   C: crash after new baseline WAL written → normal post-archive.
2581        let archive_ns = db.fs.list_archives()?;
2582        let mut archive_frames_all: Vec<WalRecord> = Vec::new();
2583        for n in &archive_ns {
2584            let arc_bytes = db.fs.read_archive(*n)?;
2585            let (arc_frames, _) = decode_all(&arc_bytes);
2586            archive_frames_all.extend(arc_frames);
2587        }
2588        let total_archive_frames = archive_frames_all.len() as u64;
2589
2590        let live_bytes = db.fs.read(FileId::Wal)?;
2591        let (live_records, _valid_len) = decode_all(&live_bytes);
2592        let total_surviving = total_archive_frames + live_records.len() as u64;
2593        // Global total including any pruned history below the horizon floor.
2594        let total = db.wal_horizon_floor + total_surviving;
2595
2596        // Horizon and range check.
2597        if commit < db.wal_horizon_floor {
2598            return Err(GraphError::CommitOutOfRange { commit, total });
2599        }
2600        if commit >= total {
2601            return Err(GraphError::CommitOutOfRange { commit, total });
2602        }
2603
2604        // Local index into surviving frames (0 = first frame of oldest archive).
2605        let local = commit - db.wal_horizon_floor;
2606
2607        if local < total_archive_frames {
2608            // Target commit is in an archive.  Correct replay from empty state
2609            // is only possible when the archive chain is an uninterrupted
2610            // genesis chain (first archive taken from a fresh store, no prior
2611            // WAL truncation) and no archives have been pruned (floor == 0).
2612            //
2613            // If either condition is violated the prefix needed to reconstruct
2614            // the requested state is gone; refuse rather than return wrong data.
2615            if db.wal_horizon_floor > 0 || !db.archive_genesis_chain {
2616                return Err(GraphError::CommitOutOfRange { commit, total });
2617            }
2618            // Replay all archive frames up to and including the target commit
2619            // from an empty database state.  Archives must be replayed in order
2620            // so that dense-id intern tables are built up correctly.
2621            for rec in archive_frames_all.into_iter().take((local + 1) as usize) {
2622                db.apply(&rec)?;
2623                let _ = db.engine.drain_deltas();
2624            }
2625        } else {
2626            // Target commit is in the live WAL: load snapshot as base, then
2627            // replay the needed live WAL prefix.
2628            //
2629            // Base state: a truncating snapshot (wal_truncated=true) compacts
2630            // all pre-truncation / pre-archive commits.  Dense-id records in
2631            // the live WAL reference ids/interns that the snapshot provides.
2632            // Peek 6 bytes (same pattern as open_with).
2633            let snap_header = db.fs.read_prefix(FileId::Snapshot, 6)?;
2634            let is_v8 = snap_header.len() >= 6
2635                && &snap_header[0..4] == b"GDB1"
2636                && u16::from_le_bytes([snap_header[4], snap_header[5]])
2637                    == core_storage::snapshot::VERSION_8;
2638            if is_v8 {
2639                let state = if let Some(snap_path) = db.fs.snapshot_path() {
2640                    let mapped = core_storage::v8::MappedBase::map(&snap_path).map_err(|e| {
2641                        GraphError::Corrupt {
2642                            detail: format!("v8: open_at mmap: {e:?}"),
2643                        }
2644                    })?;
2645                    core_storage::snapshot::decode_v8_from_mapped(&mapped)?
2646                } else {
2647                    let snap_bytes = db.fs.read(FileId::Snapshot)?;
2648                    core_storage::snapshot::decode(&snap_bytes)?
2649                };
2650                if let Some(state) = state {
2651                    if state.wal_truncated {
2652                        db.restore_snapshot_state(state)?;
2653                    }
2654                }
2655            } else if !snap_header.is_empty() {
2656                let snap_bytes = db.fs.read(FileId::Snapshot)?;
2657                if let Some(state) = core_storage::snapshot::decode(&snap_bytes)? {
2658                    if state.wal_truncated {
2659                        db.restore_snapshot_state(state)?;
2660                    }
2661                }
2662            }
2663            // else: snap_header empty = no snapshot file.
2664            let live_local = local - total_archive_frames;
2665            for rec in live_records.into_iter().take((live_local + 1) as usize) {
2666                db.apply(&rec)?;
2667                let _ = db.engine.drain_deltas();
2668            }
2669        }
2670        // Pin: pending_delta_count must be 0 after as-of replay, mirroring T1's
2671        // post-loop assert in open_with.
2672        debug_assert_eq!(
2673            db.engine.pending_delta_count(),
2674            0,
2675            "pending_deltas non-empty after open_at replay — \
2676             per-frame drain must run inside the loop to keep memory O(1)"
2677        );
2678        let _ = db.engine.drain_deltas(); // belt-and-braces no-op
2679                                          // Rebuild view values after WAL replay so derived-edge-driven views
2680                                          // reflect the as-of state.  open_at always uses the legacy path (no V8
2681                                          // base), so topo_view is always owned.
2682        {
2683            let topo_view = TopologyView::owned(&db.topo);
2684            db.view_store
2685                .rebuild_all(&mut db.props, &topo_view, &db.ids, &db.syms, &db.labels);
2686        }
2687        // Rebuild full-text index for as-of view (mirrors open_with pattern).
2688        db.fulltext.rebuild_all(
2689            &db.ids,
2690            &db.labels,
2691            &db.syms,
2692            build_props_view(&db.props, &db.base),
2693        );
2694        db.prop_index.rebuild_all(
2695            &db.ids,
2696            &db.labels,
2697            &db.syms,
2698            build_props_view(&db.props, &db.base),
2699        );
2700        // Load roles sidecar (current roles, not point-in-time).
2701        db.roles = Self::load_roles_from_fs(&db.fs)?;
2702        db.read_only = true;
2703        db.total_wal_commits = total;
2704        // Capture initial fold so reader() is immediately usable.
2705        db.fold_now();
2706        Ok(db)
2707    }
2708
2709    /// Whether this instance is a read-only as-of view.
2710    pub fn is_read_only(&self) -> bool {
2711        self.read_only
2712    }
2713
2714    // ── MVCC epoch reader ─────────────────────────────────────────────────────
2715
2716    /// Clone the current overlay state into a new `FrozenOverlay` and reset
2717    /// the delta tail. Called automatically every `FOLD_EVERY_K` commits and at
2718    /// the end of `open_with` / `open_at_with` to prime the reader.
2719    fn fold_now(&mut self) {
2720        let frozen = crate::reader::FrozenOverlay {
2721            ids: self.ids.clone(),
2722            syms: self.syms.clone(),
2723            topo: self.topo.clone(),
2724            props: self.props.clone(),
2725            labels: self.labels.clone(),
2726            edge_props: self.edge_props.clone(),
2727            roles: self.roles.clone(),
2728            fulltext: self.fulltext.clone(),
2729        };
2730        self.fold_overlay = Some(Arc::new(frozen));
2731        self.delta_tail.clear();
2732        self.commits_since_fold = 0;
2733    }
2734
2735    /// Capture a lock-free reader snapshot of the current db state.
2736    ///
2737    /// The read lock is held only for the duration of this call (to clone a
2738    /// handful of `Arc` handles). Subsequent query operations run without any
2739    /// lock.
2740    pub fn reader(&self) -> crate::reader::ReaderSnapshot {
2741        crate::reader::ReaderSnapshot::new(
2742            self.fold_overlay
2743                .clone()
2744                .expect("fold_overlay is always Some after open_with; call reader() after open"),
2745            self.base.clone(),
2746            self.delta_tail.clone(),
2747        )
2748    }
2749
2750    /// Total number of WAL commits at the time [`open_at`] was called.
2751    /// Returns 0 for normal (non-as-of) instances.
2752    pub fn total_wal_commits(&self) -> u64 {
2753        self.total_wal_commits
2754    }
2755
2756    /// Apply a record to in-memory state. Used by both live writes and replay,
2757    /// so replay is definitionally identical to the original execution.
2758    fn apply(&mut self, rec: &WalRecord) -> Result<()> {
2759        match rec {
2760            WalRecord::InsertNode { label, key, props } => {
2761                let id = self.ids.try_insert(key)?;
2762                let sym = self.syms.intern(label);
2763                if self.labels.len() <= id as usize {
2764                    // gap slots are sentinels, never valid label symbols
2765                    self.labels.resize(id as usize + 1, u32::MAX);
2766                }
2767                self.labels[id as usize] = sym;
2768                for (field, value) in props {
2769                    self.props.set(id, field, value.clone());
2770                }
2771                // Initialize view values for the new node before the engine runs so
2772                // delta-based increments start from a known zero baseline.
2773                self.view_store
2774                    .init_node_views(id, &mut self.props, &self.syms, &self.labels);
2775                // Fire rules for the newly inserted node.
2776                let cursor = self.engine.pending_delta_count();
2777                let mut eng = std::mem::take(&mut self.engine);
2778                {
2779                    let mut gm = make_graph_mut(
2780                        &self.ids,
2781                        &mut self.syms,
2782                        &self.labels,
2783                        build_props_view(&self.props, &self.base),
2784                        &mut self.topo,
2785                        &self.base,
2786                        &mut self.edge_props,
2787                    );
2788                    eng.on_node_changed(id, None, &mut gm);
2789                }
2790                self.engine = eng;
2791                // Process derived-edge deltas for view maintenance.
2792                // Fast path: skip the O(delta_count) allocation when no views exist.
2793                if !self.view_store.is_empty() {
2794                    #[cfg(test)]
2795                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2796                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2797                    for d in &new_deltas {
2798                        self.view_store.on_edge_changed(
2799                            d.etype_sym,
2800                            d.src_id,
2801                            d.dst_id,
2802                            d.fired,
2803                            &mut self.props,
2804                            &build_topo_view(&self.topo, &self.base),
2805                            &self.ids,
2806                            &self.syms,
2807                            &self.labels,
2808                            self.base.as_ref().map(|b| {
2809                                b.columns()
2810                                    .expect("base columns section bounds validated at open")
2811                            }),
2812                        );
2813                    }
2814                }
2815                // Full-text index maintenance: index enabled fields for this label.
2816                if self.fulltext.has_label(label) {
2817                    for (field, value) in props {
2818                        if self.fulltext.is_enabled(label, field) {
2819                            self.fulltext.add_tokens(id, field, value);
2820                        }
2821                    }
2822                }
2823                // Property (equality) index maintenance.
2824                if self.prop_index.has_label(label) {
2825                    for (field, value) in props {
2826                        self.prop_index.set(label, field, id, value);
2827                    }
2828                }
2829            }
2830            WalRecord::InsertEdge {
2831                edge_type,
2832                src_key,
2833                dst_key,
2834            } => {
2835                let src = self.ids.get(src_key).ok_or_else(|| GraphError::Corrupt {
2836                    detail: format!("wal replay references unknown key {src_key}"),
2837                })?;
2838                let dst = self.ids.get(dst_key).ok_or_else(|| GraphError::Corrupt {
2839                    detail: format!("wal replay references unknown key {dst_key}"),
2840                })?;
2841                let etype = self.syms.intern(edge_type);
2842                // Skip if the edge is already visible in the merged base+overlay
2843                // view.  This keeps WAL replay idempotent when the WAL contains
2844                // pre-snapshot records that are already encoded in a V8 base
2845                // (keep_wal=true opens and crash-before-truncation scenarios).
2846                if self.base.is_some()
2847                    && self
2848                        .topo_view()
2849                        .neighbors(etype, Direction::Out, src)
2850                        .contains(&dst)
2851                {
2852                    return Ok(());
2853                }
2854                self.topo.add_edge(etype, src, dst);
2855                // View maintenance for manual edge insert.
2856                self.view_store.on_edge_changed(
2857                    etype,
2858                    src,
2859                    dst,
2860                    true,
2861                    &mut self.props,
2862                    &build_topo_view(&self.topo, &self.base),
2863                    &self.ids,
2864                    &self.syms,
2865                    &self.labels,
2866                    self.base.as_ref().map(|b| {
2867                        b.columns()
2868                            .expect("base columns section bounds validated at open")
2869                    }),
2870                );
2871                // Rule engine: via-hop rules must update when user edges change.
2872                let cursor = self.engine.pending_delta_count();
2873                let mut eng = std::mem::take(&mut self.engine);
2874                {
2875                    let mut gm = make_graph_mut(
2876                        &self.ids,
2877                        &mut self.syms,
2878                        &self.labels,
2879                        build_props_view(&self.props, &self.base),
2880                        &mut self.topo,
2881                        &self.base,
2882                        &mut self.edge_props,
2883                    );
2884                    eng.on_edge_changed(edge_type, src, dst, &mut gm);
2885                }
2886                self.engine = eng;
2887                if !self.view_store.is_empty() {
2888                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2889                    for d in &new_deltas {
2890                        self.view_store.on_edge_changed(
2891                            d.etype_sym,
2892                            d.src_id,
2893                            d.dst_id,
2894                            d.fired,
2895                            &mut self.props,
2896                            &build_topo_view(&self.topo, &self.base),
2897                            &self.ids,
2898                            &self.syms,
2899                            &self.labels,
2900                            self.base.as_ref().map(|b| {
2901                                b.columns()
2902                                    .expect("base columns section bounds validated at open")
2903                            }),
2904                        );
2905                    }
2906                }
2907            }
2908            WalRecord::SetProp { key, field, value } => {
2909                let id = self.ids.get(key).ok_or_else(|| GraphError::Corrupt {
2910                    detail: format!("wal replay references unknown key {key}"),
2911                })?;
2912                let old_value = build_props_view(&self.props, &self.base)
2913                    .get(id, field)
2914                    .map(|vr| vr.into_value());
2915                self.props.set(id, field, value.clone());
2916                // Fire rules for the changed field.
2917                let cursor = self.engine.pending_delta_count();
2918                let mut eng = std::mem::take(&mut self.engine);
2919                {
2920                    let mut gm = make_graph_mut(
2921                        &self.ids,
2922                        &mut self.syms,
2923                        &self.labels,
2924                        build_props_view(&self.props, &self.base),
2925                        &mut self.topo,
2926                        &self.base,
2927                        &mut self.edge_props,
2928                    );
2929                    eng.on_node_changed(id, Some((field, old_value)), &mut gm);
2930                }
2931                self.engine = eng;
2932                // Derived-edge deltas → view updates.
2933                if !self.view_store.is_empty() {
2934                    #[cfg(test)]
2935                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2936                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2937                    for d in &new_deltas {
2938                        self.view_store.on_edge_changed(
2939                            d.etype_sym,
2940                            d.src_id,
2941                            d.dst_id,
2942                            d.fired,
2943                            &mut self.props,
2944                            &build_topo_view(&self.topo, &self.base),
2945                            &self.ids,
2946                            &self.syms,
2947                            &self.labels,
2948                            self.base.as_ref().map(|b| {
2949                                b.columns()
2950                                    .expect("base columns section bounds validated at open")
2951                            }),
2952                        );
2953                    }
2954                }
2955                // Neighbor-aggregate views that read `field` must also update.
2956                self.view_store.on_prop_changed(
2957                    id,
2958                    field,
2959                    &mut self.props,
2960                    &build_topo_view(&self.topo, &self.base),
2961                    &self.ids,
2962                    &self.syms,
2963                    &self.labels,
2964                    self.base.as_ref().map(|b| {
2965                        b.columns()
2966                            .expect("base columns section bounds validated at open")
2967                    }),
2968                );
2969                // Full-text index maintenance: update tokens for this field if indexed.
2970                if self.fulltext.field_indexed(field) {
2971                    let label_opt = self.labels.get(id as usize).and_then(|&sym| {
2972                        if sym == u32::MAX {
2973                            None
2974                        } else {
2975                            self.syms.resolve(sym)
2976                        }
2977                    });
2978                    if let Some(label) = label_opt {
2979                        if self.fulltext.is_enabled(label, field) {
2980                            self.fulltext.remove_node_field(id, field);
2981                            self.fulltext.add_tokens(id, field, value);
2982                        }
2983                    }
2984                }
2985                // Property (equality) index maintenance: re-key this node's value.
2986                if self.prop_index.field_indexed(field) {
2987                    let label_opt = self.labels.get(id as usize).and_then(|&sym| {
2988                        if sym == u32::MAX {
2989                            None
2990                        } else {
2991                            self.syms.resolve(sym)
2992                        }
2993                    });
2994                    if let Some(label) = label_opt {
2995                        self.prop_index.set(label, field, id, value);
2996                    }
2997                }
2998            }
2999            WalRecord::Intern { id, text } => {
3000                if let Some(existing) = self.syms.get(text) {
3001                    if existing != *id {
3002                        return Err(GraphError::Corrupt {
3003                            detail: format!(
3004                                "wal intern mismatch for {text:?}: have {existing}, record {id}"
3005                            ),
3006                        });
3007                    }
3008                } else {
3009                    let got = self.syms.intern(text);
3010                    if got != *id {
3011                        return Err(GraphError::Corrupt {
3012                            detail: format!(
3013                                "wal intern assigned {got} for {text:?}, record wanted {id}"
3014                            ),
3015                        });
3016                    }
3017                }
3018            }
3019            WalRecord::InsertNodeId { label, key, props } => {
3020                let id = self.ids.try_insert(key)?;
3021                if self.labels.len() <= id as usize {
3022                    self.labels.resize(id as usize + 1, u32::MAX);
3023                }
3024                self.labels[id as usize] = *label;
3025                let label_str = self
3026                    .syms
3027                    .resolve(*label)
3028                    .ok_or_else(|| GraphError::Corrupt {
3029                        detail: format!("wal InsertNodeId unknown label intern {label}"),
3030                    })?
3031                    .to_string();
3032                for (field_sym, value) in props {
3033                    let field =
3034                        self.syms
3035                            .resolve(*field_sym)
3036                            .ok_or_else(|| GraphError::Corrupt {
3037                                detail: format!(
3038                                    "wal InsertNodeId unknown field intern {field_sym}"
3039                                ),
3040                            })?;
3041                    self.props.set(id, field, value.clone());
3042                }
3043                self.view_store
3044                    .init_node_views(id, &mut self.props, &self.syms, &self.labels);
3045                let cursor = self.engine.pending_delta_count();
3046                let mut eng = std::mem::take(&mut self.engine);
3047                {
3048                    let mut gm = make_graph_mut(
3049                        &self.ids,
3050                        &mut self.syms,
3051                        &self.labels,
3052                        build_props_view(&self.props, &self.base),
3053                        &mut self.topo,
3054                        &self.base,
3055                        &mut self.edge_props,
3056                    );
3057                    eng.on_node_changed(id, None, &mut gm);
3058                }
3059                self.engine = eng;
3060                if !self.view_store.is_empty() {
3061                    #[cfg(test)]
3062                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3063                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3064                    for d in &new_deltas {
3065                        self.view_store.on_edge_changed(
3066                            d.etype_sym,
3067                            d.src_id,
3068                            d.dst_id,
3069                            d.fired,
3070                            &mut self.props,
3071                            &build_topo_view(&self.topo, &self.base),
3072                            &self.ids,
3073                            &self.syms,
3074                            &self.labels,
3075                            self.base.as_ref().map(|b| {
3076                                b.columns()
3077                                    .expect("base columns section bounds validated at open")
3078                            }),
3079                        );
3080                    }
3081                }
3082                if self.fulltext.has_label(&label_str) {
3083                    for (field_sym, value) in props {
3084                        let Some(field) = self.syms.resolve(*field_sym) else {
3085                            continue;
3086                        };
3087                        if self.fulltext.is_enabled(&label_str, field) {
3088                            self.fulltext.add_tokens(id, field, value);
3089                        }
3090                    }
3091                }
3092                if self.prop_index.has_label(&label_str) {
3093                    for (field_sym, value) in props {
3094                        let Some(field) = self.syms.resolve(*field_sym) else {
3095                            continue;
3096                        };
3097                        self.prop_index.set(&label_str, field, id, value);
3098                    }
3099                }
3100            }
3101            WalRecord::InsertEdgeId { etype, src, dst } => {
3102                // Replay-over-snapshot: dense ids in the pre-snapshot WAL may
3103                // already be tombstoned. Skip rather than attaching edges to
3104                // dead ids (DeleteNode keys the live re-insert, not the old id).
3105                if self.ids.is_tombstoned(*src)
3106                    || self.ids.is_tombstoned(*dst)
3107                    || self.ids.key_of(*src).is_none()
3108                    || self.ids.key_of(*dst).is_none()
3109                {
3110                    return Ok(());
3111                }
3112                // Skip if already visible in the merged view (same idempotency
3113                // guard as InsertEdge above: prevents double-counting when
3114                // pre-snapshot WAL records are replayed over a V8 base).
3115                if self.base.is_some()
3116                    && self
3117                        .topo_view()
3118                        .neighbors(*etype, Direction::Out, *src)
3119                        .contains(dst)
3120                {
3121                    return Ok(());
3122                }
3123                self.topo.add_edge(*etype, *src, *dst);
3124                self.view_store.on_edge_changed(
3125                    *etype,
3126                    *src,
3127                    *dst,
3128                    true,
3129                    &mut self.props,
3130                    &build_topo_view(&self.topo, &self.base),
3131                    &self.ids,
3132                    &self.syms,
3133                    &self.labels,
3134                    self.base.as_ref().map(|b| {
3135                        b.columns()
3136                            .expect("base columns section bounds validated at open")
3137                    }),
3138                );
3139                // Rule engine: via-hop rules fire when user via-edges are inserted.
3140                // Resolve etype back to string so on_edge_changed can match rules by name.
3141                if let Some(etype_str) = self.syms.resolve(*etype).map(|s| s.to_string()) {
3142                    let cursor = self.engine.pending_delta_count();
3143                    let mut eng = std::mem::take(&mut self.engine);
3144                    {
3145                        let mut gm = make_graph_mut(
3146                            &self.ids,
3147                            &mut self.syms,
3148                            &self.labels,
3149                            build_props_view(&self.props, &self.base),
3150                            &mut self.topo,
3151                            &self.base,
3152                            &mut self.edge_props,
3153                        );
3154                        eng.on_edge_changed(&etype_str, *src, *dst, &mut gm);
3155                    }
3156                    self.engine = eng;
3157                    if !self.view_store.is_empty() {
3158                        let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3159                        for d in &new_deltas {
3160                            self.view_store.on_edge_changed(
3161                                d.etype_sym,
3162                                d.src_id,
3163                                d.dst_id,
3164                                d.fired,
3165                                &mut self.props,
3166                                &build_topo_view(&self.topo, &self.base),
3167                                &self.ids,
3168                                &self.syms,
3169                                &self.labels,
3170                                self.base.as_ref().map(|b| {
3171                                    b.columns()
3172                                        .expect("base columns section bounds validated at open")
3173                                }),
3174                            );
3175                        }
3176                    }
3177                }
3178            }
3179            WalRecord::SetPropId { id, field, value } => {
3180                if self.ids.is_tombstoned(*id) || self.ids.key_of(*id).is_none() {
3181                    return Ok(());
3182                }
3183                let field_str = self
3184                    .syms
3185                    .resolve(*field)
3186                    .ok_or_else(|| GraphError::Corrupt {
3187                        detail: format!("wal SetPropId unknown field intern {field}"),
3188                    })?
3189                    .to_string();
3190                let old_value = build_props_view(&self.props, &self.base)
3191                    .get(*id, &field_str)
3192                    .map(|vr| vr.into_value());
3193                self.props.set(*id, &field_str, value.clone());
3194                let cursor = self.engine.pending_delta_count();
3195                let mut eng = std::mem::take(&mut self.engine);
3196                {
3197                    let mut gm = make_graph_mut(
3198                        &self.ids,
3199                        &mut self.syms,
3200                        &self.labels,
3201                        build_props_view(&self.props, &self.base),
3202                        &mut self.topo,
3203                        &self.base,
3204                        &mut self.edge_props,
3205                    );
3206                    eng.on_node_changed(*id, Some((field_str.as_str(), old_value)), &mut gm);
3207                }
3208                self.engine = eng;
3209                if !self.view_store.is_empty() {
3210                    #[cfg(test)]
3211                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3212                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3213                    for d in &new_deltas {
3214                        self.view_store.on_edge_changed(
3215                            d.etype_sym,
3216                            d.src_id,
3217                            d.dst_id,
3218                            d.fired,
3219                            &mut self.props,
3220                            &build_topo_view(&self.topo, &self.base),
3221                            &self.ids,
3222                            &self.syms,
3223                            &self.labels,
3224                            self.base.as_ref().map(|b| {
3225                                b.columns()
3226                                    .expect("base columns section bounds validated at open")
3227                            }),
3228                        );
3229                    }
3230                }
3231                self.view_store.on_prop_changed(
3232                    *id,
3233                    &field_str,
3234                    &mut self.props,
3235                    &build_topo_view(&self.topo, &self.base),
3236                    &self.ids,
3237                    &self.syms,
3238                    &self.labels,
3239                    self.base.as_ref().map(|b| {
3240                        b.columns()
3241                            .expect("base columns section bounds validated at open")
3242                    }),
3243                );
3244                if self.fulltext.field_indexed(&field_str) {
3245                    let label_opt = self.labels.get(*id as usize).and_then(|&sym| {
3246                        if sym == u32::MAX {
3247                            None
3248                        } else {
3249                            self.syms.resolve(sym)
3250                        }
3251                    });
3252                    if let Some(label) = label_opt {
3253                        if self.fulltext.is_enabled(label, &field_str) {
3254                            self.fulltext.remove_node_field(*id, &field_str);
3255                            self.fulltext.add_tokens(*id, &field_str, value);
3256                        }
3257                    }
3258                }
3259                if self.prop_index.field_indexed(&field_str) {
3260                    let label_opt = self.labels.get(*id as usize).and_then(|&sym| {
3261                        if sym == u32::MAX {
3262                            None
3263                        } else {
3264                            self.syms.resolve(sym)
3265                        }
3266                    });
3267                    if let Some(label) = label_opt {
3268                        self.prop_index.set(label, &field_str, *id, value);
3269                    }
3270                }
3271            }
3272            WalRecord::CreateRule { def_bytes } => {
3273                let def: RuleDef = decode_rule_def(def_bytes).map_err(|e| GraphError::Corrupt {
3274                    detail: format!("CreateRule def_bytes deserialize failed: {e}"),
3275                })?;
3276                // Replay-over-snapshot idempotency: the rule was captured in the snapshot
3277                // so the engine already has it; silently skip to avoid a spurious
3278                // RuleInvalid error in the crash window between snapshot write and WAL
3279                // truncation.
3280                if self.engine.rules().any(|r| r.name == def.name) {
3281                    return Ok(());
3282                }
3283                let cursor = self.engine.pending_delta_count();
3284                let mut eng = std::mem::take(&mut self.engine);
3285                let result = {
3286                    let mut gm = make_graph_mut(
3287                        &self.ids,
3288                        &mut self.syms,
3289                        &self.labels,
3290                        build_props_view(&self.props, &self.base),
3291                        &mut self.topo,
3292                        &self.base,
3293                        &mut self.edge_props,
3294                    );
3295                    eng.create_rule(def, &mut gm)
3296                };
3297                self.engine = eng;
3298                result.map_err(|e| GraphError::RuleInvalid { detail: e })?;
3299                // Derived-edge fires from backfill → view updates.
3300                // Fast path: skip O(edge_count) allocation when no views exist.
3301                if !self.view_store.is_empty() {
3302                    #[cfg(test)]
3303                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3304                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3305                    for d in &new_deltas {
3306                        self.view_store.on_edge_changed(
3307                            d.etype_sym,
3308                            d.src_id,
3309                            d.dst_id,
3310                            d.fired,
3311                            &mut self.props,
3312                            &build_topo_view(&self.topo, &self.base),
3313                            &self.ids,
3314                            &self.syms,
3315                            &self.labels,
3316                            self.base.as_ref().map(|b| {
3317                                b.columns()
3318                                    .expect("base columns section bounds validated at open")
3319                            }),
3320                        );
3321                    }
3322                }
3323            }
3324            WalRecord::DeleteRule { name } => {
3325                // Replay-over-snapshot idempotency: the snapshot already captured the
3326                // post-delete state so the rule is absent; silently skip to avoid a
3327                // spurious RuleNotFound error in the crash window between snapshot write
3328                // and WAL truncation.
3329                if !self.engine.rules().any(|r| r.name == *name) {
3330                    return Ok(());
3331                }
3332                let cursor = self.engine.pending_delta_count();
3333                let mut eng = std::mem::take(&mut self.engine);
3334                let result = {
3335                    let mut gm = make_graph_mut(
3336                        &self.ids,
3337                        &mut self.syms,
3338                        &self.labels,
3339                        build_props_view(&self.props, &self.base),
3340                        &mut self.topo,
3341                        &self.base,
3342                        &mut self.edge_props,
3343                    );
3344                    eng.delete_rule(name, &mut gm)
3345                };
3346                self.engine = eng;
3347                result.map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
3348                // Derived-edge retractions → view updates.
3349                if !self.view_store.is_empty() {
3350                    #[cfg(test)]
3351                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3352                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3353                    for d in &new_deltas {
3354                        self.view_store.on_edge_changed(
3355                            d.etype_sym,
3356                            d.src_id,
3357                            d.dst_id,
3358                            d.fired,
3359                            &mut self.props,
3360                            &build_topo_view(&self.topo, &self.base),
3361                            &self.ids,
3362                            &self.syms,
3363                            &self.labels,
3364                            self.base.as_ref().map(|b| {
3365                                b.columns()
3366                                    .expect("base columns section bounds validated at open")
3367                            }),
3368                        );
3369                    }
3370                }
3371            }
3372            WalRecord::RemoveProp { key, field } => {
3373                // Recovery-safe: unknown key or already-absent field is a
3374                // clean no-op. Crash-window replay over a snapshot that
3375                // already applied this record must not Err.
3376                let Some(id) = self.ids.get(key) else {
3377                    return Ok(());
3378                };
3379                // Read old value through the seam for rule retraction.
3380                let old = build_props_view(&self.props, &self.base)
3381                    .get(id, field)
3382                    .map(|vr| vr.into_value());
3383                self.props.remove(id, field);
3384                // If the base still supplies the value after the overlay removal,
3385                // record a tombstone so ColumnsView::get does not resurrect it.
3386                // This covers both the base-only case AND the both-resident case:
3387                //   base-only (in_overlay=false): old prop was only in base, remove
3388                //     is a no-op on overlay, base still visible → tombstone needed.
3389                //   both-resident (in_overlay=true): overlay had v2, base has v1;
3390                //     removing overlay uncovers v1 → tombstone needed.
3391                // Idempotent on double-replay: second pass sees the tombstone →
3392                // get() returns None → condition is false → no duplicate tombstone.
3393                if build_props_view(&self.props, &self.base)
3394                    .get(id, field)
3395                    .is_some()
3396                {
3397                    self.props.record_prop_tombstone(id, field);
3398                }
3399                let cursor = self.engine.pending_delta_count();
3400                let mut eng = std::mem::take(&mut self.engine);
3401                {
3402                    let mut gm = make_graph_mut(
3403                        &self.ids,
3404                        &mut self.syms,
3405                        &self.labels,
3406                        build_props_view(&self.props, &self.base),
3407                        &mut self.topo,
3408                        &self.base,
3409                        &mut self.edge_props,
3410                    );
3411                    eng.on_node_changed(id, Some((field, old)), &mut gm);
3412                }
3413                self.engine = eng;
3414                // Derived-edge deltas → view updates.
3415                if !self.view_store.is_empty() {
3416                    #[cfg(test)]
3417                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3418                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3419                    for d in &new_deltas {
3420                        self.view_store.on_edge_changed(
3421                            d.etype_sym,
3422                            d.src_id,
3423                            d.dst_id,
3424                            d.fired,
3425                            &mut self.props,
3426                            &build_topo_view(&self.topo, &self.base),
3427                            &self.ids,
3428                            &self.syms,
3429                            &self.labels,
3430                            self.base.as_ref().map(|b| {
3431                                b.columns()
3432                                    .expect("base columns section bounds validated at open")
3433                            }),
3434                        );
3435                    }
3436                }
3437                // Neighbor-aggregate views that read `field` must also update.
3438                self.view_store.on_prop_changed(
3439                    id,
3440                    field,
3441                    &mut self.props,
3442                    &build_topo_view(&self.topo, &self.base),
3443                    &self.ids,
3444                    &self.syms,
3445                    &self.labels,
3446                    self.base.as_ref().map(|b| {
3447                        b.columns()
3448                            .expect("base columns section bounds validated at open")
3449                    }),
3450                );
3451                // Full-text index maintenance: remove tokens for this field.
3452                if self.fulltext.field_indexed(field) {
3453                    self.fulltext.remove_node_field(id, field);
3454                }
3455                // Property (equality) index maintenance: drop this node's entry.
3456                if self.prop_index.field_indexed(field) {
3457                    if let Some(label) = self.labels.get(id as usize).and_then(|&sym| {
3458                        (sym != u32::MAX).then(|| self.syms.resolve(sym)).flatten()
3459                    }) {
3460                        self.prop_index.remove_node(label, field, id);
3461                    }
3462                }
3463            }
3464            WalRecord::DeleteEdge {
3465                edge_type,
3466                src_key,
3467                dst_key,
3468            } => {
3469                // Recovery-safe: unknown keys, unknown etype, or already-
3470                // absent edge is a clean no-op (remove_edge returns false).
3471                let Some(src) = self.ids.get(src_key) else {
3472                    return Ok(());
3473                };
3474                let Some(dst) = self.ids.get(dst_key) else {
3475                    return Ok(());
3476                };
3477                let Some(etype) = self.syms.get(edge_type) else {
3478                    return Ok(());
3479                };
3480                // I3: phantom-tombstone guard.  When a V8 base is present, a
3481                // DeleteEdge WAL record for an edge that was already absorbed into
3482                // the new base (i.e. neither in overlay nor in base) must be skipped.
3483                // Without this guard, remove_edge records a tombstone for an edge
3484                // that no longer exists, incorrectly understating edge_count.
3485                if self.base.is_some()
3486                    && !self
3487                        .topo_view()
3488                        .neighbors(etype, core_storage::topology::Direction::Out, src)
3489                        .contains(&dst)
3490                {
3491                    return Ok(());
3492                }
3493                self.topo.remove_edge(etype, src, dst);
3494                self.edge_props.remove_edge(etype, src, dst);
3495                // View maintenance for manual edge delete (topo already updated above).
3496                self.view_store.on_edge_changed(
3497                    etype,
3498                    src,
3499                    dst,
3500                    false,
3501                    &mut self.props,
3502                    &build_topo_view(&self.topo, &self.base),
3503                    &self.ids,
3504                    &self.syms,
3505                    &self.labels,
3506                    self.base.as_ref().map(|b| {
3507                        b.columns()
3508                            .expect("base columns section bounds validated at open")
3509                    }),
3510                );
3511                // Rule engine: via-hop rules must retract when user via-edges are deleted.
3512                let cursor = self.engine.pending_delta_count();
3513                let mut eng = std::mem::take(&mut self.engine);
3514                {
3515                    let mut gm = make_graph_mut(
3516                        &self.ids,
3517                        &mut self.syms,
3518                        &self.labels,
3519                        build_props_view(&self.props, &self.base),
3520                        &mut self.topo,
3521                        &self.base,
3522                        &mut self.edge_props,
3523                    );
3524                    eng.on_edge_changed(edge_type, src, dst, &mut gm);
3525                }
3526                self.engine = eng;
3527                if !self.view_store.is_empty() {
3528                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3529                    for d in &new_deltas {
3530                        self.view_store.on_edge_changed(
3531                            d.etype_sym,
3532                            d.src_id,
3533                            d.dst_id,
3534                            d.fired,
3535                            &mut self.props,
3536                            &build_topo_view(&self.topo, &self.base),
3537                            &self.ids,
3538                            &self.syms,
3539                            &self.labels,
3540                            self.base.as_ref().map(|b| {
3541                                b.columns()
3542                                    .expect("base columns section bounds validated at open")
3543                            }),
3544                        );
3545                    }
3546                }
3547            }
3548            WalRecord::DeleteNode { key } => {
3549                // Recovery-safe: already-tombstoned / unknown key is a clean
3550                // no-op. Crash-window replay over a snapshot that already
3551                // applied this record cannot recover the retired id from the
3552                // key (`IdMap::get` is None), so every subsequent step is
3553                // skipped. Each step is independently idempotent if invoked
3554                // twice on a still-live id: retraction is a no-op on empty
3555                // provenance, `remove_edge` returns false, `remove_all` is a
3556                // no-op, `ids.delete` returns None, label sentinel is sticky.
3557                let Some(n) = self.ids.get(key) else {
3558                    return Ok(());
3559                };
3560
3561                // (1) Retract derived edges + de-index while props/labels live.
3562                let cursor = self.engine.pending_delta_count();
3563                let mut eng = std::mem::take(&mut self.engine);
3564                {
3565                    let mut gm = make_graph_mut(
3566                        &self.ids,
3567                        &mut self.syms,
3568                        &self.labels,
3569                        build_props_view(&self.props, &self.base),
3570                        &mut self.topo,
3571                        &self.base,
3572                        &mut self.edge_props,
3573                    );
3574                    eng.on_node_removed(n, &mut gm);
3575                }
3576                self.engine = eng;
3577                // Derived-edge retractions → view updates for neighbors.
3578                if !self.view_store.is_empty() {
3579                    #[cfg(test)]
3580                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3581                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3582                    for d in &new_deltas {
3583                        self.view_store.on_edge_changed(
3584                            d.etype_sym,
3585                            d.src_id,
3586                            d.dst_id,
3587                            d.fired,
3588                            &mut self.props,
3589                            &build_topo_view(&self.topo, &self.base),
3590                            &self.ids,
3591                            &self.syms,
3592                            &self.labels,
3593                            self.base.as_ref().map(|b| {
3594                                b.columns()
3595                                    .expect("base columns section bounds validated at open")
3596                            }),
3597                        );
3598                    }
3599                }
3600
3601                // (2) Sweep ALL remaining edges incident to n, both directions,
3602                // every etype. This cascade is intentionally mask-independent:
3603                // topology integrity requires removing every edge touching the
3604                // deleted node regardless of the caller's visibility scope.
3605                // (The mask limits which nodes a role's read phase can return;
3606                // the WAL delete always executes with full storage authority.)
3607                // Collect then remove so neighbor slices stay valid during
3608                // iteration. Remove from topo first, then call view maintenance
3609                // so Avg/Min/Max recompute sees the correct (reduced) neighbor set.
3610                let etypes: Vec<u32> = self.topo.etypes().collect();
3611                let mut doomed = Vec::new();
3612                for et in &etypes {
3613                    for &dst in self.topo.neighbors(*et, Direction::Out, n).as_ref() {
3614                        doomed.push((*et, n, dst));
3615                    }
3616                    for &src in self.topo.neighbors(*et, Direction::In, n).as_ref() {
3617                        doomed.push((*et, src, n));
3618                    }
3619                }
3620                for (et, s, d) in doomed {
3621                    self.topo.remove_edge(et, s, d);
3622                    self.edge_props.remove_edge(et, s, d);
3623                    // View maintenance: n's own view values will be cleared by
3624                    // remove_all below; only update surviving neighbors.
3625                    self.view_store.on_edge_changed(
3626                        et,
3627                        s,
3628                        d,
3629                        false,
3630                        &mut self.props,
3631                        &build_topo_view(&self.topo, &self.base),
3632                        &self.ids,
3633                        &self.syms,
3634                        &self.labels,
3635                        self.base.as_ref().map(|b| {
3636                            b.columns()
3637                                .expect("base columns section bounds validated at open")
3638                        }),
3639                    );
3640                }
3641
3642                // (3) Drop every remaining prop (`ColumnStore::remove_all`).
3643                self.props.remove_all(n);
3644                // Full-text index maintenance: remove all tokens for this node.
3645                self.fulltext.remove_node(n);
3646                // Property (equality) index maintenance: drop all entries for n.
3647                self.prop_index.remove_node_all(n);
3648
3649                // (4) Retire the dense id and stamp the label sentinel.
3650                self.ids.delete(key);
3651                if let Some(slot) = self.labels.get_mut(n as usize) {
3652                    *slot = u32::MAX;
3653                }
3654            }
3655            WalRecord::Batch(inner) => {
3656                // Apply each inner record in order through the same apply path.
3657                // Inner records are validated free of nested Batch by encode_record.
3658                for rec in inner {
3659                    self.apply(rec)?;
3660                }
3661            }
3662            WalRecord::RebuildRule { name } => {
3663                // Replay-over-snapshot idempotency: the snapshot may already
3664                // reflect a later delete_rule, so the rule is absent; skip.
3665                if !self.engine.rules().any(|r| r.name == *name) {
3666                    return Ok(());
3667                }
3668                let cursor = self.engine.pending_delta_count();
3669                let mut eng = std::mem::take(&mut self.engine);
3670                let result = {
3671                    let mut gm = make_graph_mut(
3672                        &self.ids,
3673                        &mut self.syms,
3674                        &self.labels,
3675                        build_props_view(&self.props, &self.base),
3676                        &mut self.topo,
3677                        &self.base,
3678                        &mut self.edge_props,
3679                    );
3680                    eng.rebuild(name, &mut gm)
3681                };
3682                self.engine = eng;
3683                result.map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
3684                // Derived-edge delta changes → view updates.
3685                if !self.view_store.is_empty() {
3686                    #[cfg(test)]
3687                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3688                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3689                    for d in &new_deltas {
3690                        self.view_store.on_edge_changed(
3691                            d.etype_sym,
3692                            d.src_id,
3693                            d.dst_id,
3694                            d.fired,
3695                            &mut self.props,
3696                            &build_topo_view(&self.topo, &self.base),
3697                            &self.ids,
3698                            &self.syms,
3699                            &self.labels,
3700                            self.base.as_ref().map(|b| {
3701                                b.columns()
3702                                    .expect("base columns section bounds validated at open")
3703                            }),
3704                        );
3705                    }
3706                }
3707            }
3708            WalRecord::CreateView { def_bytes } => {
3709                let def: ViewDef =
3710                    bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
3711                        detail: format!("CreateView def_bytes deserialize failed: {e}"),
3712                    })?;
3713                // Replay-over-snapshot idempotency: view already present → skip.
3714                if self.view_store.has_view(&def.name) {
3715                    return Ok(());
3716                }
3717                self.view_store
3718                    .create_view(
3719                        def,
3720                        &mut self.props,
3721                        &build_topo_view(&self.topo, &self.base),
3722                        &self.ids,
3723                        &self.syms,
3724                        &self.labels,
3725                    )
3726                    .map_err(|e| GraphError::RuleInvalid { detail: e })?;
3727            }
3728            WalRecord::DeleteView { name } => {
3729                // Replay-over-snapshot idempotency: view already absent → skip.
3730                if !self.view_store.has_view(name) {
3731                    return Ok(());
3732                }
3733                self.view_store
3734                    .delete_view(name, &mut self.props, &self.ids, &self.labels, &self.syms)
3735                    .map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
3736            }
3737            WalRecord::EnableFulltext { label, field } => {
3738                // Replay-over-snapshot idempotency: already enabled → skip.
3739                if self.fulltext.is_enabled(label, field) {
3740                    return Ok(());
3741                }
3742                self.fulltext.enable(label, field);
3743                // Backfill: index all live nodes of this label that have the field.
3744                let n = self.ids.len() as u32;
3745                for id in 0..n {
3746                    let Some(&sym) = self.labels.get(id as usize) else {
3747                        continue;
3748                    };
3749                    if sym == u32::MAX {
3750                        continue; // tombstoned
3751                    }
3752                    let Some(lbl) = self.syms.resolve(sym) else {
3753                        continue;
3754                    };
3755                    if lbl != label {
3756                        continue;
3757                    }
3758                    if let Some(value) = build_props_view(&self.props, &self.base)
3759                        .get(id, field)
3760                        .map(|vr| vr.into_value())
3761                    {
3762                        self.fulltext.add_tokens(id, field, &value);
3763                    }
3764                }
3765            }
3766            WalRecord::DisableFulltext { label, field } => {
3767                // Replay-over-snapshot idempotency: already disabled → skip.
3768                if !self.fulltext.is_enabled(label, field) {
3769                    return Ok(());
3770                }
3771                // If another label still indexes this field, the postings column
3772                // is kept — but it must not contain node_ids from the now-disabled
3773                // label.  Remove them before calling disable() so the field_indexed
3774                // guard inside disable() sees the correct post-removal state.
3775                if self.fulltext.field_indexed_by_other(label, field) {
3776                    if let Some(label_sym) = self.syms.get(label) {
3777                        for (node_id, &lsym) in self.labels.iter().enumerate() {
3778                            if lsym == label_sym {
3779                                self.fulltext.remove_node_field(node_id as u32, field);
3780                            }
3781                        }
3782                    }
3783                }
3784                self.fulltext.disable(label, field);
3785            }
3786            WalRecord::EnableIndex { label, field } => {
3787                // Replay-over-snapshot idempotency: already enabled → skip.
3788                if self.prop_index.is_enabled(label, field) {
3789                    return Ok(());
3790                }
3791                self.prop_index.enable(label, field);
3792                // Backfill: index all live nodes of this label that have the field.
3793                let n = self.ids.len() as u32;
3794                for id in 0..n {
3795                    let Some(&sym) = self.labels.get(id as usize) else {
3796                        continue;
3797                    };
3798                    if sym == u32::MAX {
3799                        continue; // tombstoned
3800                    }
3801                    let Some(lbl) = self.syms.resolve(sym) else {
3802                        continue;
3803                    };
3804                    if lbl != label {
3805                        continue;
3806                    }
3807                    if let Some(value) = build_props_view(&self.props, &self.base)
3808                        .get(id, field)
3809                        .map(|vr| vr.into_value())
3810                    {
3811                        self.prop_index.set(label, field, id, &value);
3812                    }
3813                }
3814            }
3815            WalRecord::DisableIndex { label, field } => {
3816                self.prop_index.disable(label, field);
3817            }
3818            // History markers carry no replay state — rules re-derive edges
3819            // deterministically on open/replay. Skip unconditionally.
3820            WalRecord::DerivedEdgeAdded { .. } | WalRecord::DerivedEdgeRetracted { .. } => {}
3821            // ── rename_node ──────────────────────────────────────────────────
3822            WalRecord::RenameNode { old_key, new_key } => {
3823                // Recovery-safe: if old_key is already gone (key was renamed
3824                // by a snapshot or a prior replay frame), skip cleanly.
3825                if self.ids.get(old_key).is_none() {
3826                    return Ok(());
3827                }
3828                // The rename only updates the key-table; the dense id, all
3829                // topo edges, props, labels, and rule state are id-indexed and
3830                // require no change.
3831                self.ids
3832                    .rename(old_key, new_key)
3833                    .map_err(|e| GraphError::Corrupt {
3834                        detail: format!("wal replay RenameNode {old_key}→{new_key}: {e}"),
3835                    })?;
3836            }
3837        }
3838        Ok(())
3839    }
3840
3841    /// Intern `s` in `syms` and emit a WAL `Intern` record so `*Id` records
3842    /// replay on WAL-only `open_at` (no snapshot intern table). Apply is
3843    /// idempotent when the string is already bound. Always emit: after
3844    /// `snapshot()` the WAL is truncated and live intern is not on disk.
3845    fn intern_wal(&mut self, s: &str) -> (u32, WalRecord) {
3846        let id = if let Some(id) = self.syms.get(s) {
3847            id
3848        } else {
3849            self.syms.intern(s)
3850        };
3851        (
3852            id,
3853            WalRecord::Intern {
3854                id,
3855                text: s.to_string(),
3856            },
3857        )
3858    }
3859
3860    /// Rewrite user-facing records into dense-id records. On `Err`, no live
3861    /// state is left mutated: speculative interns made while building the
3862    /// output are rolled back, so a later successful mutation cannot log an
3863    /// `Intern` record whose id replay would never reproduce.
3864    fn rewrite_wal_dense(&mut self, recs: Vec<WalRecord>) -> Result<Vec<WalRecord>> {
3865        let syms_checkpoint = self.syms.len();
3866        let result = self.rewrite_wal_dense_inner(recs);
3867        if result.is_err() {
3868            self.syms.truncate(syms_checkpoint);
3869        }
3870        result
3871    }
3872
3873    fn rewrite_wal_dense_inner(&mut self, recs: Vec<WalRecord>) -> Result<Vec<WalRecord>> {
3874        let mut out = Vec::with_capacity(recs.len());
3875        // Node ids allocated by later apply(InsertNodeId) in this same batch.
3876        let mut pending: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
3877        let mut interned = std::collections::HashSet::<u32>::new();
3878        let mut next = u32::try_from(self.ids.len()).map_err(|_| GraphError::Corrupt {
3879            detail: "id space exhausted".into(),
3880        })?;
3881        let lookup = |ids: &IdMap,
3882                      pending: &std::collections::HashMap<String, u32>,
3883                      key: &str|
3884         -> Option<u32> { ids.get(key).or_else(|| pending.get(key).copied()) };
3885        for rec in recs {
3886            match rec {
3887                WalRecord::InsertNode { label, key, props } => {
3888                    let (label_id, intern) = self.intern_wal(&label);
3889                    if interned.insert(label_id) {
3890                        out.push(intern);
3891                    }
3892                    let mut props_id = Vec::with_capacity(props.len());
3893                    for (field, value) in props {
3894                        let (field_id, intern) = self.intern_wal(&field);
3895                        if interned.insert(field_id) {
3896                            out.push(intern);
3897                        }
3898                        props_id.push((field_id, value));
3899                    }
3900                    if lookup(&self.ids, &pending, &key).is_none() {
3901                        pending.insert(key.clone(), next);
3902                        next = next.checked_add(1).ok_or_else(|| GraphError::Corrupt {
3903                            detail: "id space exhausted".into(),
3904                        })?;
3905                    }
3906                    out.push(WalRecord::InsertNodeId {
3907                        label: label_id,
3908                        key,
3909                        props: props_id,
3910                    });
3911                }
3912                WalRecord::SetProp { key, field, value } => {
3913                    let id =
3914                        lookup(&self.ids, &pending, &key).ok_or_else(|| GraphError::Corrupt {
3915                            detail: format!("dense WAL rewrite missing key {key}"),
3916                        })?;
3917                    let (field_id, intern) = self.intern_wal(&field);
3918                    if interned.insert(field_id) {
3919                        out.push(intern);
3920                    }
3921                    out.push(WalRecord::SetPropId {
3922                        id,
3923                        field: field_id,
3924                        value,
3925                    });
3926                }
3927                WalRecord::InsertEdge {
3928                    edge_type,
3929                    src_key,
3930                    dst_key,
3931                } => {
3932                    let (etype, intern) = self.intern_wal(&edge_type);
3933                    if interned.insert(etype) {
3934                        out.push(intern);
3935                    }
3936                    let src = lookup(&self.ids, &pending, &src_key).ok_or_else(|| {
3937                        GraphError::Corrupt {
3938                            detail: format!("dense WAL rewrite missing src {src_key}"),
3939                        }
3940                    })?;
3941                    let dst = lookup(&self.ids, &pending, &dst_key).ok_or_else(|| {
3942                        GraphError::Corrupt {
3943                            detail: format!("dense WAL rewrite missing dst {dst_key}"),
3944                        }
3945                    })?;
3946                    out.push(WalRecord::InsertEdgeId { etype, src, dst });
3947                }
3948                WalRecord::RenameNode {
3949                    ref old_key,
3950                    ref new_key,
3951                } => {
3952                    // Track the rename in `pending` so subsequent InsertEdge /
3953                    // SetProp records in this batch can resolve the new key.
3954                    let id = lookup(&self.ids, &pending, old_key).ok_or_else(|| {
3955                        GraphError::Corrupt {
3956                            detail: format!(
3957                                "dense WAL rewrite: RenameNode old key {old_key} not found"
3958                            ),
3959                        }
3960                    })?;
3961                    pending.remove(old_key.as_str());
3962                    pending.insert(new_key.clone(), id);
3963                    out.push(rec);
3964                }
3965                // # Symbol-order invariant (load-bearing)
3966                //
3967                // Write-time and replay-time symbol assignment must agree: every
3968                // symbol in a `Batch` frame has to receive the same dense id when
3969                // the frame's records are replayed in order as it received when
3970                // the frame was written.
3971                //
3972                // A rule's backfill interns its `edge_type` lazily
3973                // (`core_rules::engine`, every `g.syms.intern(&def.edge_type)`
3974                // site), and that backfill runs from `apply` — during the
3975                // `CreateRule` record itself, and again from any later
3976                // `InsertNodeId` in the same frame that makes the rule fire. At
3977                // write time the whole batch is rewritten before any of it is
3978                // applied, so a later `InsertEdge` in the same batch would win the
3979                // lower id for its edge type; on replay the rule's lazy intern
3980                // gets there first and steals it, and the `Intern` record fails at
3981                // the `wal intern assigned …` check in `apply`.
3982                //
3983                // Pre-interning the rule's `edge_type` here, and emitting its
3984                // `Intern` record ahead of the `CreateRule` record, makes both
3985                // orders identical. `weight_prop` needs no pre-intern:
3986                // `EdgeProps::set` keys props by `String`, never through the
3987                // interner. `via_edge` needs none either: via-hop rules resolve it
3988                // with `syms.get` and skip when it is absent.
3989                //
3990                // `RebuildRule` and `DeleteRule` need no such handling here:
3991                // `RebuildRule` has no `BatchOp` variant, so it never appears
3992                // inside a `Batch` today — it is only ever issued as its own
3993                // standalone commit (`rebuild_rule`, or the auto-rebuild path
3994                // that logs it as a second commit after the triggering op).
3995                // `DeleteRule` does have a `BatchOp` variant and can appear
3996                // inside a `Batch`, but it carries only a rule `name` — no
3997                // `edge_type` or other symbol that needs pre-interning — so
3998                // only `CreateRule` needs this arm.
3999                WalRecord::CreateRule { ref def_bytes } => {
4000                    let def = decode_rule_def(def_bytes).map_err(|e| GraphError::Corrupt {
4001                        detail: format!("CreateRule def_bytes deserialize failed: {e}"),
4002                    })?;
4003                    let (etype, intern) = self.intern_wal(&def.edge_type);
4004                    if interned.insert(etype) {
4005                        out.push(intern);
4006                    }
4007                    out.push(rec);
4008                }
4009                other => out.push(other),
4010            }
4011        }
4012        Ok(out)
4013    }
4014
4015    fn log_dense(&mut self, recs: Vec<WalRecord>) -> Result<()> {
4016        let recs = self.rewrite_wal_dense(recs)?;
4017        match recs.len() {
4018            0 => Ok(()),
4019            1 => self.log_then_apply(recs.into_iter().next().unwrap()),
4020            _ => self.log_then_apply(WalRecord::Batch(recs)),
4021        }
4022    }
4023
4024    /// Durable write, then notify the event sink. Replay (`apply` during
4025    /// `open`) never enters this function, so it is the replay-silent seam.
4026    fn log_then_apply(&mut self, rec: WalRecord) -> Result<()> {
4027        self.log_then_apply_with(rec, None, self.fsync)
4028    }
4029
4030    /// Whether this frame must fsync under `policy`.
4031    ///
4032    /// Batched contract: user-visible batches (>1 mutation) fsync; single
4033    /// mutations do not. The dense rewrite wraps a single mutation in a
4034    /// `Batch([Intern.., <one *Id record>])`, so `Intern` records are excluded
4035    /// from the count — removing that filter would make every single-op write
4036    /// fsync under Batched (or, if the threshold were raised instead, skip a
4037    /// needed fsync for real two-op batches).
4038    fn wal_needs_sync(policy: FsyncPolicy, rec: &WalRecord) -> bool {
4039        match policy {
4040            FsyncPolicy::Relaxed => false,
4041            FsyncPolicy::Strict => true,
4042            FsyncPolicy::Batched => match rec {
4043                // Intern + one mutation is the single-op rewrite, not a user batch.
4044                WalRecord::Batch(inner) => {
4045                    inner
4046                        .iter()
4047                        .filter(|r| !matches!(r, WalRecord::Intern { .. }))
4048                        .count()
4049                        > 1
4050                }
4051                _ => false,
4052            },
4053        }
4054    }
4055
4056    /// # Apply-infallibility invariant (load-bearing)
4057    ///
4058    /// The ordering is: WAL append → fsync → apply. If `apply` returned `Err`
4059    /// for a `Batch` frame after a successful WAL write, the WAL would contain
4060    /// the full frame while in-memory state would reflect only the ops before
4061    /// the failure. On reopen, WAL replay would then apply the entire batch —
4062    /// diverging permanently from what the pre-crash process had in memory.
4063    ///
4064    /// For `Batch` frames this situation cannot arise because:
4065    /// - All validation runs via `commit_logged_batch`/`MutPreview` **before**
4066    ///   the WAL write. `MutPreview` uses the same `&mut self` that apply will
4067    ///   use, with no concurrent mutation between validation exit and apply entry.
4068    /// - Every `apply` arm for a validated op is either infallible by construction
4069    ///   (`InsertNode`, `RemoveProp`, `DeleteEdge`, `DeleteNode`), has idempotency
4070    ///   guards that return `Ok(())` (`CreateRule`, `DeleteRule`), or is
4071    ///   guaranteed-present by validation (`InsertEdge`/`SetProp` key lookups).
4072    /// - `on_node_changed` and `on_node_removed` return `()` — never `Err`.
4073    ///
4074    /// A `debug_assert!` below fires in debug builds if `apply` ever returns
4075    /// `Err` for a `Batch` frame, making any future regression immediately visible
4076    /// in tests rather than silently diverging crash-recovery behaviour.
4077    fn log_then_apply_with(
4078        &mut self,
4079        rec: WalRecord,
4080        ingest: Option<(String, usize)>,
4081        policy: FsyncPolicy,
4082    ) -> Result<()> {
4083        // Read-only guard: as-of instances must never write the WAL.
4084        if self.read_only {
4085            return Err(GraphError::ReadOnly);
4086        }
4087        // Degraded guard: fsync failure left WAL truncated, or a refresh failed
4088        // partway; in-memory state is ahead of (or out of step with) the
4089        // on-disk WAL, so further mutations would deepen the divergence.
4090        // Reopen the database to recover.  Checked before the lock guard: this
4091        // is the more serious condition and the more useful error.
4092        if self.degraded {
4093            return Err(GraphError::Io(std::io::Error::other(
4094                "database degraded after group-commit fsync failure; reopen required",
4095            )));
4096        }
4097        // Cross-process guard: this write scope asked for the store's write
4098        // lock and did not get it. Writing anyway would append frames on top of
4099        // a WAL another process is extending, so refuse instead.
4100        if self.lock_denied {
4101            return Err(GraphError::Busy { holder: None });
4102        }
4103        // Ensure retained provenance bytes are decoded into the live mutable
4104        // fields before any mutation touches self.engine.provenance.  This is a
4105        // no-op if provenance was never stored (fresh store) or has already been
4106        // consumed (subsequent mutations).  WAL replay calls apply() directly
4107        // and is covered by consume_retained_state_eager before replay.
4108        self.ensure_v8_base_sections_loaded();
4109        self.engine.ensure_provenance_loaded_mut();
4110        // Invariant (I-1): no stale deltas may enter from a previous apply.
4111        // If any engine method ever accumulates deltas before erroring, they would
4112        // contaminate the *next* commit's event stream. This assert fires in debug
4113        // builds, making any future regression visible at the earliest point.
4114        debug_assert_eq!(
4115            self.engine.pending_delta_count(),
4116            0,
4117            "stale engine deltas at log_then_apply_with entry — \
4118             a previous apply arm may have accumulated deltas before erroring; \
4119             the caller must drain_deltas() on any error path before returning"
4120        );
4121        let frame = encode_record(&rec);
4122        self.fs.append(FileId::Wal, &frame)?;
4123        // The cursor advances by exactly the bytes appended: these frames are
4124        // ours and already applied, so a later refresh must not replay them.
4125        self.wal_consumed += frame.len() as u64;
4126        if Self::wal_needs_sync(policy, &rec) {
4127            self.fs.sync(FileId::Wal)?;
4128        }
4129        // Marker writing always needs the engine deltas, but the engine only
4130        // accumulates them when emit_deltas is true (normally gated on subscribers
4131        // or views being present).  Enable emission for this apply if it is
4132        // currently off, then restore the original state unconditionally via an
4133        // RAII guard — this prevents a panic in apply() from leaking the flag.
4134        // The same guard resets the engine's transient chaining state. A panic
4135        // unwinding out of a rule hook would otherwise leave `chain_depth`
4136        // non-zero, which makes every later `begin_chain` decide chaining is
4137        // already running and silently switch it off for good.
4138        struct RestoreEmitDeltas(*mut RuleEngine, bool);
4139        impl Drop for RestoreEmitDeltas {
4140            fn drop(&mut self) {
4141                // SAFETY: pointer into self (GraphDb); guard is dropped within
4142                // this frame before log_then_apply_with returns.
4143                unsafe {
4144                    (*self.0).set_emit_deltas(self.1);
4145                    (*self.0).reset_chain_state();
4146                }
4147            }
4148        }
4149        let original_emit = self.engine.emit_deltas();
4150        if !original_emit {
4151            self.engine.set_emit_deltas(true);
4152        }
4153        // SAFETY: raw pointer into self; guard dropped within this frame.
4154        let _emit_guard = RestoreEmitDeltas(&mut self.engine as *mut _, original_emit);
4155
4156        let apply_result = self.apply(&rec);
4157        // For Batch frames, post-validation apply must be infallible (see above).
4158        // A debug_assert here catches any future change that makes apply fallible
4159        // before the caller notices via silent WAL/memory divergence.
4160        if matches!(&rec, WalRecord::Batch(_)) {
4161            debug_assert!(
4162                apply_result.is_ok(),
4163                "Batch apply returned Err after successful WAL write — \
4164                 the validate-then-apply invariant has been violated; \
4165                 see log_then_apply_with invariant doc"
4166            );
4167        }
4168        if apply_result.is_err() {
4169            // Discard any partial deltas accumulated by the failed apply.
4170            // They must not ride the next commit's event stream (I-1).
4171            // _emit_guard restores emit_deltas on drop automatically.
4172            let _ = self.engine.drain_deltas();
4173            let _ = self.engine.take_rebuild_needed();
4174            apply_result?;
4175        }
4176        self.commit_seq += 1;
4177        let seq = self.commit_seq;
4178        // Update per-node last-change map for the committed record.
4179        // Must happen after commit_seq is incremented so the seq is correct.
4180        self.update_last_change_from_rec(&rec, seq);
4181        // Drain engine deltas and distribute to subscribers before the existing
4182        // MutationEvent sink fires — both happen post-fsync, post-apply.
4183        // _emit_guard restores emit_deltas after this line when it drops.
4184        let engine_deltas = self.engine.drain_deltas();
4185
4186        // Append history-marker WAL records for any derived-edge changes so
4187        // that `edge_history` and `was_linked` can surface rule-attributed
4188        // events. Markers are STATE NO-OPS during replay; they are written
4189        // without an additional fsync (the triggering commit's sync already
4190        // happened; the next commit's sync covers these lazily).
4191        if !engine_deltas.is_empty() {
4192            let markers: Vec<WalRecord> = engine_deltas
4193                .iter()
4194                .map(|d| {
4195                    if d.fired {
4196                        WalRecord::DerivedEdgeAdded {
4197                            rule: d.rule.clone(),
4198                            edge_type: d.edge_type.clone(),
4199                            src_key: d.src_key.clone(),
4200                            dst_key: d.dst_key.clone(),
4201                        }
4202                    } else {
4203                        WalRecord::DerivedEdgeRetracted {
4204                            rule: d.rule.clone(),
4205                            edge_type: d.edge_type.clone(),
4206                            src_key: d.src_key.clone(),
4207                            dst_key: d.dst_key.clone(),
4208                        }
4209                    }
4210                })
4211                .collect();
4212            let marker_frame = if markers.len() == 1 {
4213                markers.into_iter().next().unwrap()
4214            } else {
4215                WalRecord::Batch(markers)
4216            };
4217            // Ignore append errors: markers are best-effort history
4218            // annotations. Losing them does not affect state correctness.
4219            // The cursor only advances when the bytes actually landed.
4220            let marker_bytes = encode_record(&marker_frame);
4221            if self.fs.append(FileId::Wal, &marker_bytes).is_ok() {
4222                self.wal_consumed += marker_bytes.len() as u64;
4223            }
4224        }
4225
4226        // Record MVCC CommitDelta for the epoch reader.  The WAL record is
4227        // stored as-is (including any nested Batch / Intern records); the
4228        // ReaderSnapshot's apply_one function handles all variants.
4229        {
4230            let derived_inserts = engine_deltas
4231                .iter()
4232                .filter(|d| d.fired)
4233                .map(|d| (d.etype_sym, d.src_id, d.dst_id))
4234                .collect();
4235            let derived_deletes = engine_deltas
4236                .iter()
4237                .filter(|d| !d.fired)
4238                .map(|d| (d.etype_sym, d.src_id, d.dst_id))
4239                .collect();
4240            let delta = Arc::new(crate::reader::CommitDelta {
4241                records: vec![rec.clone()],
4242                derived_inserts,
4243                derived_deletes,
4244            });
4245            self.delta_tail.push(delta);
4246            self.commits_since_fold += 1;
4247            if self.commits_since_fold >= crate::reader::FOLD_EVERY_K {
4248                self.fold_now();
4249            }
4250        }
4251
4252        if self.defer_events {
4253            // Group-commit drain thread: hold events until after the group
4254            // fsync so subscribers only observe durable data (R2).
4255            self.deferred_events.push(DeferredEvent {
4256                rec: rec.clone(),
4257                engine_deltas,
4258                seq,
4259                ingest,
4260            });
4261        } else {
4262            self.distribute_events(&rec, &engine_deltas, seq);
4263            self.emit_committed(&rec, ingest);
4264        }
4265        // Drift is only known after apply, so auto-rebuild cannot join the
4266        // triggering op's WAL frame. Issue RebuildRule as a second commit.
4267        // Skip when `rec` is itself RebuildRule: rebuild resets drift, so a
4268        // retrigger loop is impossible if the fit succeeded, but we still
4269        // drain the flag so a leftover cannot re-enter.
4270        let rebuilds = self.engine.take_rebuild_needed();
4271        if !matches!(&rec, WalRecord::RebuildRule { .. }) {
4272            let mut failed = Vec::new();
4273            for name in rebuilds {
4274                if self.engine.rules().any(|r| r.name == name) {
4275                    // User op is already durable. A failed second commit must
4276                    // not surface as the caller's error.
4277                    if let Err(e) =
4278                        self.log_then_apply(WalRecord::RebuildRule { name: name.clone() })
4279                    {
4280                        eprintln!(
4281                            "auto-rebuild of rule {name:?} failed after durable user commit: {e}"
4282                        );
4283                        failed.push(name);
4284                    }
4285                }
4286            }
4287            for name in failed {
4288                self.engine.queue_rebuild_needed(name);
4289            }
4290        }
4291        Ok(())
4292    }
4293
4294    /// Install a post-commit hook. Replaces any previous sink.
4295    ///
4296    /// The sink runs inside `log_then_apply` after a successful
4297    /// durable commit, while the caller still holds `&mut self`. When this
4298    /// database is behind a [`crate::SharedDb`], that means the **write
4299    /// guard is held**. The sink must never call `read` / `write` (or any
4300    /// other method) on the same `SharedDb` — the `RwLock` is not
4301    /// re-entrant and doing so deadlocks. The sink is `Send + Sync`;
4302    /// `std::sync::mpsc::Sender` is not `Sync` and will not type-check.
4303    /// Intended examples: `std::sync::mpsc::SyncSender`,
4304    /// `tokio::sync::mpsc::Sender`, `tokio::sync::broadcast::Sender`
4305    /// (non-blocking `send`), or `Arc<Mutex<Vec<MutationEvent>>>`.
4306    pub fn set_event_sink(&mut self, sink: Box<dyn Fn(MutationEvent) + Send + Sync>) {
4307        self.event_sink = Some(sink);
4308    }
4309
4310    /// Whether a post-commit event sink is currently installed.
4311    pub fn has_event_sink(&self) -> bool {
4312        self.event_sink.is_some()
4313    }
4314
4315    /// Set WAL fsync cadence. Default [`FsyncPolicy::Strict`].
4316    pub fn set_fsync_policy(&mut self, p: FsyncPolicy) {
4317        self.fsync = p;
4318    }
4319
4320    /// Return the current WAL fsync cadence.
4321    pub fn fsync_policy(&self) -> FsyncPolicy {
4322        self.fsync
4323    }
4324
4325    // ── Group-commit event deferral ───────────────────────────────────────────
4326
4327    /// Enable or disable deferred event mode.
4328    ///
4329    /// When `true`, event notifications (subscription `DbEvent`s and legacy
4330    /// `MutationEvent` sink calls) are buffered rather than fired immediately.
4331    /// Call [`flush_deferred_events`] after the group fsync to deliver them,
4332    /// or [`discard_deferred_events`] if the fsync failed and the group must
4333    /// be treated as lost.
4334    pub fn set_deferred_events_mode(&mut self, defer: bool) {
4335        self.defer_events = defer;
4336    }
4337
4338    /// Fire all buffered events accumulated since [`set_deferred_events_mode`]
4339    /// was set to true.  Clears the buffer.
4340    ///
4341    /// Called by the drain thread AFTER a successful group fsync, so
4342    /// subscribers observe only data that is durably on disk.
4343    pub fn flush_deferred_events(&mut self) {
4344        let events = std::mem::take(&mut self.deferred_events);
4345        for de in events {
4346            self.distribute_events(&de.rec, &de.engine_deltas, de.seq);
4347            self.emit_committed(&de.rec, de.ingest);
4348        }
4349    }
4350
4351    /// Discard all buffered events without firing them.
4352    ///
4353    /// Called by the drain thread when a group fsync fails: the WAL has been
4354    /// truncated back to the pre-group offset, so the committed-but-unsynced
4355    /// ops must not be observable to subscribers.
4356    pub fn discard_deferred_events(&mut self) {
4357        self.deferred_events.clear();
4358    }
4359
4360    // ── Degraded state ────────────────────────────────────────────────────────
4361
4362    /// Mark this database as degraded.
4363    ///
4364    /// Called by the group-commit drain thread after a group fsync failure and
4365    /// WAL truncation: the in-memory state is now ahead of the on-disk WAL, so
4366    /// further mutations would deepen the divergence.  All subsequent calls to
4367    /// [`log_then_apply_with`] return `Err` until the database is reopened.
4368    pub fn set_degraded(&mut self) {
4369        self.degraded = true;
4370    }
4371
4372    fn emit(&self, ev: MutationEvent) {
4373        if let Some(sink) = &self.event_sink {
4374            sink(ev);
4375        }
4376    }
4377
4378    fn emit_committed(&self, rec: &WalRecord, ingest: Option<(String, usize)>) {
4379        match rec {
4380            WalRecord::Batch(inner) => {
4381                for r in inner {
4382                    if let Some(ev) = event_from_record(r, &self.syms, &self.ids) {
4383                        self.emit(ev);
4384                    }
4385                }
4386                match ingest {
4387                    Some((label, inserted)) => {
4388                        self.emit(MutationEvent::Ingested { label, inserted })
4389                    }
4390                    None => {
4391                        let ops = inner
4392                            .iter()
4393                            .filter(|r| !matches!(r, WalRecord::Intern { .. }))
4394                            .count();
4395                        if ops > 1 {
4396                            self.emit(MutationEvent::BatchApplied { ops });
4397                        }
4398                    }
4399                }
4400            }
4401            other => {
4402                if let Some(ev) = event_from_record(other, &self.syms, &self.ids) {
4403                    self.emit(ev);
4404                }
4405            }
4406        }
4407    }
4408
4409    // -----------------------------------------------------------------------
4410    // Subscription API
4411    // -----------------------------------------------------------------------
4412
4413    /// Distribute post-commit events to all live subscribers.
4414    ///
4415    /// Build a row-key → row-data map from a [`ResultSet`].
4416    ///
4417    /// Each row is serialized to JSON to form its key; a debug fallback is used
4418    /// if serialization fails. Used by both the initial-seed path in
4419    /// [`Self::subscribe_query`] and the per-commit diff path in
4420    /// [`Self::distribute_events`] to keep the two in sync.
4421    fn result_to_row_map(
4422        result: &core_query::ResultSet,
4423    ) -> std::collections::HashMap<String, Vec<Option<Value>>> {
4424        (0..result.len())
4425            .map(|i| {
4426                let row = result.row(i).to_vec();
4427                let key = serde_json::to_string(&row).unwrap_or_else(|_| format!("{row:?}"));
4428                (key, row)
4429            })
4430            .collect()
4431    }
4432
4433    /// Collect the set of label syms touched by a WAL record.
4434    ///
4435    /// Returns `Some(set)` when every record in this commit can be attributed to
4436    /// a known label sym. Returns `None` when the commit must not be skipped:
4437    /// edge records, unresolvable key→label lookups, or any record type not in
4438    /// the explicit handled set.
4439    ///
4440    /// Handled record types and their actions:
4441    /// - `InsertNode`   → look up label in interner (fails → None)
4442    /// - `InsertNodeId` → label sym is carried directly
4443    /// - `SetProp`      → resolve key→id→label (fails → None)
4444    /// - `DeleteNode`   → resolve key→id→label (fails → None)
4445    /// - `Batch`        → recurse into every inner record
4446    /// - `InsertEdge`, `DeleteEdge`, `InsertEdgeId` → always None (edge records)
4447    /// - everything else → None (conservative)
4448    fn commit_touched_labels(
4449        rec: &WalRecord,
4450        syms: &Interner,
4451        ids: &IdMap,
4452        labels: &[u32],
4453    ) -> Option<BTreeSet<u32>> {
4454        let mut out = BTreeSet::new();
4455        if Self::collect_touched_labels(rec, syms, ids, labels, &mut out) {
4456            Some(out)
4457        } else {
4458            None
4459        }
4460    }
4461
4462    fn collect_touched_labels(
4463        rec: &WalRecord,
4464        syms: &Interner,
4465        ids: &IdMap,
4466        labels: &[u32],
4467        out: &mut BTreeSet<u32>,
4468    ) -> bool {
4469        match rec {
4470            // String-key insert: the dense rewrite converts this to
4471            // [Intern, InsertNodeId], so this arm fires only for legacy WAL
4472            // records written before the dense path was added.
4473            WalRecord::InsertNode { label, .. } => {
4474                if let Some(sym) = syms.get(label) {
4475                    out.insert(sym);
4476                    true
4477                } else {
4478                    false
4479                }
4480            }
4481            // Dense-id insert (produced by rewrite_wal_dense for every
4482            // insert_node call in the current codebase).
4483            WalRecord::InsertNodeId { label, .. } => {
4484                out.insert(*label);
4485                true
4486            }
4487            // String-key prop set: dense path converts to [Intern, SetPropId].
4488            WalRecord::SetProp { key, .. } => {
4489                if let Some(sym) = Self::resolve_key_label_sym(key, ids, labels) {
4490                    out.insert(sym);
4491                    true
4492                } else {
4493                    false
4494                }
4495            }
4496            // Dense-id prop set (produced by rewrite_wal_dense for set_prop).
4497            WalRecord::SetPropId { id, .. } => {
4498                if let Some(sym) = labels.get(*id as usize).copied().filter(|&s| s != u32::MAX) {
4499                    out.insert(sym);
4500                    true
4501                } else {
4502                    false
4503                }
4504            }
4505            WalRecord::DeleteNode { key } => {
4506                if let Some(sym) = Self::resolve_key_label_sym(key, ids, labels) {
4507                    out.insert(sym);
4508                    true
4509                } else {
4510                    false
4511                }
4512            }
4513            WalRecord::Batch(inner) => inner
4514                .iter()
4515                .all(|r| Self::collect_touched_labels(r, syms, ids, labels, out)),
4516            // Intern is a pure metadata record — it does not touch any node's
4517            // label and is safe to skip for the label-skip predicate.
4518            WalRecord::Intern { .. } => true,
4519            // Edge records: always re-execute (edges can change join results).
4520            WalRecord::InsertEdge { .. }
4521            | WalRecord::DeleteEdge { .. }
4522            | WalRecord::InsertEdgeId { .. } => false,
4523            _ => false,
4524        }
4525    }
4526
4527    /// Resolve a node key to its label sym via the dense id table.
4528    /// Returns `None` if the key is unknown or the label is a tombstone sentinel.
4529    fn resolve_key_label_sym(key: &str, ids: &IdMap, labels: &[u32]) -> Option<u32> {
4530        let id = ids.get(key)?;
4531        let sym = labels.get(id as usize).copied()?;
4532        (sym != u32::MAX).then_some(sym)
4533    }
4534
4535    /// Distribute post-commit events to all live subscribers.
4536    ///
4537    /// Called from `log_then_apply_with` after apply + fsync, before the
4538    /// legacy MutationEvent sink. Prunes dead `Weak` entries in-place.
4539    ///
4540    /// Query subscriptions (subscribe_query) re-execute their plan on every
4541    /// call and diff the result against the previous run. Zero overhead when
4542    /// no query subscriptions are active.
4543    fn distribute_events(&mut self, rec: &WalRecord, engine_deltas: &[EngineEdgeDelta], seq: u64) {
4544        if self.subscriptions.is_empty() && self.query_subscriptions.is_empty() {
4545            return;
4546        }
4547
4548        if !self.subscriptions.is_empty() {
4549            // Build write events from the WAL record.
4550            let write_events: Vec<DbEvent> =
4551                Self::write_events_from_record(rec, seq, &self.syms, &self.ids);
4552
4553            // Build edge events from engine deltas.  Weight is looked up from
4554            // edge_props at distribution time (after apply), so it's always fresh.
4555            let edge_events: Vec<DbEvent> = engine_deltas
4556                .iter()
4557                .map(|d| {
4558                    if d.fired {
4559                        // The score lives under the rule's declared weight_prop,
4560                        // which is not always the literal "weight".
4561                        let prop = self
4562                            .engine
4563                            .rules()
4564                            .find(|r| r.name == d.rule)
4565                            .and_then(|r| r.weight_prop.as_deref());
4566                        let weight = prop.and_then(|p| {
4567                            self.edge_props
4568                                .get(d.etype_sym, d.src_id, d.dst_id, p)
4569                                .and_then(|v| {
4570                                    if let core_storage::Value::Float(f) = v {
4571                                        Some(*f)
4572                                    } else {
4573                                        None
4574                                    }
4575                                })
4576                        });
4577                        DbEvent::EdgeFired {
4578                            rule: d.rule.clone(),
4579                            src_key: d.src_key.clone(),
4580                            dst_key: d.dst_key.clone(),
4581                            edge_type: d.edge_type.clone(),
4582                            weight,
4583                            commit_seq: seq,
4584                        }
4585                    } else {
4586                        DbEvent::EdgeRetracted {
4587                            rule: d.rule.clone(),
4588                            src_key: d.src_key.clone(),
4589                            dst_key: d.dst_key.clone(),
4590                            edge_type: d.edge_type.clone(),
4591                            commit_seq: seq,
4592                        }
4593                    }
4594                })
4595                .collect();
4596
4597            // Prune dead entries; push matching events to live ones.
4598            self.subscriptions.retain(|entry| {
4599                let Some(inner) = entry.inner.upgrade() else {
4600                    return false;
4601                };
4602                for ev in &write_events {
4603                    if event_matches(ev, &entry.filter) {
4604                        inner.push(ev.clone());
4605                    }
4606                }
4607                for ev in &edge_events {
4608                    if event_matches(ev, &entry.filter) {
4609                        inner.push(ev.clone());
4610                    }
4611                }
4612                true
4613            });
4614
4615            // Turn off delta accumulation if all subscribers dropped and no views remain.
4616            if self.subscriptions.is_empty() && self.view_store.is_empty() {
4617                self.engine.set_emit_deltas(false);
4618            }
4619        }
4620
4621        // Query subscriptions: full re-run per commit, then diff rows.
4622        // IMPORTANT: full re-execution on every commit — use LIMIT to bound cost.
4623        // Differential evaluation is roadmap / Phase 5.
4624        if !self.query_subscriptions.is_empty() {
4625            // Take the list out so we can call self.view() without borrow conflict.
4626            let mut query_subs = std::mem::take(&mut self.query_subscriptions);
4627            let empty_params = BTreeMap::new();
4628            query_subs.retain_mut(|entry| {
4629                let Some(inner) = entry.inner.upgrade() else {
4630                    return false; // subscriber dropped — prune
4631                };
4632                // Label-skip: if the plan has a known scan label and this commit
4633                // can be proven to touch only different labels (and no rule-derived
4634                // edge deltas fired), the result set cannot have changed — skip.
4635                if let Some(scan_sym) = entry.scan_label {
4636                    if engine_deltas.is_empty() {
4637                        let touched =
4638                            Self::commit_touched_labels(rec, &self.syms, &self.ids, &self.labels);
4639                        if touched.map(|t| !t.contains(&scan_sym)).unwrap_or(false) {
4640                            return true; // safe to skip — result set unchanged
4641                        }
4642                    }
4643                }
4644                QUERY_SUB_EXECS_TL.with(|c| c.set(c.get() + 1));
4645                let result = match execute(&self.view(), &entry.ops, &Params(&empty_params)) {
4646                    Ok(r) => r,
4647                    Err(e) => {
4648                        // Keep the subscription alive; skip the diff for this commit.
4649                        // Re-run errors are transient (e.g., planner change) and
4650                        // self-heal when the next commit succeeds.
4651                        eprintln!("[mushroomdb] subscribe_query re-run failed: {e}");
4652                        return true;
4653                    }
4654                };
4655                // Build new row map: serialized-key → row data.
4656                let new_row_map = Self::result_to_row_map(&result);
4657                // Removed rows: in prev but not in new.
4658                for (key, row) in &entry.prev_row_map {
4659                    if !new_row_map.contains_key(key) {
4660                        inner.push(DbEvent::QueryRowRemoved {
4661                            columns: entry.columns.clone(),
4662                            row: row.clone(),
4663                        });
4664                    }
4665                }
4666                // Added rows: in new but not in prev.
4667                for (key, row) in &new_row_map {
4668                    if !entry.prev_row_map.contains_key(key) {
4669                        inner.push(DbEvent::QueryRowAdded {
4670                            columns: entry.columns.clone(),
4671                            row: row.clone(),
4672                        });
4673                    }
4674                }
4675                entry.prev_row_map = new_row_map;
4676                true
4677            });
4678            self.query_subscriptions = query_subs;
4679        }
4680    }
4681
4682    /// Returns `true` if any live subscriber or view definition requires delta
4683    /// accumulation. Used to set `engine.emit_deltas` on subscribe/view DDL.
4684    fn needs_emit_deltas(&self) -> bool {
4685        !self.view_store.is_empty()
4686            || self
4687                .subscriptions
4688                .iter()
4689                .any(|e| e.inner.upgrade().is_some())
4690    }
4691
4692    /// Convert a WAL record into `DbEvent` write events with the given seq.
4693    fn write_events_from_record(
4694        rec: &WalRecord,
4695        seq: u64,
4696        intern: &Interner,
4697        ids: &IdMap,
4698    ) -> Vec<DbEvent> {
4699        match rec {
4700            WalRecord::InsertNode { label, key, .. } => vec![DbEvent::NodeInserted {
4701                label: label.clone(),
4702                key: key.clone(),
4703                commit_seq: seq,
4704            }],
4705            // *Id arms run after a successful apply, so resolution can only
4706            // fail on a programming error. Skip the event rather than emit a
4707            // fabricated "" that clients can't tell from a real empty value
4708            // (mirrors event_from_record returning None).
4709            WalRecord::InsertNodeId { label, key, .. } => intern
4710                .resolve(*label)
4711                .map(|label| DbEvent::NodeInserted {
4712                    label: label.to_string(),
4713                    key: key.clone(),
4714                    commit_seq: seq,
4715                })
4716                .into_iter()
4717                .collect(),
4718            WalRecord::SetProp { key, field, .. } => vec![DbEvent::PropSet {
4719                key: key.clone(),
4720                field: field.clone(),
4721                commit_seq: seq,
4722            }],
4723            WalRecord::SetPropId { id, field, .. } => ids
4724                .key_of(*id)
4725                .zip(intern.resolve(*field))
4726                .map(|(key, field)| DbEvent::PropSet {
4727                    key: key.to_string(),
4728                    field: field.to_string(),
4729                    commit_seq: seq,
4730                })
4731                .into_iter()
4732                .collect(),
4733            WalRecord::RemoveProp { key, field } => vec![DbEvent::PropRemoved {
4734                key: key.clone(),
4735                field: field.clone(),
4736                commit_seq: seq,
4737            }],
4738            WalRecord::InsertEdge {
4739                edge_type,
4740                src_key,
4741                dst_key,
4742            } => vec![DbEvent::EdgeInserted {
4743                edge_type: edge_type.clone(),
4744                src: src_key.clone(),
4745                dst: dst_key.clone(),
4746                commit_seq: seq,
4747            }],
4748            WalRecord::InsertEdgeId { etype, src, dst } => (|| {
4749                Some(DbEvent::EdgeInserted {
4750                    edge_type: intern.resolve(*etype)?.to_string(),
4751                    src: ids.key_of(*src)?.to_string(),
4752                    dst: ids.key_of(*dst)?.to_string(),
4753                    commit_seq: seq,
4754                })
4755            })()
4756            .into_iter()
4757            .collect(),
4758            WalRecord::DeleteEdge {
4759                edge_type,
4760                src_key,
4761                dst_key,
4762            } => vec![DbEvent::EdgeDeleted {
4763                edge_type: edge_type.clone(),
4764                src: src_key.clone(),
4765                dst: dst_key.clone(),
4766                commit_seq: seq,
4767            }],
4768            WalRecord::DeleteNode { key } => vec![DbEvent::NodeDeleted {
4769                key: key.clone(),
4770                commit_seq: seq,
4771            }],
4772            WalRecord::Batch(inner) => inner
4773                .iter()
4774                .flat_map(|r| Self::write_events_from_record(r, seq, intern, ids))
4775                .collect(),
4776            WalRecord::CreateRule { .. }
4777            | WalRecord::DeleteRule { .. }
4778            | WalRecord::RebuildRule { .. }
4779            | WalRecord::CreateView { .. }
4780            | WalRecord::DeleteView { .. }
4781            | WalRecord::EnableFulltext { .. }
4782            | WalRecord::DisableFulltext { .. }
4783            | WalRecord::EnableIndex { .. }
4784            | WalRecord::DisableIndex { .. }
4785            | WalRecord::Intern { .. }
4786            // History markers produce no DbEvent — the engine delta already
4787            // fired the EdgeFired/EdgeRetracted subscription events.
4788            | WalRecord::DerivedEdgeAdded { .. }
4789            | WalRecord::DerivedEdgeRetracted { .. }
4790            | WalRecord::RenameNode { .. } => vec![],
4791        }
4792    }
4793
4794    /// Subscribe to edge-fire and edge-retract events for one named rule.
4795    ///
4796    /// Returns `Err(GraphError::RuleNotFound)` if `rule_name` is not
4797    /// currently registered. Dropping the returned [`Subscription`] handle
4798    /// unregisters the subscriber — no further events are queued, no
4799    /// resources leak.
4800    pub fn subscribe_rule(&mut self, rule_name: &str) -> core_storage::Result<Subscription> {
4801        if self.read_only {
4802            return Err(core_storage::GraphError::ReadOnly);
4803        }
4804        if !self.engine.rules().any(|r| r.name == rule_name) {
4805            return Err(core_storage::GraphError::RuleNotFound {
4806                name: rule_name.to_string(),
4807            });
4808        }
4809        let inner = SubInner::new(self.sub_capacity());
4810        self.subscriptions.push(SubEntry {
4811            filter: SubFilter::Rule(rule_name.to_string()),
4812            inner: std::sync::Arc::downgrade(&inner),
4813        });
4814        self.engine.set_emit_deltas(true);
4815        Ok(Subscription(inner))
4816    }
4817
4818    /// Subscribe to edge-fire and edge-retract events for **all** rules.
4819    ///
4820    /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
4821    /// as-of instances never commit, so `distribute_events` never runs and the
4822    /// subscription would never deliver events.
4823    pub fn subscribe_all_rules(&mut self) -> core_storage::Result<Subscription> {
4824        if self.read_only {
4825            return Err(core_storage::GraphError::ReadOnly);
4826        }
4827        let inner = SubInner::new(self.sub_capacity());
4828        self.subscriptions.push(SubEntry {
4829            filter: SubFilter::AllRules,
4830            inner: std::sync::Arc::downgrade(&inner),
4831        });
4832        self.engine.set_emit_deltas(true);
4833        Ok(Subscription(inner))
4834    }
4835
4836    /// Subscribe to write events: node insert/delete, prop set/remove.
4837    ///
4838    /// Does not include edge-fire / edge-retract (rule-derived edge events).
4839    ///
4840    /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
4841    /// as-of instances never commit, so `distribute_events` never runs and the
4842    /// subscription would never deliver events.
4843    pub fn subscribe_writes(&mut self) -> core_storage::Result<Subscription> {
4844        if self.read_only {
4845            return Err(core_storage::GraphError::ReadOnly);
4846        }
4847        let inner = SubInner::new(self.sub_capacity());
4848        self.subscriptions.push(SubEntry {
4849            filter: SubFilter::Writes,
4850            inner: std::sync::Arc::downgrade(&inner),
4851        });
4852        self.engine.set_emit_deltas(true);
4853        Ok(Subscription(inner))
4854    }
4855
4856    /// Subscribe to incremental Cypher query results.
4857    ///
4858    /// Parses and plans `cypher`; rejects the query if the plan is not in the
4859    /// allowlisted subset (see [`core_query::cypher::is_subscribable`]):
4860    ///   - `MATCH (n:Label) WHERE … RETURN … [LIMIT n]`
4861    ///   - `MATCH (a)-[r:TYPE]->(b) RETURN … [LIMIT n]`  (exactly one hop)
4862    ///
4863    /// SKIP is not supported — it shifts the result window on every commit,
4864    /// causing spurious Added/Removed churn for rows whose data never changed.
4865    /// Multi-hop Expand chains are not supported; each additional MATCH clause
4866    /// widens scope beyond the documented single-scan / single-hop subset.
4867    ///
4868    /// After each successful commit, the plan is **fully re-executed** and the
4869    /// result is diffed against the previous run. Added rows produce
4870    /// [`DbEvent::QueryRowAdded`]; removed rows produce
4871    /// [`DbEvent::QueryRowRemoved`].
4872    ///
4873    /// **Full re-run per commit; use LIMIT to bound execution cost.**
4874    /// The existing 1 M intermediate-row cap applies. Differential evaluation
4875    /// is roadmap / Phase 5.
4876    ///
4877    /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
4878    /// as-of instances never commit, so `distribute_events` never runs and the
4879    /// subscription would never deliver events.
4880    ///
4881    /// Returns `Err(GraphError::QueryError)` if the query fails to parse, plan,
4882    /// or if the plan shape is not in the allowlist.
4883    pub fn subscribe_query(&mut self, cypher: &str) -> Result<Subscription> {
4884        if self.read_only {
4885            return Err(GraphError::ReadOnly);
4886        }
4887        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
4888            detail: format!("lex: {e}"),
4889        })?;
4890        let ast = parse(&tokens).map_err(|e| GraphError::QueryError {
4891            detail: format!("parse: {e}"),
4892        })?;
4893        let ops = plan(&ast).map_err(|e| GraphError::QueryError {
4894            detail: format!("plan: {e}"),
4895        })?;
4896        if !is_subscribable(&ops) {
4897            return Err(GraphError::QueryError {
4898                detail: "subscribe_query only supports allowlisted plan shapes: \
4899                         MATCH (n:Label) WHERE … RETURN … [LIMIT n] or \
4900                         MATCH (a)-[r:TYPE]->(b) RETURN … [LIMIT n] (exactly one hop). \
4901                         Not supported: multi-hop Expand chains, SKIP (creates \
4902                         unstable offset windows), ORDER BY, DISTINCT, aggregates, \
4903                         variable-length paths, OPTIONAL MATCH, WITH, UNWIND. \
4904                         Use LIMIT to bound re-execution cost."
4905                    .to_string(),
4906            });
4907        }
4908        // Execute once to capture initial state (initial rows are not emitted as
4909        // events — the subscriber learns the baseline via the first query call).
4910        let empty_params = BTreeMap::new();
4911        let initial = execute(&self.view(), &ops, &Params(&empty_params)).map_err(|e| {
4912            GraphError::QueryError {
4913                detail: format!("execute: {e}"),
4914            }
4915        })?;
4916        let columns = initial.columns().to_vec();
4917        let prev_row_map = Self::result_to_row_map(&initial);
4918        let inner = SubInner::new(self.sub_capacity());
4919        // Derive the scan-label sym for the commit-skip fast-path.  Any Expand op
4920        // or unrecognized leading scan → None (always re-execute).
4921        let scan_label = extract_scan_label(&ops, &mut self.syms);
4922        self.query_subscriptions.push(QuerySubEntry {
4923            ops,
4924            columns,
4925            prev_row_map,
4926            inner: std::sync::Arc::downgrade(&inner),
4927            scan_label,
4928        });
4929        Ok(Subscription(inner))
4930    }
4931
4932    /// Queue capacity used for new subscriptions.
4933    fn sub_capacity(&self) -> usize {
4934        self.sub_capacity
4935    }
4936
4937    /// Override per-subscriber queue capacity for subsequently created
4938    /// subscriptions on this db instance.
4939    ///
4940    /// Default is [`DEFAULT_SUB_CAPACITY`] (65,536 events). Use a smaller
4941    /// value in tests to exercise the [`DbEvent::Lagged`] path without
4942    /// generating tens of thousands of events.
4943    ///
4944    /// This is a test-support escape hatch. Calling it in production reduces
4945    /// subscriber reliability (more Lagged events). It is hidden from rustdoc
4946    /// to discourage accidental production use.
4947    #[doc(hidden)]
4948    pub fn set_sub_capacity(&mut self, capacity: usize) {
4949        self.sub_capacity = capacity;
4950    }
4951
4952    // -----------------------------------------------------------------------
4953
4954    /// Start an atomic batch.
4955    ///
4956    /// The returned [`BatchBuilder`] borrows `self` mutably until
4957    /// [`BatchBuilder::commit`]. Builder methods queue ops only — no
4958    /// validation, no WAL I/O. `commit` validates every queued op against
4959    /// live state plus preceding ops in this batch (duplicate key inside
4960    /// the batch is `Err`; an edge between two nodes created earlier in
4961    /// the batch is valid; `delete_node` then insert of the same key is a
4962    /// fresh identity). Validation never mutates the database. Any failure
4963    /// leaves WAL bytes and in-memory state identical to before `commit`.
4964    /// On success, one `WalRecord::Batch` frame is appended (one fsync)
4965    /// and each inner record is applied in order so rules fire per record.
4966    /// An empty batch, or a batch of only no-ops, writes zero WAL bytes.
4967    ///
4968    /// **Rule-window limitation:** batch validation cannot see edges that a
4969    /// rule created earlier in the *same* batch will derive at apply time, so
4970    /// a `delete_edge` / `insert_edge` in that window is silently no-oped
4971    /// where sequential calls would return `Err(RuleOwned)`. State integrity
4972    /// is unaffected (idempotent apply, provenance intact). Create rules in
4973    /// their own batch, or sequentially, when later ops may touch derived
4974    /// edges.
4975    pub fn batch(&mut self) -> BatchBuilder<'_, F> {
4976        BatchBuilder {
4977            db: self,
4978            ops: Vec::new(),
4979        }
4980    }
4981
4982    /// Closure-style atomic write batch.
4983    ///
4984    /// Equivalent to calling [`GraphDb::batch`], invoking `build` to queue ops,
4985    /// then committing. All ops queued inside `build` are validated in order and
4986    /// committed as a single `WalRecord::Batch` frame (one fsync). Rules fire
4987    /// once per inner record, in order, after commit — semantically identical to
4988    /// sequential single-op writes.
4989    ///
4990    /// **Error semantics — validate-then-apply.** `build` queues ops without
4991    /// touching the database. [`BatchBuilder::commit`] validates every op against
4992    /// live state plus earlier ops in this batch before writing anything. If op N
4993    /// fails validation (duplicate key, unknown key, rule-owned edge, …) the
4994    /// entire batch is rejected: no WAL bytes are written and no in-memory state
4995    /// changes. The database is identical to its state before `write_batch` was
4996    /// called.
4997    ///
4998    /// **Atomicity is crash-level, NOT isolation-level.** On replay after a crash,
4999    /// a partial (torn) `Batch` frame applies NONE of its ops — the frame is
5000    /// either fully applied or not at all. However, while applying a committed
5001    /// batch, concurrent readers may observe intermediate states as ops are applied
5002    /// sequentially in memory. There is no interactive transaction isolation in v1.
5003    /// This is documented as "crash-atomic write batches; no interactive
5004    /// transactions or read isolation."
5005    ///
5006    /// **Returns** `(nodes_inserted, edges_inserted)`. An empty or all-noop batch
5007    /// writes zero WAL bytes and returns `(0, 0)`.
5008    ///
5009    /// # Example
5010    ///
5011    /// ```rust,ignore
5012    /// let (nodes, edges) = db.write_batch(|b| {
5013    ///     b.insert_node("Person", "alice", vec![("age".into(), Value::Int(30))]);
5014    ///     b.insert_node("Person", "bob", vec![]);
5015    ///     b.insert_edge("KNOWS", "alice", "bob");
5016    ///     b.set_prop("alice", "role", Value::Str("admin".into()));
5017    ///     b.delete_node("old_key");
5018    /// })?;
5019    /// // One fsync; on crash replay: all five ops land or none do.
5020    /// ```
5021    pub fn write_batch<C>(&mut self, build: C) -> Result<(usize, usize)>
5022    where
5023        C: FnOnce(&mut BatchBuilder<'_, F>),
5024    {
5025        let mut b = self.batch();
5026        build(&mut b);
5027        b.commit()
5028    }
5029
5030    /// Insert `rows` as nodes of `label`. One call is one atomic batch:
5031    /// auto-declared KeyMatch rules (if any) first, then the accepted node
5032    /// inserts, so incremental fire sees the new rules. Per-row key problems
5033    /// are collected in [`IngestReport::row_errors`] and skipped; a commit
5034    /// `Err` means nothing was applied.
5035    ///
5036    /// Auto-FK rule names are `auto_fk_<src_label_lowercase>_<field>` so
5037    /// distinct source labels sharing an FK field each get their own rule.
5038    pub fn ingest(
5039        &mut self,
5040        label: &str,
5041        rows: Vec<BTreeMap<String, Value>>,
5042        opts: &IngestOptions,
5043    ) -> Result<IngestReport> {
5044        self.ingest_with_edges(label, rows, opts, &[])
5045    }
5046
5047    /// [`ingest`] plus user edges in the **same** previewed WAL batch.
5048    /// A failing edge rejects the whole request; nothing is applied.
5049    pub fn ingest_with_edges(
5050        &mut self,
5051        label: &str,
5052        rows: Vec<BTreeMap<String, Value>>,
5053        opts: &IngestOptions,
5054        edges: &[(String, String, String)],
5055    ) -> Result<IngestReport> {
5056        crate::ingest::run(self, label, rows, opts, edges)
5057    }
5058
5059    /// Parse `json` as an array of objects and ingest via [`GraphDb::ingest`].
5060    ///
5061    /// JSON `null` fields are silently omitted (not stored, not a row error).
5062    /// Nested objects and arrays-of-objects are a per-row error (row skipped).
5063    /// Parse failures and a top-level value that is not an array of objects
5064    /// return [`GraphError::IngestError`].
5065    pub fn ingest_json(
5066        &mut self,
5067        label: &str,
5068        json: &str,
5069        opts: &IngestOptions,
5070    ) -> Result<IngestReport> {
5071        crate::ingest::run_json(self, label, json, opts)
5072    }
5073
5074    fn commit_logged_batch(
5075        &mut self,
5076        ops: Vec<BatchOp>,
5077        ingest: Option<(String, usize)>,
5078        // Two-source rule: write_batch_authz threads authz here directly (never
5079        // touches pending_write_authz); query_write_authz sets the field instead
5080        // and passes None.  Only one source is non-None per call.
5081        param_authz: Option<WriteAuthz>,
5082    ) -> Result<(usize, usize)> {
5083        // Read-only guard: catches empty-batch calls before the early-return
5084        // that skips log_then_apply_with, ensuring all mutation entry points fail.
5085        if self.read_only {
5086            return Err(GraphError::ReadOnly);
5087        }
5088        // Ensure provenance is decoded before MutPreview accesses it
5089        // (note_delete_rule / is_rule_owned may call engine.provenance()).
5090        self.engine.ensure_provenance_loaded_mut();
5091
5092        // ── Authz pre-check ──────────────────────────────────────────────────
5093        // Evaluate the decision table per-op BEFORE MutPreview so that a denial
5094        // produces no WAL frame (all-or-nothing at the authz boundary extends
5095        // the existing validate-then-apply contract to role-scope checks).
5096        //
5097        // `batch_created` tracks key→label for nodes created by earlier ops in
5098        // THIS batch, so InsertEdgeUpsert can count same-batch placeholder nodes
5099        // as visible without needing to call `self.ids.get` on not-yet-committed
5100        // keys (they won't be there yet).
5101        //
5102        // Two-source rule: param_authz (write_batch_authz path) takes precedence;
5103        // fall back to self.pending_write_authz (query_write_authz/Cypher path).
5104        // Cloning the field copy avoids a simultaneous borrow of self.ids below.
5105        let authz_opt = param_authz.or_else(|| self.pending_write_authz.clone());
5106        if let Some(ref authz) = authz_opt {
5107            let mut batch_created: BTreeMap<String, String> = BTreeMap::new();
5108            for op in &ops {
5109                self.check_single_op_authz(authz, op, &batch_created)?;
5110                // Update batch_created after a passing authz check so that
5111                // subsequent ops in this batch see the nodes as "about to exist".
5112                match op {
5113                    BatchOp::InsertNode { label, key, .. } => {
5114                        // Only track genuinely new nodes (absent from the
5115                        // snapshot at authz-check time). A pre-existing visible
5116                        // key would be a DuplicateKey — not a real creation —
5117                        // so MutPreview handles it. Letting it into batch_created
5118                        // would allow a later SetProp to bypass update_labels
5119                        // via the "batch-created → always updatable" ruling
5120                        // (delete+recreate exploit, fix for I1 review round 2).
5121                        //
5122                        // Accepted edge: for a delete+recreate-with-different-
5123                        // label batch, node_status resolves the pre-delete
5124                        // (store) label for any subsequent update checks. This
5125                        // grants no net-new capability — a role that can delete+
5126                        // create can already place arbitrary props via
5127                        // InsertNode's own props field.
5128                        if self.ids.get(key.as_str()).is_none() {
5129                            batch_created.insert(key.clone(), label.clone());
5130                        }
5131                    }
5132                    BatchOp::InsertEdgeUpsert {
5133                        placeholder_label,
5134                        src_key,
5135                        dst_key,
5136                        ..
5137                    } => {
5138                        // Both endpoints will be created if not already in store.
5139                        for ep_key in [src_key, dst_key] {
5140                            if self.ids.get(ep_key.as_str()).is_none()
5141                                && !batch_created.contains_key(ep_key.as_str())
5142                            {
5143                                batch_created.insert(ep_key.clone(), placeholder_label.clone());
5144                            }
5145                        }
5146                    }
5147                    _ => {}
5148                }
5149            }
5150        }
5151
5152        let recs = {
5153            let mut preview = MutPreview::new(self);
5154            let mut recs = Vec::with_capacity(ops.len());
5155            for op in ops {
5156                match op {
5157                    BatchOp::InsertNode { label, key, props } => {
5158                        preview.check_insert_node(&key)?;
5159                        preview.note_insert_node(&key, &props);
5160                        recs.push(WalRecord::InsertNode { label, key, props });
5161                    }
5162                    BatchOp::InsertEdge {
5163                        edge_type,
5164                        src_key,
5165                        dst_key,
5166                    } => {
5167                        if preview.prepare_insert_edge(&edge_type, &src_key, &dst_key)? {
5168                            preview.note_insert_edge(&edge_type, &src_key, &dst_key);
5169                            recs.push(WalRecord::InsertEdge {
5170                                edge_type,
5171                                src_key,
5172                                dst_key,
5173                            });
5174                        }
5175                    }
5176                    BatchOp::SetProp { key, field, value } => {
5177                        preview.check_live_key(&key)?;
5178                        preview.note_set_prop(&key, &field, &value);
5179                        recs.push(WalRecord::SetProp { key, field, value });
5180                    }
5181                    BatchOp::RemoveProp { key, field } => {
5182                        if preview.prepare_remove_prop(&key, &field)? {
5183                            preview.note_remove_prop(&key, &field);
5184                            recs.push(WalRecord::RemoveProp { key, field });
5185                        }
5186                    }
5187                    BatchOp::DeleteEdge {
5188                        edge_type,
5189                        src_key,
5190                        dst_key,
5191                    } => {
5192                        if preview.prepare_delete_edge(&edge_type, &src_key, &dst_key)? {
5193                            preview.note_delete_edge(&edge_type, &src_key, &dst_key);
5194                            recs.push(WalRecord::DeleteEdge {
5195                                edge_type,
5196                                src_key,
5197                                dst_key,
5198                            });
5199                        }
5200                    }
5201                    BatchOp::DeleteNode { key } => {
5202                        preview.check_live_key(&key)?;
5203                        preview.note_delete_node(&key);
5204                        recs.push(WalRecord::DeleteNode { key });
5205                    }
5206                    BatchOp::CreateRule(def) => {
5207                        preview.check_create_rule(&def)?;
5208                        let def_bytes =
5209                            bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
5210                                detail: format!("serialize rule: {e}"),
5211                            })?;
5212                        preview.note_create_rule(&def);
5213                        recs.push(WalRecord::CreateRule { def_bytes });
5214                    }
5215                    BatchOp::DeleteRule { name } => {
5216                        preview.check_delete_rule(&name)?;
5217                        preview.note_delete_rule(&name);
5218                        recs.push(WalRecord::DeleteRule { name });
5219                    }
5220                    BatchOp::RenameNode { old_key, new_key } => {
5221                        preview.check_rename_node(&old_key, &new_key)?;
5222                        preview.note_rename_node(&old_key, &new_key);
5223                        recs.push(WalRecord::RenameNode { old_key, new_key });
5224                    }
5225                    BatchOp::InsertEdgeUpsert {
5226                        edge_type,
5227                        src_key,
5228                        dst_key,
5229                        placeholder_label,
5230                    } => {
5231                        // Auto-create any missing endpoints as plain InsertNode ops.
5232                        // Rules fire and last-change is updated for each created node.
5233                        for key in [&src_key, &dst_key] {
5234                            if !preview.has_key(key) {
5235                                preview.check_insert_node(key)?;
5236                                preview.note_insert_node(key, &[]);
5237                                recs.push(WalRecord::InsertNode {
5238                                    label: placeholder_label.clone(),
5239                                    key: key.clone(),
5240                                    props: vec![],
5241                                });
5242                            }
5243                        }
5244                        if preview.prepare_insert_edge(&edge_type, &src_key, &dst_key)? {
5245                            preview.note_insert_edge(&edge_type, &src_key, &dst_key);
5246                            recs.push(WalRecord::InsertEdge {
5247                                edge_type,
5248                                src_key,
5249                                dst_key,
5250                            });
5251                        }
5252                    }
5253                }
5254            }
5255            recs
5256        };
5257        if recs.is_empty() {
5258            return Ok((0, 0));
5259        }
5260        // rewrite_wal_dense converts every InsertNode/InsertEdge into its
5261        // *Id form, so only the dense variants can appear in `recs` here.
5262        let recs = self.rewrite_wal_dense(recs)?;
5263        let nodes_inserted = recs
5264            .iter()
5265            .filter(|r| matches!(r, WalRecord::InsertNodeId { .. }))
5266            .count();
5267        let edges_inserted = recs
5268            .iter()
5269            .filter(|r| matches!(r, WalRecord::InsertEdgeId { .. }))
5270            .count();
5271        // Ingest / write_batch / query_write: one Batch frame, one fsync per call
5272        // under Strict.  Pass self.fsync directly so Strict stays Strict —
5273        // wal_needs_sync(Strict, _) always returns true regardless of op count.
5274        // Mapping Strict → Batched (the prior bug) caused wal_needs_sync to
5275        // short-circuit on single-op batches and silently skip the fsync.
5276        // Batched fsyncs only for multi-op batches; Relaxed always skips.
5277        self.log_then_apply_with(WalRecord::Batch(recs), ingest, self.fsync)?;
5278        Ok((nodes_inserted, edges_inserted))
5279    }
5280
5281    fn commit_batch(&mut self, ops: Vec<BatchOp>) -> Result<(usize, usize)> {
5282        self.commit_logged_batch(ops, None, None)
5283    }
5284
5285    /// Commit one submission WITHOUT an fsync — for use inside `commit_group`
5286    /// and the group-commit drain thread, which do a single group fsync later.
5287    fn commit_batch_nosync(&mut self, ops: Vec<BatchOp>) -> Result<(usize, usize)> {
5288        // Restore fsync policy even on panic via a raw-pointer drop guard.
5289        // A panic here would poison the RwLock anyway, but the correct policy
5290        // must be in place if the guard is ever unwrapped.
5291        struct RestoreFsync(*mut FsyncPolicy, FsyncPolicy);
5292        impl Drop for RestoreFsync {
5293            fn drop(&mut self) {
5294                // SAFETY: the pointer is valid for the full duration of
5295                // commit_batch_nosync; the guard is dropped before the frame
5296                // returns, and GraphDb outlives this frame.
5297                unsafe {
5298                    *self.0 = self.1;
5299                }
5300            }
5301        }
5302        let saved = self.fsync;
5303        // SAFETY: raw pointer into self; guard dropped within this frame.
5304        let _g = RestoreFsync(&mut self.fsync as *mut FsyncPolicy, saved);
5305        self.fsync = FsyncPolicy::Relaxed;
5306        self.commit_logged_batch(ops, None, None)
5307    }
5308
5309    /// Commit multiple op-batches as a **group**: each submission gets its own
5310    /// WAL `Batch` frame, but there is exactly **one** `Fs::sync` for the whole
5311    /// group (under `Strict` / `Batched` policy; `Relaxed` skips all syncs).
5312    ///
5313    /// # Durability semantics
5314    ///
5315    /// A crash before the group fsync may lose **all** submissions in the group.
5316    /// A crash after the group fsync preserves all of them.  No submission is
5317    /// ever torn: each WAL frame is either fully applied on replay or dropped
5318    /// in its entirety (CRC-protected frame boundaries).
5319    ///
5320    /// Events and subscription notifications fire per-submission immediately
5321    /// after apply, which may be before the group fsync.  From a subscriber's
5322    /// perspective this is equivalent to the `Relaxed` durability window.
5323    /// Submitters using [`SharedDb::submit_batch`] only unblock after the group
5324    /// fsync, so from their perspective durability is fully guaranteed.
5325    ///
5326    /// # MVCC interplay
5327    ///
5328    /// Each submission records its own `CommitDelta`; the fold-every-K counter
5329    /// increments per submission (not per group), preserving existing reader
5330    /// snapshot semantics.
5331    ///
5332    /// # Returns
5333    ///
5334    /// One `Result<(nodes_inserted, edges_inserted)>` per input group element,
5335    /// in order.  Failures are per-submission (validation errors); the group
5336    /// fsync error (if any) is returned as the second tuple element.
5337    pub fn commit_group(
5338        &mut self,
5339        groups: Vec<Vec<BatchOp>>,
5340    ) -> (Vec<Result<(usize, usize)>>, Option<GraphError>) {
5341        let mut results = Vec::with_capacity(groups.len());
5342        for ops in groups {
5343            results.push(self.commit_batch_nosync(ops));
5344        }
5345        let any_ok = results.iter().any(|r| r.is_ok());
5346        let sync_err = if self.fsync != FsyncPolicy::Relaxed && any_ok {
5347            self.fs
5348                .sync(core_storage::fs::FileId::Wal)
5349                .map_err(GraphError::Io)
5350                .err()
5351        } else {
5352            None
5353        };
5354        (results, sync_err)
5355    }
5356
5357    /// Like [`commit_group`] but skips the group fsync entirely.
5358    ///
5359    /// Used by the drain thread to apply submissions under the write lock and
5360    /// then perform the single fsync OUTSIDE the lock (via
5361    /// `core_storage::sync_wal_at`), reducing the write-lock hold time visible
5362    /// to concurrent readers.
5363    pub fn commit_group_nosync(
5364        &mut self,
5365        groups: Vec<Vec<BatchOp>>,
5366    ) -> Vec<Result<(usize, usize)>> {
5367        let mut results = Vec::with_capacity(groups.len());
5368        for ops in groups {
5369            results.push(self.commit_batch_nosync(ops));
5370        }
5371        results
5372    }
5373
5374    pub fn insert_node(
5375        &mut self,
5376        label: &str,
5377        key: &str,
5378        props: Vec<(String, Value)>,
5379    ) -> Result<()> {
5380        if self.read_only {
5381            return Err(GraphError::ReadOnly);
5382        }
5383        MutPreview::new(self).check_insert_node(key)?;
5384        self.log_dense(vec![WalRecord::InsertNode {
5385            label: label.into(),
5386            key: key.into(),
5387            props,
5388        }])
5389    }
5390
5391    pub fn insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
5392        if self.read_only {
5393            return Err(GraphError::ReadOnly);
5394        }
5395        if !MutPreview::new(self).prepare_insert_edge(edge_type, src_key, dst_key)? {
5396            return Ok(false);
5397        }
5398        self.log_dense(vec![WalRecord::InsertEdge {
5399            edge_type: edge_type.into(),
5400            src_key: src_key.into(),
5401            dst_key: dst_key.into(),
5402        }])?;
5403        Ok(true)
5404    }
5405
5406    pub fn set_prop(&mut self, key: &str, field: &str, value: Value) -> Result<()> {
5407        if self.read_only {
5408            return Err(GraphError::ReadOnly);
5409        }
5410        if let Some(view_name) = self.view_store.view_for_prop(field) {
5411            return Err(GraphError::ViewPropReadOnly {
5412                view_name: view_name.to_string(),
5413            });
5414        }
5415        MutPreview::new(self).check_live_key(key)?;
5416        self.log_dense(vec![WalRecord::SetProp {
5417            key: key.into(),
5418            field: field.into(),
5419            value,
5420        }])
5421    }
5422
5423    /// Remove a property. Returns `Ok(false)` (and does not log) if the field
5424    /// is already absent. Unknown or tombstoned keys are `Err(KeyNotFound)`.
5425    pub fn remove_prop(&mut self, key: &str, field: &str) -> Result<bool> {
5426        if self.read_only {
5427            return Err(GraphError::ReadOnly);
5428        }
5429        if let Some(view_name) = self.view_store.view_for_prop(field) {
5430            return Err(GraphError::ViewPropReadOnly {
5431                view_name: view_name.to_string(),
5432            });
5433        }
5434        if !MutPreview::new(self).prepare_remove_prop(key, field)? {
5435            return Ok(false);
5436        }
5437        self.log_then_apply(WalRecord::RemoveProp {
5438            key: key.into(),
5439            field: field.into(),
5440        })?;
5441        Ok(true)
5442    }
5443
5444    /// Delete a user edge. Returns `Ok(false)` (and does not log) if the edge
5445    /// is absent. Unknown keys are `Err(KeyNotFound)`. Rule-owned edges — in
5446    /// provenance, or a pair a live rule would derive — are `Err(RuleOwned)`
5447    /// (the rule would just put the edge back; delete or change the rule).
5448    pub fn delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
5449        if self.read_only {
5450            return Err(GraphError::ReadOnly);
5451        }
5452        if !MutPreview::new(self).prepare_delete_edge(edge_type, src_key, dst_key)? {
5453            return Ok(false);
5454        }
5455        self.log_then_apply(WalRecord::DeleteEdge {
5456            edge_type: edge_type.into(),
5457            src_key: src_key.into(),
5458            dst_key: dst_key.into(),
5459        })?;
5460        Ok(true)
5461    }
5462
5463    /// Delete a live node. Unknown or already-tombstoned keys are
5464    /// `Err(KeyNotFound)` and are not logged. Validation runs before the WAL
5465    /// write; `apply` of a logged `DeleteNode` for an already-tombstoned key
5466    /// (crash window) is a clean no-op.
5467    ///
5468    /// Returns a [`DeleteReport`] with counts of manual and derived edges
5469    /// removed (computed from live state before the deletion is applied).
5470    pub fn delete_node(&mut self, key: &str) -> Result<DeleteReport> {
5471        if self.read_only {
5472            return Err(GraphError::ReadOnly);
5473        }
5474        // Provenance must be loaded before we query provenance_touching.
5475        self.engine.ensure_provenance_loaded_mut();
5476        let id = self
5477            .ids
5478            .get(key)
5479            .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
5480
5481        // Count edges before the delete is applied so we can report counts.
5482        let derived_set: BTreeSet<(u32, u32, u32)> = self
5483            .engine
5484            .provenance_touching(id)
5485            .map(|(_, etype, src, dst)| (etype, src, dst))
5486            .collect();
5487        let derived_edges = derived_set.len() as u64;
5488
5489        let mut total_topo = 0u64;
5490        let tv = self.topo_view();
5491        for et in tv.etypes() {
5492            total_topo += tv.neighbors(et, Direction::Out, id).len() as u64
5493                + tv.neighbors(et, Direction::In, id).len() as u64;
5494        }
5495        // For symmetric rules (e.g. Overlap), a→b and b→a are two separate directed
5496        // triples in both the topo scan (Out and In from id) and in provenance_touching.
5497        // The subtraction remains correct because both counts include both directions.
5498        let manual_edges = total_topo.saturating_sub(derived_edges);
5499
5500        self.log_then_apply(WalRecord::DeleteNode { key: key.into() })?;
5501        Ok(DeleteReport {
5502            manual_edges,
5503            derived_edges,
5504        })
5505    }
5506
5507    /// Rename a live node's key.  The dense id (and therefore all edges,
5508    /// props, history, and last-change tracking) is unaffected.
5509    ///
5510    /// Returns `Err(KeyNotFound)` if `old` is not a live key.
5511    /// Returns `Err(DuplicateKey)` if `new` is already live.
5512    pub fn rename_node(&mut self, old: &str, new: &str) -> Result<()> {
5513        if self.read_only {
5514            return Err(GraphError::ReadOnly);
5515        }
5516        MutPreview::new(self).check_rename_node(old, new)?;
5517        self.log_then_apply(WalRecord::RenameNode {
5518            old_key: old.into(),
5519            new_key: new.into(),
5520        })
5521    }
5522
5523    /// Return the IVF drift counter for the dst-side candidate index of `rule`.
5524    /// `None` if the rule does not exist or is not approximate.
5525    ///
5526    /// The drift counter increments on IVF insert/remove after the last fit.
5527    /// When dst-side drift exceeds [`core_rules::IVF_DRIFT_REBUILD`], apply
5528    /// WAL-logs `RebuildRule` as a second commit (rebuild resets the counter).
5529    pub fn ivf_dst_drift(&self, rule: &str) -> Option<u64> {
5530        // SideIvfExport = (centroids, node→cluster, drift)
5531        self.engine
5532            .export_ivf_state()
5533            .remove(rule)
5534            .map(|(_src, dst)| dst.2)
5535    }
5536
5537    /// Validate and WAL-log a new rule, then backfill derived edges inside apply.
5538    /// Validation and duplicate-name check run before logging so invalid rules
5539    /// never enter the WAL.
5540    pub fn create_rule(&mut self, def: RuleDef) -> Result<()> {
5541        if self.read_only {
5542            return Err(GraphError::ReadOnly);
5543        }
5544        MutPreview::new(self).check_create_rule(&def)?;
5545        let def_bytes = bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
5546            detail: format!("serialize rule: {e}"),
5547        })?;
5548        self.log_then_apply(WalRecord::CreateRule { def_bytes })
5549    }
5550
5551    /// WAL-log rule deletion. Returns RuleNotFound if the rule does not exist.
5552    pub fn delete_rule(&mut self, name: &str) -> Result<()> {
5553        if self.read_only {
5554            return Err(GraphError::ReadOnly);
5555        }
5556        MutPreview::new(self).check_delete_rule(name)?;
5557        self.log_then_apply(WalRecord::DeleteRule { name: name.into() })
5558    }
5559
5560    /// Return a snapshot of all registered rules.
5561    pub fn rules(&self) -> Vec<RuleDef> {
5562        self.engine.rules().cloned().collect()
5563    }
5564
5565    // -----------------------------------------------------------------------
5566    // Rule suggestion API
5567    // -----------------------------------------------------------------------
5568
5569    /// Profile the database and suggest linking rules with previewed edge counts.
5570    ///
5571    /// Uses the default seed ([`core_rules::SUGGEST_DEFAULT_SEED`]) for deterministic
5572    /// sampling. Suggestions are sorted by estimated edge count (descending).
5573    /// **NO auto-accept** — call [`GraphDb::create_rule`] explicitly to apply.
5574    pub fn suggest_rules(&self) -> Vec<core_rules::RuleSuggestion> {
5575        self.suggest_rules_seeded(core_rules::SUGGEST_DEFAULT_SEED)
5576    }
5577
5578    /// Like [`suggest_rules`] but with a caller-supplied RNG seed for
5579    /// reproducibility. Same seed + same data = identical output.
5580    pub fn suggest_rules_seeded(&self, seed: u64) -> Vec<core_rules::RuleSuggestion> {
5581        self.suggest_rules_with_config(&core_rules::suggest::SuggestConfig::default(), seed)
5582            .suggestions
5583    }
5584
5585    /// [`suggest_rules_seeded`] with a fully custom [`SuggestConfig`].
5586    ///
5587    /// Returns a [`core_rules::SuggestReport`] that includes both the candidate list
5588    /// and a `truncated` flag indicating whether the global budget fired before all
5589    /// candidates were evaluated.
5590    pub fn suggest_rules_with_config(
5591        &self,
5592        config: &core_rules::suggest::SuggestConfig,
5593        seed: u64,
5594    ) -> core_rules::SuggestReport {
5595        use std::collections::BTreeMap;
5596
5597        // Collect (node_id, key) pairs per label, skipping tombstoned nodes.
5598        let mut label_nodes: BTreeMap<String, Vec<(u32, String)>> = BTreeMap::new();
5599        for id in 0..self.ids.len() as u32 {
5600            let Some(key) = self.ids.key_of(id) else {
5601                continue;
5602            };
5603            let Some(&sym) = self.labels.get(id as usize) else {
5604                continue;
5605            };
5606            if sym == u32::MAX {
5607                continue; // tombstoned
5608            }
5609            let Some(label) = self.syms.resolve(sym) else {
5610                continue;
5611            };
5612            label_nodes
5613                .entry(label.to_string())
5614                .or_default()
5615                .push((id, key.to_string()));
5616        }
5617
5618        let existing = self.rules();
5619        let pv = build_props_view(&self.props, &self.base);
5620        let all_fields: Vec<String> = pv.field_names();
5621
5622        core_rules::suggest::suggest_rules(
5623            &label_nodes,
5624            &|id, field| pv.get(id, field).map(|vr| vr.into_value()),
5625            &all_fields,
5626            &existing,
5627            config,
5628            seed,
5629        )
5630    }
5631
5632    /// Recompute a rule's derived edges from scratch. WAL-logged so un-trip
5633    /// plus later mutations replay identically (rebuild is a pure function
5634    /// of state).
5635    ///
5636    /// Only exit from the tripped latch: if the full desired set fits the
5637    /// budget, it is applied completely and `tripped` clears; if it still
5638    /// exceeds the budget, provenance is left untouched and `tripped` stays
5639    /// true. Counts as a fire evaluation (see [`RuleStats::fires`]).
5640    /// Unknown rule → `RuleNotFound`, nothing logged.
5641    pub fn rebuild_rule(&mut self, name: &str) -> Result<()> {
5642        if self.read_only {
5643            return Err(GraphError::ReadOnly);
5644        }
5645        if !self.engine.rules().any(|r| r.name == name) {
5646            return Err(GraphError::RuleNotFound { name: name.into() });
5647        }
5648        self.log_then_apply(WalRecord::RebuildRule { name: name.into() })
5649    }
5650
5651    // -----------------------------------------------------------------------
5652    // Materialized view API
5653    // -----------------------------------------------------------------------
5654
5655    /// Register a new materialized property view, backfill its values for all
5656    /// existing nodes, and WAL-log the definition.
5657    ///
5658    /// # Errors
5659    /// - `ReadOnly`: called on an as-of instance.
5660    /// - `RuleInvalid`: name collision, view_prop collision, or invalid def.
5661    pub fn create_view(&mut self, def: ViewDef) -> Result<()> {
5662        if self.read_only {
5663            return Err(GraphError::ReadOnly);
5664        }
5665        // Pre-validate before WAL write.
5666        def.validate()
5667            .map_err(|e| GraphError::RuleInvalid { detail: e })?;
5668        if self.view_store.has_view(&def.name) {
5669            return Err(GraphError::RuleInvalid {
5670                detail: format!("view {:?} already exists", def.name),
5671            });
5672        }
5673        if let Some(existing) = self.view_store.view_for_prop(&def.view_prop) {
5674            return Err(GraphError::RuleInvalid {
5675                detail: format!(
5676                    "view_prop {:?} is already used by view {:?}",
5677                    def.view_prop, existing
5678                ),
5679            });
5680        }
5681        let def_bytes = bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
5682            detail: format!("serialize view: {e}"),
5683        })?;
5684        // Enable delta accumulation before the view is registered so subsequent
5685        // incremental edge events reach view maintenance from this point onward.
5686        // (The backfill inside create_view reads topo directly; it does not rely
5687        // on pending deltas.)
5688        self.engine.set_emit_deltas(true);
5689        self.log_then_apply(WalRecord::CreateView { def_bytes })
5690    }
5691
5692    /// Remove a named view and delete its values from every node.
5693    ///
5694    /// # Errors
5695    /// - `ReadOnly`: called on an as-of instance.
5696    /// - `RuleNotFound`: view does not exist.
5697    pub fn delete_view(&mut self, name: &str) -> Result<()> {
5698        if self.read_only {
5699            return Err(GraphError::ReadOnly);
5700        }
5701        if !self.view_store.has_view(name) {
5702            return Err(GraphError::RuleNotFound { name: name.into() });
5703        }
5704        let result = self.log_then_apply(WalRecord::DeleteView { name: name.into() });
5705        // After deletion, disable accumulation if no listeners remain.
5706        if !self.needs_emit_deltas() {
5707            self.engine.set_emit_deltas(false);
5708        }
5709        result
5710    }
5711
5712    /// Snapshot of all registered view definitions.
5713    pub fn views(&self) -> Vec<ViewDef> {
5714        self.view_store.views().cloned().collect()
5715    }
5716
5717    // -----------------------------------------------------------------------
5718    // Full-text-lite API
5719    // -----------------------------------------------------------------------
5720
5721    /// Enable full-text indexing for all nodes of `label` on property `field`.
5722    ///
5723    /// After this call, every subsequent write to `(label, field)` is reflected
5724    /// in the index incrementally.  Existing nodes are backfilled immediately.
5725    /// The declaration is persisted as a WAL record; the index itself is rebuilt
5726    /// from scratch on re-open (no snapshot format changes).
5727    ///
5728    /// # Errors
5729    /// - [`GraphError::ReadOnly`]: called on an as-of instance.
5730    /// - [`GraphError::RuleInvalid`]: `(label, field)` is already indexed.
5731    pub fn enable_fulltext(&mut self, label: &str, field: &str) -> Result<()> {
5732        if self.read_only {
5733            return Err(GraphError::ReadOnly);
5734        }
5735        if self.fulltext.is_enabled(label, field) {
5736            return Err(GraphError::RuleInvalid {
5737                detail: format!("full-text index for ({label:?}, {field:?}) already enabled"),
5738            });
5739        }
5740        self.log_then_apply(WalRecord::EnableFulltext {
5741            label: label.into(),
5742            field: field.into(),
5743        })
5744    }
5745
5746    /// Disable full-text indexing for `(label, field)` and drop its postings.
5747    ///
5748    /// # Errors
5749    /// - [`GraphError::ReadOnly`]: called on an as-of instance.
5750    /// - [`GraphError::RuleNotFound`]: `(label, field)` is not currently indexed.
5751    pub fn disable_fulltext(&mut self, label: &str, field: &str) -> Result<()> {
5752        if self.read_only {
5753            return Err(GraphError::ReadOnly);
5754        }
5755        if !self.fulltext.is_enabled(label, field) {
5756            return Err(GraphError::RuleNotFound {
5757                name: format!("fulltext({label},{field})"),
5758            });
5759        }
5760        self.log_then_apply(WalRecord::DisableFulltext {
5761            label: label.into(),
5762            field: field.into(),
5763        })
5764    }
5765
5766    /// Whether `(label, field)` is currently indexed for full-text search.
5767    pub fn is_fulltext_enabled(&self, label: &str, field: &str) -> bool {
5768        self.fulltext.is_enabled(label, field)
5769    }
5770
5771    /// Every `(label, field)` pair with a live full-text index, sorted.
5772    ///
5773    /// Note that [`GraphDb::search`] is keyed by field alone — a pair only
5774    /// declares which nodes are *indexed*, so callers that want to search
5775    /// everything indexed should query each distinct field once.
5776    pub fn fulltext_pairs(&self) -> Vec<(String, String)> {
5777        let mut v: Vec<(String, String)> = self.fulltext.enabled_pairs().cloned().collect();
5778        v.sort();
5779        v
5780    }
5781
5782    /// Enable an equality index for all nodes of `label` on scalar property
5783    /// `field`. Subsequent `WHERE n.field = value` lookups become O(matches)
5784    /// instead of an O(N_label) scan. Existing nodes are backfilled; the
5785    /// declaration persists via WAL and the postings rebuild on re-open.
5786    ///
5787    /// # Errors
5788    /// - [`GraphError::ReadOnly`]: called on an as-of instance.
5789    /// - [`GraphError::RuleInvalid`]: `(label, field)` is already indexed.
5790    pub fn enable_index(&mut self, label: &str, field: &str) -> Result<()> {
5791        if self.read_only {
5792            return Err(GraphError::ReadOnly);
5793        }
5794        if self.prop_index.is_enabled(label, field) {
5795            return Err(GraphError::RuleInvalid {
5796                detail: format!("property index for ({label:?}, {field:?}) already enabled"),
5797            });
5798        }
5799        self.log_then_apply(WalRecord::EnableIndex {
5800            label: label.into(),
5801            field: field.into(),
5802        })
5803    }
5804
5805    /// Disable the equality index for `(label, field)` and drop its postings.
5806    ///
5807    /// # Errors
5808    /// - [`GraphError::ReadOnly`]: called on an as-of instance.
5809    /// - [`GraphError::RuleNotFound`]: `(label, field)` is not currently indexed.
5810    pub fn disable_index(&mut self, label: &str, field: &str) -> Result<()> {
5811        if self.read_only {
5812            return Err(GraphError::ReadOnly);
5813        }
5814        if !self.prop_index.is_enabled(label, field) {
5815            return Err(GraphError::RuleNotFound {
5816                name: format!("index({label},{field})"),
5817            });
5818        }
5819        self.log_then_apply(WalRecord::DisableIndex {
5820            label: label.into(),
5821            field: field.into(),
5822        })
5823    }
5824
5825    /// Whether `(label, field)` currently has an equality index.
5826    pub fn is_index_enabled(&self, label: &str, field: &str) -> bool {
5827        self.prop_index.is_enabled(label, field)
5828    }
5829
5830    /// Search a full-text-indexed field.
5831    ///
5832    /// Returns `(node_key, match_count)` pairs sorted by match_count descending,
5833    /// ties broken by key (lexicographic).  Tombstoned nodes are excluded.
5834    ///
5835    /// **Query syntax:**
5836    /// - Space-separated terms are AND'd: `"foo bar"` requires both.
5837    /// - `OR` between terms forms disjunction: `"foo OR bar"` matches either.
5838    /// - Trailing `*` on a term is a prefix match: `"rust*"` matches `rustlang`, `rusty`.
5839    /// - `AND` keyword is accepted explicitly and is the default.
5840    /// - Tokenization is unicode-alphanumeric (same as index time); case-insensitive.
5841    ///
5842    /// **Unindexed field:** returns `Ok(vec![])` if `field` is not indexed.
5843    /// Pin: this is the documented, tested, stable behavior for v1.
5844    ///
5845    /// **Memory / performance:** O(postings) lookup; no scan.  The index is
5846    /// in-memory and proportional to total indexed text across all enabled fields.
5847    ///
5848    /// **v2 grammar:** supports `"phrase"`, `-negation`, `prefix*`, `OR`, `AND`.
5849    /// Results are BM25-scored (k1=1.2, b=0.75) and sorted by score descending,
5850    /// key ascending for deterministic tiebreaking.
5851    pub fn search(&self, field: &str, query: &str) -> Vec<(String, f64)> {
5852        // Resolve node_ids to keys (excluding tombstones) then re-sort by
5853        // (score DESC, key ASC) to give a deterministic, key-lexicographic
5854        // tiebreak.  FulltextIndex::search sorts by (score DESC, node_id ASC)
5855        // which diverges from key order when nodes were not inserted in key-lex order.
5856        let mut results: Vec<(String, f64)> = self
5857            .fulltext
5858            .search(field, query, 0)
5859            .into_iter()
5860            .filter_map(|(id, score)| self.ids.key_of(id).map(|key| (key.to_string(), score)))
5861            .collect();
5862        results.sort_by(|a, b| {
5863            b.1.partial_cmp(&a.1)
5864                .unwrap_or(std::cmp::Ordering::Equal)
5865                .then(a.0.cmp(&b.0))
5866        });
5867        results
5868    }
5869
5870    /// [`search`](Self::search), stopping at the `k` best hits.
5871    ///
5872    /// Same ranking and the same deterministic tiebreak, but the index drops
5873    /// everything past `k` before any key is resolved, so a caller that wants
5874    /// the top few out of a field that matched thousands does not pay to
5875    /// materialise and re-sort the tail. `k == 0` means no limit, exactly as
5876    /// [`search`](Self::search) behaves.
5877    ///
5878    /// The BM25 scoring itself is not bounded by `k` — every candidate is
5879    /// scored either way — so this trims the resolve and the sort, not the
5880    /// search.
5881    pub fn search_top(&self, field: &str, query: &str, k: usize) -> Vec<(String, f64)> {
5882        // A tombstoned id resolves to nothing, so asking the index for exactly
5883        // `k` could return fewer. Over-fetching a little and truncating after
5884        // the filter keeps the count right without unbounding the call.
5885        let want = if k == 0 { 0 } else { k.saturating_mul(2) };
5886        let mut results: Vec<(String, f64)> = self
5887            .fulltext
5888            .search(field, query, want)
5889            .into_iter()
5890            .filter_map(|(id, score)| self.ids.key_of(id).map(|key| (key.to_string(), score)))
5891            .collect();
5892        results.sort_by(|a, b| {
5893            b.1.partial_cmp(&a.1)
5894                .unwrap_or(std::cmp::Ordering::Equal)
5895                .then(a.0.cmp(&b.0))
5896        });
5897        if k > 0 {
5898            results.truncate(k);
5899        }
5900        results
5901    }
5902
5903    /// Hybrid search: Reciprocal Rank Fusion (RRF) over fulltext + vector results.
5904    ///
5905    /// Takes up to `4*k` fulltext hits for `(text_field, query_text)` and up to
5906    /// `4*k` vector hits for `(vector_field, query_vec, min=0.0)`, then fuses
5907    /// them with RRF using a fixed constant of 60.
5908    ///
5909    /// ```text
5910    /// score(d) = Σ  1 / (60 + rank_i(d))    (rank 1-based per list)
5911    /// ```
5912    ///
5913    /// Returns the top `k` nodes by fused score, ties broken by node key
5914    /// ascending (deterministic).
5915    ///
5916    /// # Vector leg fallback
5917    ///
5918    /// When `query_vec` is empty the vector leg is skipped entirely and
5919    /// results are ranked by the text list alone through the same RRF path
5920    /// (each text result scores `1/(60 + rank)` from that single list).
5921    ///
5922    /// When `label` is `None`, the vector leg **always** returns empty results.
5923    /// Internally `label` is mapped to `""`, which does not match any rule-created
5924    /// HNSW index (all such indexes are keyed to a specific non-empty label), and
5925    /// the brute-force fallback finds no nodes with an empty label.  The fused
5926    /// ranking is therefore text-only in this case.
5927    pub fn search_hybrid(
5928        &self,
5929        text_field: &str,
5930        query_text: &str,
5931        vector_field: &str,
5932        query_vec: &[f64],
5933        label: Option<&str>,
5934        k: usize,
5935    ) -> Vec<(String, f64)> {
5936        use std::collections::HashMap;
5937
5938        const RRF_K: f64 = 60.0;
5939        let pool = 4 * k;
5940
5941        // Accumulate per-node RRF scores.
5942        let mut scores: HashMap<String, f64> = HashMap::new();
5943
5944        // Text leg.
5945        let text_hits = self.search(text_field, query_text);
5946        for (rank0, (key, _count)) in text_hits.into_iter().take(pool).enumerate() {
5947            let rank = (rank0 + 1) as f64;
5948            *scores.entry(key).or_insert(0.0) += 1.0 / (RRF_K + rank);
5949        }
5950
5951        // Vector leg (skipped when query_vec is empty).
5952        if !query_vec.is_empty() {
5953            let vec_hits = self.find_similar_vector(vector_field, label, query_vec, pool, 0.0);
5954            for (rank0, (key, _sim)) in vec_hits.into_iter().enumerate() {
5955                let rank = (rank0 + 1) as f64;
5956                *scores.entry(key).or_insert(0.0) += 1.0 / (RRF_K + rank);
5957            }
5958        }
5959
5960        // Sort: score DESC, then key ASC for deterministic tie-breaking.
5961        let mut ranked: Vec<(String, f64)> = scores.into_iter().collect();
5962        ranked.sort_by(|a, b| {
5963            b.1.partial_cmp(&a.1)
5964                .unwrap_or(std::cmp::Ordering::Equal)
5965                .then(a.0.cmp(&b.0))
5966        });
5967        ranked.truncate(k);
5968        ranked
5969    }
5970
5971    /// For DST/testing: scratch BM25 search over live nodes without the index.
5972    /// Walks every live node, re-stems field tokens, computes corpus stats, and
5973    /// returns BM25-ranked results.
5974    ///
5975    /// The oracle: the ordered key list of `search(field, q)` must equal that of
5976    /// `scratch_search(field, q)` at every quiescent state.
5977    #[doc(hidden)]
5978    pub fn scratch_search(&self, field: &str, query: &str) -> Vec<(String, f64)> {
5979        use core_storage::fulltext::{parse_query, value_tokens_stemmed_with_positions};
5980        use std::collections::BTreeMap;
5981
5982        let groups = parse_query(query);
5983        if groups.is_empty() {
5984            return vec![];
5985        }
5986
5987        // --- Pass 1: collect all live indexed nodes with stemmed token data ---
5988        struct NodeData {
5989            key: String,
5990            /// stemmed_token → positions (sorted)
5991            tokens: BTreeMap<String, Vec<u32>>,
5992            dl: u32,
5993        }
5994
5995        let mut nodes: Vec<NodeData> = Vec::new();
5996        for id in 0..self.ids.len() as u32 {
5997            let Some(key) = self.ids.key_of(id) else {
5998                continue;
5999            };
6000            let Some(&sym) = self.labels.get(id as usize) else {
6001                continue;
6002            };
6003            if sym == u32::MAX {
6004                continue;
6005            }
6006            let label = match self.syms.resolve(sym) {
6007                Some(l) => l,
6008                None => continue,
6009            };
6010            if !self.fulltext.is_enabled(label, field) {
6011                continue;
6012            }
6013            let Some(value) = self.props_view().get(id, field).map(|vr| vr.into_value()) else {
6014                continue;
6015            };
6016            // Use value_tokens_stemmed_with_positions so list elements are
6017            // separated by POSITION_GAP — identical to the index path, which
6018            // prevents phrase queries from matching across element boundaries.
6019            let stemmed_with_pos = match &value {
6020                Value::Str(_) | Value::List(_) => value_tokens_stemmed_with_positions(&value),
6021                _ => continue,
6022            };
6023            let dl = stemmed_with_pos.len() as u32;
6024            let mut tok_map: BTreeMap<String, Vec<u32>> = BTreeMap::new();
6025            for (tok, pos) in stemmed_with_pos {
6026                tok_map.entry(tok).or_default().push(pos);
6027            }
6028            nodes.push(NodeData {
6029                key: key.to_string(),
6030                tokens: tok_map,
6031                dl,
6032            });
6033        }
6034
6035        if nodes.is_empty() {
6036            return vec![];
6037        }
6038
6039        // --- BM25 corpus stats ---
6040        let n = nodes.len() as f64;
6041        let avg_dl: f64 = nodes.iter().map(|nd| nd.dl as f64).sum::<f64>() / n;
6042        // df per stemmed token across all live indexed nodes.
6043        let mut df_map: BTreeMap<&str, f64> = BTreeMap::new();
6044        for nd in &nodes {
6045            for tok in nd.tokens.keys() {
6046                *df_map.entry(tok.as_str()).or_insert(0.0) += 1.0;
6047            }
6048        }
6049
6050        const K1: f64 = 1.2;
6051        const B: f64 = 0.75;
6052
6053        // --- Pass 2: score each node against each OR-group ---
6054        let mut results: Vec<(String, f64)> = Vec::new();
6055        for nd in &nodes {
6056            let dl = nd.dl as f64;
6057            let mut total_score = 0.0f64;
6058
6059            'group: for group in &groups {
6060                let mut group_score = 0.0f64;
6061
6062                for term in group {
6063                    if term.negated {
6064                        // Negated: if doc has this stemmed token → group fails.
6065                        let present = if term.prefix {
6066                            nd.tokens.keys().any(|t| t.starts_with(term.token.as_str()))
6067                        } else {
6068                            nd.tokens.contains_key(term.token.as_str())
6069                        };
6070                        if present {
6071                            continue 'group;
6072                        }
6073                        continue;
6074                    }
6075                    if term.prefix {
6076                        // Prefix: sum BM25 for all matching stemmed tokens.
6077                        let mut prefix_matched = false;
6078                        for (tok, positions) in &nd.tokens {
6079                            if tok.starts_with(term.token.as_str()) {
6080                                let tf = positions.len() as f64;
6081                                let df = df_map.get(tok.as_str()).copied().unwrap_or(1.0);
6082                                let idf = ((n - df + 0.5) / (df + 0.5) + 1.0).ln();
6083                                let tf_norm =
6084                                    tf * (K1 + 1.0) / (tf + K1 * (1.0 - B + B * dl / avg_dl));
6085                                group_score += idf * tf_norm;
6086                                prefix_matched = true;
6087                            }
6088                        }
6089                        if !prefix_matched {
6090                            continue 'group;
6091                        }
6092                    } else {
6093                        // term.token is already stemmed by parse_query; use directly.
6094                        match nd.tokens.get(term.token.as_str()) {
6095                            None => continue 'group,
6096                            Some(positions) => {
6097                                let tf = positions.len() as f64;
6098                                let df = df_map.get(term.token.as_str()).copied().unwrap_or(1.0);
6099                                let idf = ((n - df + 0.5) / (df + 0.5) + 1.0).ln();
6100                                let tf_norm =
6101                                    tf * (K1 + 1.0) / (tf + K1 * (1.0 - B + B * dl / avg_dl));
6102                                group_score += idf * tf_norm;
6103                            }
6104                        }
6105                    }
6106                }
6107
6108                if group_score > 0.0 {
6109                    total_score += group_score;
6110                }
6111            }
6112
6113            if total_score > 0.0 {
6114                results.push((nd.key.clone(), total_score));
6115            }
6116        }
6117
6118        results.sort_by(|a, b| {
6119            b.1.partial_cmp(&a.1)
6120                .unwrap_or(std::cmp::Ordering::Equal)
6121                .then(a.0.cmp(&b.0))
6122        });
6123        results
6124    }
6125
6126    /// Return the current view-maintained value of `view_prop` for node `key`.
6127    /// Equivalent to `get_prop` but documents that it reads a view-managed column.
6128    pub fn get_view_prop(&self, key: &str, view_prop: &str) -> Option<Value> {
6129        let id = self.ids.get(key)?;
6130        self.props_view()
6131            .get(id, view_prop)
6132            .map(|vr| vr.into_value())
6133    }
6134
6135    /// For testing / DST oracle: scratch recompute of a view value for one node.
6136    ///
6137    /// Returns `None` if the node does not exist, the view does not exist, or
6138    /// the view has no result for the node (e.g. Avg with no qualifying neighbors).
6139    #[doc(hidden)]
6140    pub fn scratch_view_value(&self, key: &str, view_name: &str) -> Option<Value> {
6141        let node = self.ids.get(key)?;
6142        let def = self.view_store.views().find(|v| v.name == view_name)?;
6143        // Use TopologyView so that NeighborAgg sees base + overlay edges
6144        // without materialising a temporary Topology (I1).
6145        let topo_view = self.topo_view();
6146        core_rules::views::compute_view_value(
6147            def,
6148            node,
6149            self.props_view(),
6150            &topo_view,
6151            &self.ids,
6152            &self.syms,
6153            &self.labels,
6154        )
6155    }
6156
6157    // -----------------------------------------------------------------------
6158    // Graph algorithm API
6159    // -----------------------------------------------------------------------
6160
6161    /// Run PageRank over the unified topology (manual + derived edges).
6162    ///
6163    /// Returns a [`PageRankReport`] with scores sorted descending (ties: key
6164    /// ascending).  Set `config.edge_type` to restrict to one edge type.
6165    /// `config.converged` is `true` only when the power iteration converged
6166    /// within `config.max_iters` and within any time budget.
6167    pub fn pagerank(&self, config: &crate::algo::PageRankConfig) -> crate::algo::PageRankReport {
6168        let topo = build_topo_view(&self.topo, &self.base);
6169        let edge_props = self.edge_props_view();
6170        crate::algo::pagerank(
6171            &topo,
6172            &self.ids,
6173            &self.syms,
6174            &self.labels,
6175            &edge_props,
6176            config,
6177        )
6178    }
6179
6180    /// Weakly-connected components over the unified topology (treated as
6181    /// undirected regardless of how edges were inserted).
6182    ///
6183    /// Component IDs are the key of the smallest member in the component
6184    /// (deterministic).  Result sorted by (component_id, key).
6185    pub fn connected_components(&self, config: &crate::algo::WccConfig) -> crate::algo::WccReport {
6186        let topo = build_topo_view(&self.topo, &self.base);
6187        let edge_props = self.edge_props_view();
6188        crate::algo::wcc(
6189            &topo,
6190            &self.ids,
6191            &self.syms,
6192            &self.labels,
6193            &edge_props,
6194            config,
6195        )
6196    }
6197
6198    /// Degree centrality for every live node.
6199    ///
6200    /// `direction`: `AlgoDir::Out` = out-degree, `AlgoDir::In` = in-degree,
6201    /// `AlgoDir::Both` = out + in (total directed degree).
6202    ///
6203    /// For one-shot ranking use this; for a live property updated on every
6204    /// write, create a Degree materialized view instead (see `docs/site/algorithms.md`).
6205    pub fn degree_centrality(
6206        &self,
6207        config: &crate::algo::DegreeConfig,
6208    ) -> crate::algo::DegreeReport {
6209        let topo = build_topo_view(&self.topo, &self.base);
6210        let edge_props = self.edge_props_view();
6211        crate::algo::degree_centrality(
6212            &topo,
6213            &self.ids,
6214            &self.syms,
6215            &self.labels,
6216            &edge_props,
6217            config,
6218        )
6219    }
6220
6221    /// Louvain community detection over the unified topology (undirected).
6222    ///
6223    /// See [`crate::algo::LouvainConfig`] for edge-type/weight/label
6224    /// restriction and [`crate::algo::CommunityReport`] for the shape of the
6225    /// result (communities sorted size-desc, then smallest member key asc).
6226    pub fn communities(&self, config: &crate::algo::LouvainConfig) -> crate::algo::CommunityReport {
6227        let topo = build_topo_view(&self.topo, &self.base);
6228        let edge_props = self.edge_props_view();
6229        crate::algo::louvain(
6230            &topo,
6231            &self.ids,
6232            &self.syms,
6233            &self.labels,
6234            &edge_props,
6235            config,
6236        )
6237    }
6238
6239    /// Write a vector of `(node_key, score)` pairs as `prop_name` on each node,
6240    /// atomically via a single write-batch (one WAL frame, one fsync).
6241    ///
6242    /// # Errors
6243    /// - [`GraphError::ReadOnly`]: called on an as-of instance.
6244    /// - [`GraphError::RuleInvalid`]: `prop_name` is managed by an existing view
6245    ///   (collision check mirrors `create_view`).
6246    /// - [`GraphError::KeyNotFound`]: a key in `scores` does not exist as a live node.
6247    pub fn write_scores(&mut self, prop_name: &str, scores: &[(String, f64)]) -> Result<()> {
6248        if self.read_only {
6249            return Err(GraphError::ReadOnly);
6250        }
6251        // Collision check: refuse if prop_name is view-managed.
6252        if let Some(view_name) = self.view_store.view_for_prop(prop_name) {
6253            return Err(GraphError::RuleInvalid {
6254                detail: format!(
6255                    "prop {:?} is managed by view {:?} and cannot be written as scores",
6256                    prop_name, view_name
6257                ),
6258            });
6259        }
6260        // Refuse if prop_name is a view name itself (confusing namespace collision).
6261        if self.view_store.has_view(prop_name) {
6262            return Err(GraphError::RuleInvalid {
6263                detail: format!(
6264                    "prop_name {:?} collides with an existing view name",
6265                    prop_name
6266                ),
6267            });
6268        }
6269        // Write all scores in a single crash-atomic batch.
6270        self.write_batch(|b| {
6271            for (key, score) in scores {
6272                b.set_prop(key, prop_name, Value::Float(*score));
6273            }
6274        })?;
6275        Ok(())
6276    }
6277
6278    /// Return the value of `field` for the node with key `key`, or `None` if
6279    /// the node or field is absent.  Reads through the overlay-over-base
6280    /// `ColumnsView`, materialising base values on demand (zero heap cost for
6281    /// overlay hits; one clone per base hit).
6282    pub fn get_prop(&self, key: &str, field: &str) -> Option<Value> {
6283        let id = self.ids.get(key)?;
6284        self.props_view().get(id, field).map(|vr| vr.into_value())
6285    }
6286
6287    pub fn has_node(&self, key: &str) -> bool {
6288        self.ids.get(key).is_some()
6289    }
6290
6291    /// Borrow the raw id map. Used by `NodeMask::from_keys` to resolve keys.
6292    pub(crate) fn ids(&self) -> &IdMap {
6293        &self.ids
6294    }
6295
6296    // -----------------------------------------------------------------------
6297    // RBAC role resolution
6298    // -----------------------------------------------------------------------
6299
6300    /// Parse `roles.json` bytes from `fs`.
6301    ///
6302    /// Return values:
6303    ///   `Ok(Some(roles))` — file absent (returns `vec![]`) **or** file present
6304    ///                       and valid; in both cases `mask_for_role` uses the
6305    ///                       list normally (an absent file means no roles defined).
6306    ///   `Ok(None)`        — file present but corrupt or unrecognised version
6307    ///                       → poisoned state; `mask_for_role` returns `Err` for
6308    ///                       any role name until the file is fixed and the DB
6309    ///                       re-opened (or `apply_schema` is called to repair it).
6310    ///
6311    /// Note: `None` signals corruption, not absence — the opposite of what an
6312    /// optional "file missing" convention would suggest.  The open path stores
6313    /// this result on `db.roles` directly.
6314    fn load_roles_from_fs(fs: &F) -> Result<Option<Vec<RoleDef>>> {
6315        let bytes = fs.read(FileId::Roles).map_err(GraphError::Io)?;
6316        if bytes.is_empty() {
6317            // Empty bytes means either the file is absent or zero-byte — both
6318            // are treated identically as "no roles defined".  A zero-byte
6319            // roles.json does NOT widen access: an absent file and a zero-byte
6320            // file both resolve to an empty role list (sees nothing by default).
6321            return Ok(Some(vec![]));
6322        }
6323        match serde_json::from_slice::<RolesFile>(&bytes) {
6324            Ok(f) if f.version == 1 || f.version == 2 => Ok(Some(f.roles)),
6325            // Corrupt or unrecognised version (>2): poison the roles state.
6326            _ => Ok(None),
6327        }
6328    }
6329
6330    /// Resolve a role to a node-visibility mask against the current graph state.
6331    ///
6332    /// Returns `Err` when:
6333    /// - `roles.json` was present but corrupt at open (poisoned state), or
6334    /// - `role` does not match any defined role name.
6335    ///
6336    /// The mask union is: explicit `keys` (unknown keys silently ignored) plus
6337    /// all live nodes carrying any label in `labels`.  Label resolution is live
6338    /// — new nodes of an allowed label are visible without re-applying the
6339    /// schema.  An empty union = empty mask = sees nothing.
6340    pub fn mask_for_role(&self, role: &str) -> Result<crate::mask::NodeMask> {
6341        let roles = self.roles.as_ref().ok_or_else(|| GraphError::Corrupt {
6342            detail:
6343                "roles.json was corrupt at open; fix the file and re-open to restore role access"
6344                    .into(),
6345        })?;
6346        let def = roles
6347            .iter()
6348            .find(|r| r.name == role)
6349            .ok_or_else(|| GraphError::KeyNotFound {
6350                key: format!("role:{role}"),
6351            })?;
6352
6353        let mut visible = std::collections::HashSet::new();
6354
6355        // Key leg: resolve explicit keys to dense ids (unknown keys ignored).
6356        for key in &def.keys {
6357            if let Some(id) = self.ids.get(key) {
6358                visible.insert(id);
6359            }
6360        }
6361
6362        // Label leg: live scan — iterate labels vec for matching symbol.
6363        for label_name in &def.labels {
6364            if let Some(sym) = self.syms.get(label_name) {
6365                for (i, &s) in self.labels.iter().enumerate() {
6366                    if s == sym {
6367                        visible.insert(i as u32);
6368                    }
6369                }
6370            }
6371        }
6372
6373        Ok(crate::mask::NodeMask::from_ids(visible))
6374    }
6375
6376    /// Return the current list of role definitions.
6377    ///
6378    /// Returns an empty list when no roles are defined or when `roles.json`
6379    /// was corrupt at open (check [`mask_for_role`](Self::mask_for_role) for
6380    /// the fail-loud error in that case).
6381    pub fn roles(&self) -> Vec<RoleDef> {
6382        self.roles.as_deref().unwrap_or(&[]).to_vec()
6383    }
6384
6385    // ── Role-scoped write authz ───────────────────────────────────────────────
6386
6387    /// Execute `ops` with optional role-scoped write authorization.
6388    ///
6389    /// - `None` → full authority, identical to [`write_batch`](Self::write_batch)
6390    ///   (zero-cost bypass of all authz checks).
6391    /// - `Some(authz)` → the decision table is evaluated per-op BEFORE any WAL
6392    ///   record is built.  A denial returns an error with no WAL frame written
6393    ///   (all-or-nothing at the authz boundary, then at the MutPreview boundary).
6394    ///
6395    /// See the plan's "authz decision table" section for the full semantics.
6396    pub fn write_batch_authz(
6397        &mut self,
6398        authz: Option<&WriteAuthz>,
6399        ops: Vec<BatchOp>,
6400    ) -> Result<(usize, usize)> {
6401        // Thread authz as a direct parameter — never touches pending_write_authz.
6402        self.commit_logged_batch(ops, None, authz.cloned())
6403    }
6404
6405    /// Execute a Cypher write statement with role-scoped write authorization.
6406    ///
6407    /// Resolves scope + mask from `self.roles` inside the call (same write-guard
6408    /// lifetime as execution, satisfying §5 lock discipline).  The resolved
6409    /// `WriteAuthz` is stored as `pending_write_authz` for the duration of the
6410    /// call so that all inner `batch.commit()` calls are authz-checked.
6411    ///
6412    /// MERGE is handled specially: the MERGE scope precondition (§3.3) is
6413    /// checked in `exec_merge` BEFORE `has_node` to close the §6.2
6414    /// timing-oracle item (hidden ≡ absent for unscoped roles).
6415    ///
6416    /// Roles with `write: None` (v1 behavior) → `RoleWriteDenied` with
6417    /// "this endpoint is not permitted".
6418    pub fn query_write_authz(
6419        &mut self,
6420        role: &str,
6421        cypher: &str,
6422        params: &BTreeMap<String, Value>,
6423    ) -> Result<ResultSet> {
6424        // Resolve scope (fails fast if role has no write scope).
6425        // write:None → byte-identical v1 blanket-403 body (plan §v1-sidecar mandate).
6426        let scope =
6427            {
6428                let roles = self.roles.as_deref().ok_or_else(|| GraphError::Corrupt {
6429                    detail: "roles.json was corrupt at open; re-open to restore role access".into(),
6430                })?;
6431                let def = roles.iter().find(|r| r.name == role).ok_or_else(|| {
6432                    GraphError::KeyNotFound {
6433                        key: format!("role:{role}"),
6434                    }
6435                })?;
6436                def.write
6437                    .clone()
6438                    .ok_or_else(|| GraphError::RoleWriteDenied {
6439                        reason: "role-bound token: writes are not permitted".into(),
6440                    })?
6441            };
6442        // Resolve mask inside the call (same guard, §5 coherence).
6443        let mask = self.mask_for_role(role)?;
6444        self.pending_write_authz = Some(WriteAuthz {
6445            role: role.into(),
6446            scope,
6447            mask,
6448        });
6449        // RAII guard: always clears pending_write_authz on scope exit, including
6450        // on panic or early-return, mirroring the RestoreEmitDeltas precedent.
6451        struct ClearPendingAuthzOnDrop(*mut Option<WriteAuthz>);
6452        impl Drop for ClearPendingAuthzOnDrop {
6453            fn drop(&mut self) {
6454                // SAFETY: pointer into the owning GraphDb; guard is dropped
6455                // within this function's frame before it returns.
6456                unsafe { *self.0 = None };
6457            }
6458        }
6459        // SAFETY: raw pointer into self; guard dropped before this fn returns.
6460        let _authz_guard = ClearPendingAuthzOnDrop(&mut self.pending_write_authz as *mut _);
6461        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
6462            detail: format!("lex: {e}"),
6463        })?;
6464        let stmt = parse_write(&tokens).map_err(|e| GraphError::QueryError {
6465            detail: format!("parse: {e}"),
6466        })?;
6467        self.exec_write_stmt(stmt, params)
6468    }
6469
6470    /// Execute `ops` with optional role-scoped write authorization, suppressing
6471    /// fsync (for use inside the group-commit drain thread, which performs one
6472    /// group fsync after releasing the write lock).
6473    ///
6474    /// Identical to [`write_batch_authz`] except the fsync policy is temporarily
6475    /// forced to `Relaxed` for the duration of the call, matching the drain-thread
6476    /// contract established by [`commit_batch_nosync`].
6477    pub(crate) fn write_batch_authz_nosync(
6478        &mut self,
6479        authz: Option<&WriteAuthz>,
6480        ops: Vec<BatchOp>,
6481    ) -> Result<(usize, usize)> {
6482        let saved = self.fsync;
6483        struct RestoreFsync(*mut FsyncPolicy, FsyncPolicy);
6484        impl Drop for RestoreFsync {
6485            fn drop(&mut self) {
6486                // SAFETY: pointer into the owning GraphDb; guard is dropped
6487                // within the enclosing function's frame before it returns.
6488                unsafe { *self.0 = self.1 };
6489            }
6490        }
6491        // SAFETY: raw pointer into self; guard dropped before this fn returns.
6492        let _g = RestoreFsync(&mut self.fsync as *mut FsyncPolicy, saved);
6493        self.fsync = FsyncPolicy::Relaxed;
6494        self.commit_logged_batch(ops, None, authz.cloned())
6495    }
6496
6497    /// Execute a `/ingest` request with role-scoped write authorization.
6498    ///
6499    /// Resolves the role's `WriteScope` and `NodeMask` inside this call (same
6500    /// write-guard lifetime as the mutation, satisfying §5 lock discipline).
6501    /// Sets `pending_write_authz` for the duration of the call so that the
6502    /// `commit_ingest` → `commit_logged_batch` path picks up the authz context
6503    /// and evaluates the decision table per-op before any WAL write.
6504    ///
6505    /// §7.3: roles with empty `create_labels` will see every `InsertNode` op
6506    /// denied by the decision table with the appropriate §4.3 scope reason;
6507    /// no special HTTP-layer check is needed.
6508    ///
6509    /// Roles with `write: None` return `RoleWriteDenied` with
6510    /// "writes are not permitted" (byte-identical to v1 blanket 403).
6511    pub fn ingest_with_edges_authz(
6512        &mut self,
6513        role: &str,
6514        label: &str,
6515        rows: Vec<std::collections::BTreeMap<String, Value>>,
6516        opts: &crate::ingest::IngestOptions,
6517        edges: &[(String, String, String)],
6518    ) -> Result<crate::ingest::IngestReport> {
6519        // Resolve scope (fails fast if role has no write scope).
6520        // write:None → byte-identical v1 blanket-403 body (plan §v1-sidecar mandate).
6521        let scope =
6522            {
6523                let roles = self.roles.as_deref().ok_or_else(|| GraphError::Corrupt {
6524                    detail: "roles.json was corrupt at open; re-open to restore role access".into(),
6525                })?;
6526                let def = roles.iter().find(|r| r.name == role).ok_or_else(|| {
6527                    GraphError::KeyNotFound {
6528                        key: format!("role:{role}"),
6529                    }
6530                })?;
6531                def.write
6532                    .clone()
6533                    .ok_or_else(|| GraphError::RoleWriteDenied {
6534                        reason: "role-bound token: writes are not permitted".into(),
6535                    })?
6536            };
6537        let mask = self.mask_for_role(role)?;
6538        self.pending_write_authz = Some(WriteAuthz {
6539            role: role.into(),
6540            scope,
6541            mask,
6542        });
6543        // RAII guard: always clears pending_write_authz on scope exit, including
6544        // on panic or early-return, mirroring the RestoreEmitDeltas precedent.
6545        struct ClearPendingAuthzOnDrop(*mut Option<WriteAuthz>);
6546        impl Drop for ClearPendingAuthzOnDrop {
6547            fn drop(&mut self) {
6548                // SAFETY: pointer into the owning GraphDb; guard is dropped
6549                // within this function's frame before it returns.
6550                unsafe { *self.0 = None };
6551            }
6552        }
6553        // SAFETY: raw pointer into self; guard dropped before this fn returns.
6554        let _authz_guard = ClearPendingAuthzOnDrop(&mut self.pending_write_authz as *mut _);
6555        self.ingest_with_edges(label, rows, opts, edges)
6556    }
6557
6558    /// Evaluate the write-authz decision table for one `BatchOp`.
6559    ///
6560    /// Called by `commit_logged_batch` for each op when `pending_write_authz`
6561    /// is `Some`, BEFORE MutPreview.  A denial returns an error immediately;
6562    /// the remaining ops are not evaluated and no WAL frame is written.
6563    ///
6564    /// `batch_created` carries the key→label pairs of nodes that earlier ops in
6565    /// THIS batch will create.  Used by `InsertEdgeUpsert` to count same-batch
6566    /// placeholder nodes as visible (spec: "a placeholder endpoint the SAME
6567    /// batch creates counts as visible if its label passed the create-class gate").
6568    fn check_single_op_authz(
6569        &self,
6570        authz: &WriteAuthz,
6571        op: &BatchOp,
6572        batch_created: &BTreeMap<String, String>,
6573    ) -> Result<()> {
6574        // Helper: 3-way node status under the authz mask.
6575        //
6576        // Batch-created nodes (from earlier InsertNode in THIS batch) are treated
6577        // as Visible with their recorded label — their create gate already passed
6578        // and they are not yet in self.ids (not committed).  This fixes the
6579        // MERGE+ON CREATE SET case where InsertNode + SetProp arrive together:
6580        // the SetProp must not see the node as Absent.
6581        let node_status = |key: &str| -> NodeAuthzStatus {
6582            if let Some(label) = batch_created.get(key) {
6583                return NodeAuthzStatus::Visible(label.clone());
6584            }
6585            match self.ids.get(key) {
6586                None => NodeAuthzStatus::Absent,
6587                Some(id) if !authz.mask.contains_id(id) => NodeAuthzStatus::Hidden,
6588                Some(id) => {
6589                    let label = self
6590                        .labels
6591                        .get(id as usize)
6592                        .and_then(|&sym| {
6593                            if sym == u32::MAX {
6594                                None
6595                            } else {
6596                                self.syms.resolve(sym).map(str::to_string)
6597                            }
6598                        })
6599                        .unwrap_or_default();
6600                    NodeAuthzStatus::Visible(label)
6601                }
6602            }
6603        };
6604
6605        // Helper: is an InsertEdgeUpsert endpoint visible?
6606        // A same-batch placeholder counts as visible if its label passed
6607        // the create-class gate (spec "upsert placeholder-counts-as-visible").
6608        let upsert_ep_visible = |ep_key: &str, placeholder_label: &str| -> bool {
6609            // In store and visible?
6610            if let Some(id) = self.ids.get(ep_key) {
6611                return authz.mask.contains_id(id);
6612            }
6613            // Created by an earlier op in this batch?
6614            if let Some(created_label) = batch_created.get(ep_key) {
6615                return authz.scope.create_labels.contains(created_label);
6616            }
6617            // Will be created by THIS InsertEdgeUpsert: placeholder_label
6618            // must pass the create-class gate.
6619            authz
6620                .scope
6621                .create_labels
6622                .contains(&placeholder_label.to_string())
6623        };
6624
6625        match op {
6626            // RenameNode / CreateRule / DeleteRule: defense-in-depth gate.
6627            // These ops are never routed to role-scoped paths by the HTTP layer,
6628            // but we 403 them here to close any future bypass route.
6629            BatchOp::RenameNode { .. } | BatchOp::CreateRule(_) | BatchOp::DeleteRule { .. } => {
6630                return Err(GraphError::RoleWriteDenied {
6631                    reason: "role-bound token: this endpoint is not permitted".into(),
6632                });
6633            }
6634
6635            // ── CREATE-class: InsertNode ─────────────────────────────────────
6636            //
6637            // Decision table row 1 (scope-before-lookup): check label in
6638            // create_labels BEFORE any key lookup.  This is the structural
6639            // closure of the §6.2 timing-oracle item — the denial fires even
6640            // when the store is EMPTY (see test_create_scope_denied_empty_store).
6641            BatchOp::InsertNode { label, key, .. } => {
6642                if !authz.scope.create_labels.contains(label) {
6643                    return Err(GraphError::RoleWriteDenied {
6644                        reason: format!(
6645                            "role-bound token: label '{}' not in write scope (create_labels)",
6646                            label
6647                        ),
6648                    });
6649                }
6650                // Row 2/3: key lookup.
6651                match self.ids.get(key.as_str()) {
6652                    Some(id) if authz.mask.contains_id(id) => {
6653                        // Visible: DuplicateKey — let MutPreview handle this.
6654                    }
6655                    Some(_) => {
6656                        // Hidden: indistinguishable from absent to the role.
6657                        return Err(GraphError::RoleWriteDenied {
6658                            reason: "role-bound token: target node not visible".into(),
6659                        });
6660                    }
6661                    None => {
6662                        // Absent: proceed (create).
6663                    }
6664                }
6665            }
6666
6667            // ── UPDATE-class: SetProp, RemoveProp ────────────────────────────
6668            BatchOp::SetProp { key, .. } | BatchOp::RemoveProp { key, .. } => {
6669                if batch_created.contains_key(key.as_str()) {
6670                    // Batch-created node: create gate already passed this batch.
6671                    // Updating it in the same batch is always allowed, regardless
6672                    // of update_labels (ruling §3.5: "writer just created it").
6673                } else {
6674                    let label = match node_status(key) {
6675                        NodeAuthzStatus::Visible(lbl) => lbl,
6676                        _ => {
6677                            return Err(GraphError::RoleWriteDenied {
6678                                reason: "role-bound token: target node not visible".into(),
6679                            });
6680                        }
6681                    };
6682                    if !authz.scope.update_labels.contains(&label) {
6683                        return Err(GraphError::RoleWriteDenied {
6684                            reason: format!(
6685                                "role-bound token: label '{}' not in write scope (update_labels)",
6686                                label
6687                            ),
6688                        });
6689                    }
6690                }
6691            }
6692
6693            // ── DELETE-class: DeleteNode ─────────────────────────────────────
6694            BatchOp::DeleteNode { key } => {
6695                let label = match node_status(key) {
6696                    NodeAuthzStatus::Visible(lbl) => lbl,
6697                    _ => {
6698                        return Err(GraphError::RoleWriteDenied {
6699                            reason: "role-bound token: target node not visible".into(),
6700                        });
6701                    }
6702                };
6703                if !authz.scope.delete_labels.contains(&label) {
6704                    return Err(GraphError::RoleWriteDenied {
6705                        reason: format!(
6706                            "role-bound token: label '{}' not in write scope (delete_labels)",
6707                            label
6708                        ),
6709                    });
6710                }
6711            }
6712
6713            // ── DELETE-class: DeleteEdge ─────────────────────────────────────
6714            //
6715            // Derived-edge rejection runs BEFORE the delete_edge_types scope
6716            // check (spec §3.5: "existing derived-edge rejection precedes
6717            // delete_edge_types check").
6718            BatchOp::DeleteEdge {
6719                edge_type,
6720                src_key,
6721                dst_key,
6722            } => {
6723                // Check provenance ownership BEFORE scope (spec §3.5 ordering).
6724                if let (Some(src_id), Some(dst_id), Some(et_sym)) = (
6725                    self.ids.get(src_key.as_str()),
6726                    self.ids.get(dst_key.as_str()),
6727                    self.syms.get(edge_type.as_str()),
6728                ) {
6729                    if self.engine.is_owned(et_sym, src_id, dst_id) {
6730                        return Err(GraphError::RuleOwned {
6731                            detail: format!(
6732                                "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
6733                                 delete or change the owning rule"
6734                            ),
6735                        });
6736                    }
6737                    // Also check would_derive via MutPreview (empty overlay, pre-batch).
6738                    let preview = MutPreview::new(self);
6739                    if preview.would_derive(edge_type, src_key, dst_key) {
6740                        return Err(GraphError::RuleOwned {
6741                            detail: format!(
6742                                "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
6743                                 delete or change the owning rule, or a live rule would \
6744                                 re-derive it"
6745                            ),
6746                        });
6747                    }
6748                }
6749                // Scope check (AFTER derived-edge check, BEFORE endpoint visibility).
6750                if !authz.scope.delete_edge_types.contains(edge_type) {
6751                    return Err(GraphError::RoleWriteDenied {
6752                        reason: format!(
6753                            "role-bound token: edge type '{}' not in write scope (delete_edge_types)",
6754                            edge_type
6755                        ),
6756                    });
6757                }
6758                // Both endpoints must be visible.
6759                for ep_key in [src_key.as_str(), dst_key.as_str()] {
6760                    match self.ids.get(ep_key) {
6761                        None => {
6762                            return Err(GraphError::RoleWriteDenied {
6763                                reason: "role-bound token: edge endpoint not visible".into(),
6764                            });
6765                        }
6766                        Some(id) if !authz.mask.contains_id(id) => {
6767                            return Err(GraphError::RoleWriteDenied {
6768                                reason: "role-bound token: edge endpoint not visible".into(),
6769                            });
6770                        }
6771                        _ => {}
6772                    }
6773                }
6774            }
6775
6776            // ── EDGE-CREATE: InsertEdge ──────────────────────────────────────
6777            //
6778            // Scope check BEFORE endpoint lookup (preserves timing symmetry).
6779            BatchOp::InsertEdge {
6780                edge_type,
6781                src_key,
6782                dst_key,
6783            } => {
6784                if !authz.scope.create_edge_types.contains(edge_type) {
6785                    return Err(GraphError::RoleWriteDenied {
6786                        reason: format!(
6787                            "role-bound token: edge type '{}' not in write scope (create_edge_types)",
6788                            edge_type
6789                        ),
6790                    });
6791                }
6792                // Both endpoints must be visible. A node created by an earlier
6793                // InsertNode in the same batch (tracked in batch_created) counts
6794                // as visible if its label passed the create-class gate.
6795                for ep_key in [src_key.as_str(), dst_key.as_str()] {
6796                    if batch_created.contains_key(ep_key) {
6797                        // Created earlier this batch — already scope-checked.
6798                        continue;
6799                    }
6800                    match self.ids.get(ep_key) {
6801                        None => {
6802                            return Err(GraphError::RoleWriteDenied {
6803                                reason: "role-bound token: edge endpoint not visible".into(),
6804                            });
6805                        }
6806                        Some(id) if !authz.mask.contains_id(id) => {
6807                            return Err(GraphError::RoleWriteDenied {
6808                                reason: "role-bound token: edge endpoint not visible".into(),
6809                            });
6810                        }
6811                        _ => {}
6812                    }
6813                }
6814            }
6815
6816            // ── EDGE-CREATE: InsertEdgeUpsert ────────────────────────────────
6817            //
6818            // Scope check first; then endpoint visibility using same-batch
6819            // placeholder awareness (spec: "a placeholder endpoint the SAME
6820            // batch creates counts as visible if its label passed the
6821            // create-class gate").
6822            BatchOp::InsertEdgeUpsert {
6823                edge_type,
6824                src_key,
6825                dst_key,
6826                placeholder_label,
6827            } => {
6828                if !authz.scope.create_edge_types.contains(edge_type) {
6829                    return Err(GraphError::RoleWriteDenied {
6830                        reason: format!(
6831                            "role-bound token: edge type '{}' not in write scope (create_edge_types)",
6832                            edge_type
6833                        ),
6834                    });
6835                }
6836                // Check placeholder label against create_labels (create-class gate).
6837                // This ensures the auto-created endpoints are scope-allowed.
6838                for ep_key in [src_key.as_str(), dst_key.as_str()] {
6839                    if !upsert_ep_visible(ep_key, placeholder_label) {
6840                        return Err(GraphError::RoleWriteDenied {
6841                            reason: "role-bound token: edge endpoint not visible".into(),
6842                        });
6843                    }
6844                }
6845            }
6846        }
6847        Ok(())
6848    }
6849
6850    /// Write `roles` to `roles.json` atomically and update the in-memory list.
6851    ///
6852    /// Called by `apply_schema` when roles change. Never called on unchanged
6853    /// re-apply — this preserves byte-identical idempotency.
6854    pub(crate) fn commit_roles(&mut self, roles: Vec<RoleDef>) -> Result<()> {
6855        let file = RolesFile::new_versioned(roles.clone());
6856        let bytes = serde_json::to_vec(&file).map_err(|e| GraphError::Corrupt {
6857            detail: format!("roles serialization: {e}"),
6858        })?;
6859        self.fs
6860            .write_atomic(FileId::Roles, &bytes)
6861            .map_err(GraphError::Io)?;
6862        self.roles = Some(roles);
6863        // Refresh the MVCC frozen overlay so that reader() immediately sees the
6864        // updated role definitions without waiting for the next K-commit fold.
6865        self.fold_now();
6866        Ok(())
6867    }
6868
6869    fn view(&self) -> GraphView<'_> {
6870        GraphView {
6871            ids: &self.ids,
6872            syms: &self.syms,
6873            labels: &self.labels,
6874            props: self.props_view(),
6875            topo: self.topo_view(),
6876            edge_props: self.edge_props_view(),
6877            mask: None,
6878            prop_index: Some(&self.prop_index),
6879        }
6880    }
6881
6882    fn view_masked<'a>(&'a self, mask: &'a crate::mask::NodeMask) -> GraphView<'a> {
6883        GraphView {
6884            ids: &self.ids,
6885            syms: &self.syms,
6886            labels: &self.labels,
6887            props: self.props_view(),
6888            topo: self.topo_view(),
6889            edge_props: self.edge_props_view(),
6890            mask: Some(&mask.visible),
6891            prop_index: Some(&self.prop_index),
6892        }
6893    }
6894
6895    /// Execute a read-only Cypher query with a node visibility mask.
6896    ///
6897    /// Only nodes whose key is in `mask` are accessible: label scans, key
6898    /// lookups, and neighbor expansions all respect the mask. Edges where
6899    /// either endpoint is hidden are silently dropped.
6900    ///
6901    /// Returns `Err` with a "masked queries are read-only" message when
6902    /// `cypher` is a write statement (CREATE / MERGE / MATCH…SET / DELETE).
6903    pub fn query_masked(
6904        &self,
6905        cypher: &str,
6906        params: &std::collections::BTreeMap<String, Value>,
6907        mask: &crate::mask::NodeMask,
6908    ) -> Result<ResultSet> {
6909        // Reject write statements up front.
6910        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
6911            detail: format!("lex: {e}"),
6912        })?;
6913        if is_write_tokens(&tokens) {
6914            return Err(GraphError::MaskedReadOnly);
6915        }
6916        let union = parse_read(&tokens).map_err(|e| GraphError::QueryError {
6917            detail: format!("parse: {e}"),
6918        })?;
6919        // Each UNION part executes against the same masked view, so the mask
6920        // applies uniformly across the chain.
6921        execute_union(&self.view_masked(mask), &union, &Params(params)).map_err(|e| {
6922            GraphError::QueryError {
6923                detail: format!("execute: {e}"),
6924            }
6925        })
6926    }
6927
6928    pub fn node_ref(&self, key: &str) -> Option<NodeRef<'_, F>> {
6929        let id = self.ids.get(key)?;
6930        Some(NodeRef { db: self, id })
6931    }
6932
6933    /// BFS neighborhood expansion restricted to visible nodes in `mask`.
6934    ///
6935    /// Hidden nodes are never used as traversal intermediaries in either
6936    /// [`MaskMode::Omit`] or [`MaskMode::Stub`] — a visible node reachable
6937    /// only through a hidden node will not appear in results.
6938    ///
6939    /// In [`MaskMode::Stub`] mode, hidden nodes that are direct neighbours of
6940    /// a visited visible node are appended to the result as stub rows
6941    /// (`label` column is `null`, same key+depth columns as visible rows).
6942    /// They are NOT added to the BFS frontier.
6943    ///
6944    /// Returns `None` when `key` does not exist (caller should 404).
6945    ///
6946    /// **SECURITY**: role-token callers always pass an Omit-mode mask, so
6947    /// stub rows are never produced on the role path.
6948    pub fn neighborhood_masked(
6949        &self,
6950        key: &str,
6951        depth: u32,
6952        edge_types: Option<&[&str]>,
6953        dir: Dir,
6954        mask: &crate::mask::NodeMask,
6955    ) -> Option<ResultSet> {
6956        let start_id = self.ids.get(key)?;
6957        let view = self.view_masked(mask);
6958        let resolved: Option<Vec<u32>> = edge_types.map(|names| {
6959            names
6960                .iter()
6961                .filter_map(|name| view.syms.get(name))
6962                .collect()
6963        });
6964        let nb = neighborhood(&view, start_id, depth, resolved.as_deref(), dir);
6965        let mut rs = ResultSet::new(vec!["key".into(), "label".into(), "depth".into()]);
6966        // Collect visible BFS results (start_id at depth 0, BFS nodes after).
6967        let mut visited: Vec<(u32, u32)> = Vec::with_capacity(nb.nodes.len() + 1);
6968        visited.push((start_id, 0));
6969        for (nid, d) in &nb.nodes {
6970            let k = view.key_of(*nid);
6971            let label = view
6972                .label_of(*nid)
6973                .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
6974            rs.push_row(vec![
6975                Some(Value::Str(k.to_string())),
6976                Some(Value::Str(label.to_string())),
6977                Some(Value::Int(*d as i64)),
6978            ]);
6979            visited.push((*nid, *d));
6980        }
6981        // Stub mode: add hidden direct neighbours of each visited node as stubs.
6982        // Hidden nodes are edge-endpoints only — they are not added to the BFS
6983        // frontier, so the BFS never expands through them.
6984        if mask.mode() == crate::mask::MaskMode::Stub {
6985            let raw_view = self.view();
6986            let mut seen: std::collections::HashSet<u32> =
6987                visited.iter().map(|(id, _)| *id).collect();
6988            for (node_id, node_depth) in &visited {
6989                if *node_depth >= depth {
6990                    continue;
6991                }
6992                for e in expand(&raw_view, *node_id, resolved.as_deref(), dir) {
6993                    let nbr = if e.src == *node_id { e.dst } else { e.src };
6994                    if !mask.contains_id(nbr) && seen.insert(nbr) {
6995                        if let Some(k) = self.ids.key_of(nbr) {
6996                            rs.push_row(vec![
6997                                Some(Value::Str(k.to_string())),
6998                                None,
6999                                Some(Value::Int((*node_depth + 1) as i64)),
7000                            ]);
7001                        }
7002                    }
7003                }
7004            }
7005        }
7006        Some(rs)
7007    }
7008
7009    /// Live node's key, label, and columnar props. Unknown or tombstoned → `None`.
7010    pub fn node_info(&self, key: &str) -> Option<NodeInfo> {
7011        let n = self.node_ref(key)?;
7012        Some(NodeInfo {
7013            key: n.key().to_string(),
7014            label: n.label().to_string(),
7015            props: n.props(),
7016        })
7017    }
7018
7019    /// Look up a node with mask awareness.
7020    ///
7021    /// | Key state         | Omit mode       | Stub mode              |
7022    /// |-------------------|-----------------|------------------------|
7023    /// | does not exist    | `None` (→ 404)  | `None` (→ 404)         |
7024    /// | exists, visible   | `Some(Visible)` | `Some(Visible)`        |
7025    /// | exists, hidden    | `None` (→ 404)  | `Some(Restricted)`     |
7026    ///
7027    /// **SECURITY**: only call from client-mask (full-token) paths.
7028    /// Role-token paths must use [`node_info`] after an explicit visibility check.
7029    pub fn node_info_masked(
7030        &self,
7031        key: &str,
7032        mask: &crate::mask::NodeMask,
7033    ) -> Option<MaskedNodeResult> {
7034        let id = self.ids.get(key)?;
7035        if mask.contains_id(id) {
7036            Some(MaskedNodeResult::Visible(self.node_info(key)?))
7037        } else {
7038            match mask.mode() {
7039                crate::mask::MaskMode::Stub => Some(MaskedNodeResult::Restricted),
7040                crate::mask::MaskMode::Omit => None,
7041            }
7042        }
7043    }
7044
7045    /// Get edges for `key` with mask-aware hidden-endpoint handling.
7046    ///
7047    /// - Omit mode: edges to hidden endpoints are excluded (same as role-path filtering).
7048    /// - Stub mode: edges to hidden endpoints are included; `src_restricted`/`dst_restricted`
7049    ///   is `true` for each hidden endpoint.
7050    ///
7051    /// Unknown key → [`GraphError::KeyNotFound`].
7052    ///
7053    /// **SECURITY**: only call from client-mask (full-token) paths.
7054    pub fn node_edges_masked(
7055        &self,
7056        key: &str,
7057        mask: &crate::mask::NodeMask,
7058    ) -> Result<Vec<MaskedEdge>> {
7059        self.ensure_v8_base_sections_loaded();
7060        let id = self
7061            .ids
7062            .get(key)
7063            .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
7064        let derived: BTreeSet<(u32, u32, u32)> = self
7065            .engine
7066            .provenance_touching(id)
7067            .map(|(_rule, etype, src, dst)| (etype, src, dst))
7068            .collect();
7069        let mut edges = Vec::new();
7070        let tv = self.topo_view();
7071        for etype in tv.etypes() {
7072            // etype comes from the archived CSR (access_unchecked, no eager CRC).
7073            // A bit-flip in the large TOPOLOGY section can produce an etype id
7074            // that is not in the interner.  Return Corrupt rather than panic.
7075            let edge_type = self
7076                .syms
7077                .resolve(etype)
7078                .ok_or_else(|| GraphError::Corrupt {
7079                    detail: format!("v8: topology etype {etype} not in interner"),
7080                })?
7081                .to_string();
7082            for dir in [Direction::Out, Direction::In] {
7083                for &nbr in tv.neighbors(etype, dir, id).as_ref() {
7084                    let nbr_restricted = !mask.contains_id(nbr);
7085                    if nbr_restricted && mask.mode() == crate::mask::MaskMode::Omit {
7086                        continue;
7087                    }
7088                    let nbr_key = self
7089                        .ids
7090                        .key_of(nbr)
7091                        .ok_or_else(|| GraphError::Corrupt {
7092                            detail: format!("topology id {nbr} has no key"),
7093                        })?
7094                        .to_string();
7095                    let (src_id, dst_id, src_key, dst_key, src_restricted, dst_restricted) =
7096                        match dir {
7097                            Direction::Out => {
7098                                (id, nbr, key.to_string(), nbr_key, false, nbr_restricted)
7099                            }
7100                            Direction::In => {
7101                                (nbr, id, nbr_key, key.to_string(), nbr_restricted, false)
7102                            }
7103                        };
7104                    edges.push(MaskedEdge {
7105                        edge_type: edge_type.clone(),
7106                        src_key,
7107                        src_restricted,
7108                        dst_key,
7109                        dst_restricted,
7110                        derived: derived.contains(&(etype, src_id, dst_id)),
7111                    });
7112                }
7113            }
7114        }
7115        edges.sort_by(|a, b| {
7116            a.edge_type
7117                .cmp(&b.edge_type)
7118                .then(a.src_key.cmp(&b.src_key))
7119                .then(a.dst_key.cmp(&b.dst_key))
7120        });
7121        edges.dedup_by(|a, b| {
7122            a.edge_type == b.edge_type && a.src_key == b.src_key && a.dst_key == b.dst_key
7123        });
7124        Ok(edges)
7125    }
7126
7127    /// Every directed edge incident on `key`, both directions, every etype.
7128    ///
7129    /// Walk is `topology.etypes()` × `{Out, In}` × `neighbors()`. `derived` is
7130    /// membership in [`RuleEngine::provenance_touching`] (O(degree) via the
7131    /// Plan-8 `by_node` index). Sorted by `(edge_type, src_key, dst_key)`.
7132    /// Unknown key → [`GraphError::KeyNotFound`].
7133    pub fn node_edges(&self, key: &str) -> Result<Vec<EdgeInfo>> {
7134        self.ensure_v8_base_sections_loaded();
7135        let id = self
7136            .ids
7137            .get(key)
7138            .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
7139        let derived: BTreeSet<(u32, u32, u32)> = self
7140            .engine
7141            .provenance_touching(id)
7142            .map(|(_rule, etype, src, dst)| (etype, src, dst))
7143            .collect();
7144        let mut edges = Vec::new();
7145        let tv = self.topo_view();
7146        for etype in tv.etypes() {
7147            // Same guard as node_edges_masked: etype from unchecked-CRC CSR.
7148            let edge_type = self
7149                .syms
7150                .resolve(etype)
7151                .ok_or_else(|| GraphError::Corrupt {
7152                    detail: format!("v8: topology etype {etype} not in interner"),
7153                })?
7154                .to_string();
7155            for dir in [Direction::Out, Direction::In] {
7156                for &nbr in tv.neighbors(etype, dir, id).as_ref() {
7157                    let (src, dst, src_key, dst_key) = match dir {
7158                        Direction::Out => (
7159                            id,
7160                            nbr,
7161                            key.to_string(),
7162                            self.ids
7163                                .key_of(nbr)
7164                                .ok_or_else(|| GraphError::Corrupt {
7165                                    detail: format!("topology id {nbr} has no key"),
7166                                })?
7167                                .to_string(),
7168                        ),
7169                        Direction::In => (
7170                            nbr,
7171                            id,
7172                            self.ids
7173                                .key_of(nbr)
7174                                .ok_or_else(|| GraphError::Corrupt {
7175                                    detail: format!("topology id {nbr} has no key"),
7176                                })?
7177                                .to_string(),
7178                            key.to_string(),
7179                        ),
7180                    };
7181                    edges.push(EdgeInfo {
7182                        edge_type: edge_type.clone(),
7183                        src_key,
7184                        dst_key,
7185                        derived: derived.contains(&(etype, src, dst)),
7186                    });
7187                }
7188            }
7189        }
7190        edges.sort_by(|a, b| {
7191            a.edge_type
7192                .cmp(&b.edge_type)
7193                .then(a.src_key.cmp(&b.src_key))
7194                .then(a.dst_key.cmp(&b.dst_key))
7195        });
7196        // Self-loops appear in both Out and In; sort makes the pair adjacent
7197        // (sort key matches PartialEq for this case) so one pass drops the dup.
7198        edges.dedup();
7199        Ok(edges)
7200    }
7201
7202    // ── Backup ────────────────────────────────────────────────────────────────
7203
7204    /// Copy this store to `dest` as a consistent, verified snapshot.
7205    ///
7206    /// Copies every durable file in the database directory — `snapshot.bin`,
7207    /// `wal.bin`, all `wal.<N>.archive` files, `wal.floor`, `wal.genesis`, and
7208    /// `roles.json` — into a freshly created `dest` directory using OS-level
7209    /// `copy` calls (no large in-process buffers).
7210    ///
7211    /// # Consistency guarantee
7212    ///
7213    /// The guarantee is **process-local**: the caller holds `&self`, which
7214    /// prevents any concurrent writer in the **same process** from modifying
7215    /// the files during the copy.  Running `mushroomdb backup` against a
7216    /// directory that is **concurrently being written by another process** (e.g.
7217    /// `mushroomdb serve`) is **unsafe** — the copy can be torn.  The post-copy
7218    /// `verified: true` result reduces but does not eliminate the risk of a
7219    /// silent corrupt backup (CRC catches many bit-flips; it cannot catch a
7220    /// consistent mid-write snapshot).
7221    ///
7222    /// **The safe path for a live-served store is `POST /backup` on the HTTP
7223    /// server.** That handler acquires the read lock on the shared database
7224    /// before calling this method, which is the correct cross-process
7225    /// synchronisation point because the server is the single process writing
7226    /// the files.
7227    ///
7228    /// After copying, opens the destination read-only and runs the CRC section
7229    /// verifier (`verify_snapshot`) to confirm byte-for-byte integrity.
7230    /// `BackupReport::verified` reflects whether both checks passed.
7231    ///
7232    /// Returns `Err` when `self` is not backed by a `RealFs` (e.g. `SimFs`).
7233    pub fn backup_to(&self, dest: &std::path::Path) -> Result<BackupReport> {
7234        // Derive source directory from snapshot_path (RealFs only).
7235        let src_dir = match self.fs.snapshot_path() {
7236            Some(p) => p.parent().map(|d| d.to_path_buf()).ok_or_else(|| {
7237                GraphError::Io(std::io::Error::other("snapshot has no parent dir"))
7238            })?,
7239            None => {
7240                return Err(GraphError::Io(std::io::Error::other(
7241                    "backup_to requires a real filesystem (RealFs)",
7242                )))
7243            }
7244        };
7245
7246        std::fs::create_dir_all(dest)?;
7247
7248        let mut files: Vec<String> = Vec::new();
7249        let mut bytes: u64 = 0;
7250
7251        // Helper: copy src_dir/name → dest/name if the file exists.
7252        let mut try_copy = |name: &str| -> std::io::Result<()> {
7253            let src_path = src_dir.join(name);
7254            if src_path.exists() {
7255                let n = std::fs::copy(&src_path, dest.join(name))?;
7256                bytes += n;
7257                files.push(name.to_string());
7258            }
7259            Ok(())
7260        };
7261
7262        try_copy("snapshot.bin")?;
7263        try_copy("snapshot.bin.bak")?;
7264        try_copy("wal.bin")?;
7265        try_copy("wal.floor")?;
7266        try_copy("wal.genesis")?;
7267        try_copy("roles.json")?;
7268
7269        // Copy WAL archives.
7270        let archives = self.fs.list_archives()?;
7271        for n in &archives {
7272            let name = format!("wal.{n}.archive");
7273            let n_bytes = std::fs::copy(src_dir.join(&name), dest.join(&name))?;
7274            bytes += n_bytes;
7275            files.push(name);
7276        }
7277
7278        files.sort();
7279
7280        // Post-copy verification: open dest and run CRC checks.
7281        let snap_in_dest = dest.join("snapshot.bin").exists();
7282        let crc_ok = if snap_in_dest {
7283            crate::verify_snapshot(dest)
7284                .map(|results| results.iter().all(|(_, _, _, r)| r.is_ok()))
7285                .unwrap_or(false)
7286        } else {
7287            true // WAL-only store: nothing to CRC-check in snapshot
7288        };
7289        let opens_ok = GraphDb::<core_storage::fs::RealFs>::open(dest).is_ok();
7290        let verified = crc_ok && opens_ok;
7291
7292        Ok(BackupReport {
7293            files,
7294            bytes,
7295            verified,
7296        })
7297    }
7298
7299    // ── Export helpers ────────────────────────────────────────────────────────
7300
7301    /// All live nodes, sorted by key (deterministic).
7302    ///
7303    /// Reads base + WAL overlay. Tombstoned nodes are excluded.
7304    pub fn all_nodes_for_export(&self) -> Vec<NodeInfo> {
7305        self.ensure_v8_base_sections_loaded();
7306        let pv = self.props_view();
7307        let mut nodes = Vec::new();
7308        for id in 0..self.ids.len() as u32 {
7309            let Some(key) = self.ids.key_of(id) else {
7310                continue;
7311            };
7312            let Some(&sym) = self.labels.get(id as usize) else {
7313                continue;
7314            };
7315            if sym == u32::MAX {
7316                continue; // tombstoned
7317            }
7318            let Some(label) = self.syms.resolve(sym) else {
7319                continue;
7320            };
7321            let mut props = BTreeMap::new();
7322            for field in pv.field_names() {
7323                if let Some(vr) = pv.get(id, &field) {
7324                    props.insert(field, vr.into_value());
7325                }
7326            }
7327            nodes.push(NodeInfo {
7328                key: key.to_string(),
7329                label: label.to_string(),
7330                props,
7331            });
7332        }
7333        nodes.sort_by(|a, b| a.key.cmp(&b.key));
7334        nodes
7335    }
7336
7337    /// All directed edges, sorted by `(edge_type, src, dst)`. Each edge appears once.
7338    ///
7339    /// Derived edges carry `derived: true` and the creating rule's name in `rule`.
7340    /// Manual edges carry `derived: false` and `rule: None`.
7341    /// `weight` is the creating rule's `weight_prop` value read off the edge
7342    /// (numeric only), mirroring the convention used by [`GraphDb::explain`]
7343    /// and [`GraphDb::weighted_edges`]. Deterministic across runs on the same
7344    /// store state.
7345    pub fn all_edges_for_export(&self) -> Vec<ExportEdge> {
7346        self.ensure_v8_base_sections_loaded();
7347
7348        // Build (etype_sym, src_id, dst_id) → rule_name for O(1) derivation lookup.
7349        let mut prov: HashMap<(u32, u32, u32), String> = HashMap::new();
7350        for (rule_name, triples) in self.engine.provenance() {
7351            for &(etype, src, dst) in triples {
7352                prov.insert((etype, src, dst), rule_name.clone());
7353            }
7354        }
7355
7356        // rule_name → weight_prop, for O(1) lookup per derived edge.
7357        let weight_props: HashMap<&str, Option<&str>> = self
7358            .engine
7359            .rules()
7360            .map(|r| (r.name.as_str(), r.weight_prop.as_deref()))
7361            .collect();
7362
7363        let tv = self.topo_view();
7364        let ep = self.edge_props_view();
7365        let mut edges = Vec::new();
7366
7367        for id in 0..self.ids.len() as u32 {
7368            let Some(key) = self.ids.key_of(id) else {
7369                continue;
7370            };
7371            let Some(&lsym) = self.labels.get(id as usize) else {
7372                continue;
7373            };
7374            if lsym == u32::MAX {
7375                continue; // tombstoned
7376            }
7377
7378            for etype_sym in tv.etypes() {
7379                // etype from archived CSR (access_unchecked, no eager CRC).
7380                // Skip edges whose etype is not in the interner; this can only
7381                // occur with a corrupt large TOPOLOGY section (bit-flip on an
7382                // etype field in the archived data).  The function returns Vec,
7383                // not Result, so we continue rather than propagate.
7384                let Some(edge_type) = self.syms.resolve(etype_sym) else {
7385                    continue;
7386                };
7387                let edge_type = edge_type.to_string();
7388                for &nbr in tv.neighbors(etype_sym, Direction::Out, id).as_ref() {
7389                    let Some(dst_key) = self.ids.key_of(nbr) else {
7390                        continue; // skip corrupt entries
7391                    };
7392                    let prov_key = (etype_sym, id, nbr);
7393                    let rule = prov.get(&prov_key).cloned();
7394                    let derived = rule.is_some();
7395                    let weight = rule
7396                        .as_deref()
7397                        .and_then(|rn| weight_props.get(rn).copied().flatten())
7398                        .and_then(|prop| match ep.get(etype_sym, id, nbr, prop) {
7399                            Some(Value::Float(f)) => Some(f),
7400                            Some(Value::Int(i)) => Some(i as f64),
7401                            _ => None,
7402                        });
7403                    edges.push(ExportEdge {
7404                        edge_type: edge_type.clone(),
7405                        src: key.to_string(),
7406                        dst: dst_key.to_string(),
7407                        derived,
7408                        rule,
7409                        weight,
7410                    });
7411                }
7412            }
7413        }
7414
7415        edges.sort_by(|a, b| {
7416            a.edge_type
7417                .cmp(&b.edge_type)
7418                .then(a.src.cmp(&b.src))
7419                .then(a.dst.cmp(&b.dst))
7420        });
7421        edges
7422    }
7423
7424    /// What each edge type *is*, without building one record per edge.
7425    ///
7426    /// [`all_edges_for_export`](Self::all_edges_for_export) answers the same
7427    /// question by materialising every edge — three `String`s apiece, a
7428    /// provenance `HashMap` over every derived edge, and a final sort. That is
7429    /// the right shape for an export, and the wrong one for a summary: on a
7430    /// store with 1.3 M derived edges it allocates hundreds of megabytes to
7431    /// produce nine lines. This walks the topology instead, summing neighbour
7432    /// slice lengths and collecting *label symbols* rather than label strings,
7433    /// so the per-edge cost is an integer add and a set insert on a set with
7434    /// as many members as the store has labels.
7435    ///
7436    /// The rule names come off the rule *definitions*, which each declare the
7437    /// `edge_type` they derive, so naming them costs one pass over the rules
7438    /// rather than one provenance lookup per edge. That is also why `rules`
7439    /// is a list: two rules may derive the same type — the association store
7440    /// derives `INDUSTRY_ALIGNMENT` from both a talent→company and a
7441    /// talent→job rule — and naming only one of them would be a half-truth.
7442    /// A type with no rules is one written by hand.
7443    ///
7444    /// `sample` is the first edge of the type in the store's own id order,
7445    /// which is insertion order: deterministic for a given store, and not the
7446    /// same as key order, which cannot be had without resolving a key per
7447    /// edge. Sorted by `edge_type`.
7448    pub fn edge_type_census(&self) -> Vec<EdgeTypeCensus> {
7449        self.ensure_v8_base_sections_loaded();
7450
7451        let mut rules_by_type: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
7452        for r in self.engine.rules() {
7453            rules_by_type
7454                .entry(r.edge_type.as_str())
7455                .or_default()
7456                .insert(r.name.as_str());
7457        }
7458
7459        let tv = self.topo_view();
7460        let node_count = self.ids.len() as u32;
7461        let mut out = Vec::new();
7462        for etype_sym in tv.etypes() {
7463            // An etype the interner cannot resolve means a corrupt TOPOLOGY
7464            // section; skip it rather than name it, as `all_edges_for_export`
7465            // does for the same reason.
7466            let Some(edge_type) = self.syms.resolve(etype_sym) else {
7467                continue;
7468            };
7469            let mut edges: u64 = 0;
7470            let mut src_syms: BTreeSet<u32> = BTreeSet::new();
7471            let mut dst_syms: BTreeSet<u32> = BTreeSet::new();
7472            let mut sample: Option<(u32, u32)> = None;
7473            for id in 0..node_count {
7474                let Some(&lsym) = self.labels.get(id as usize) else {
7475                    continue;
7476                };
7477                if lsym == u32::MAX {
7478                    continue; // tombstoned
7479                }
7480                let nbrs = tv.neighbors(etype_sym, Direction::Out, id);
7481                let nbrs = nbrs.as_ref();
7482                if nbrs.is_empty() {
7483                    continue;
7484                }
7485                edges += nbrs.len() as u64;
7486                src_syms.insert(lsym);
7487                for &nbr in nbrs {
7488                    if let Some(&dsym) = self.labels.get(nbr as usize) {
7489                        if dsym != u32::MAX {
7490                            dst_syms.insert(dsym);
7491                        }
7492                    }
7493                }
7494                if sample.is_none() {
7495                    sample = Some((id, nbrs[0]));
7496                }
7497            }
7498            let resolve = |syms: &BTreeSet<u32>| -> Vec<String> {
7499                syms.iter()
7500                    .filter_map(|&s| self.syms.resolve(s))
7501                    .map(ToString::to_string)
7502                    .collect()
7503            };
7504            out.push(EdgeTypeCensus {
7505                edge_type: edge_type.to_string(),
7506                edges,
7507                src_labels: resolve(&src_syms),
7508                dst_labels: resolve(&dst_syms),
7509                rules: rules_by_type
7510                    .get(edge_type)
7511                    .map(|rs| rs.iter().map(ToString::to_string).collect())
7512                    .unwrap_or_default(),
7513                sample: sample.and_then(|(s, d)| {
7514                    Some((
7515                        self.ids.key_of(s)?.to_string(),
7516                        self.ids.key_of(d)?.to_string(),
7517                    ))
7518                }),
7519            });
7520        }
7521        out.sort_by(|a, b| a.edge_type.cmp(&b.edge_type));
7522        out
7523    }
7524
7525    /// All directed edges of `edge_type`, with the raw value of `weight_prop`
7526    /// on each edge when given.
7527    ///
7528    /// `weight` is `Some(f)` only when `weight_prop` is set and the edge
7529    /// carries that property with a numeric (`Int`/`Float`) value; otherwise
7530    /// `None` — callers that want a default weight (e.g. `1.0` for missing
7531    /// props) apply it themselves, matching the convention used internally
7532    /// by [`GraphDb::pagerank`], [`GraphDb::connected_components`],
7533    /// [`GraphDb::degree_centrality`], and [`GraphDb::communities`].
7534    ///
7535    /// Sorted by `(src, dst)` for determinism. Reads the unified topology
7536    /// (manual + rule-derived edges).  An unknown `edge_type` returns an
7537    /// empty vec.
7538    pub fn weighted_edges(
7539        &self,
7540        edge_type: &str,
7541        weight_prop: Option<&str>,
7542    ) -> Vec<(String, String, Option<f64>)> {
7543        let Some(etype_sym) = self.syms.get(edge_type) else {
7544            return Vec::new();
7545        };
7546        let tv = self.topo_view();
7547        let ep = self.edge_props_view();
7548        let mut out = Vec::new();
7549        for id in 0..self.ids.len() as u32 {
7550            let Some(key) = self.ids.key_of(id) else {
7551                continue;
7552            };
7553            let Some(&sym) = self.labels.get(id as usize) else {
7554                continue;
7555            };
7556            if sym == u32::MAX {
7557                continue; // tombstoned
7558            }
7559            for &nbr in tv.neighbors(etype_sym, Direction::Out, id).as_ref() {
7560                let Some(dst_key) = self.ids.key_of(nbr) else {
7561                    continue;
7562                };
7563                let weight = weight_prop.and_then(|prop| match ep.get(etype_sym, id, nbr, prop) {
7564                    Some(Value::Float(f)) => Some(f),
7565                    Some(Value::Int(i)) => Some(i as f64),
7566                    _ => None,
7567                });
7568                out.push((key.to_string(), dst_key.to_string(), weight));
7569            }
7570        }
7571        out.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
7572        out
7573    }
7574
7575    pub fn nodes_with_label(&self, label: &str) -> Vec<NodeRef<'_, F>> {
7576        self.view()
7577            .nodes_with_label(label)
7578            .into_iter()
7579            .map(|id| NodeRef { db: self, id })
7580            .collect()
7581    }
7582
7583    pub fn find_nodes(&self, label: &str, filter: &Filter) -> Vec<NodeRef<'_, F>> {
7584        let view = self.view();
7585        view.nodes_with_label(label)
7586            .into_iter()
7587            .filter(|&id| {
7588                eval_filter(filter, &|field| {
7589                    view.prop(id, field).map(|vr| vr.into_value())
7590                })
7591            })
7592            .map(|id| NodeRef { db: self, id })
7593            .collect()
7594    }
7595
7596    /// Returns `true` if any approximate (HNSW) VectorSimilar rule covers
7597    /// `field`.  Use as a capability probe: when `true`, `find_similar_vector`
7598    /// with `label = None` will use the native ANN path rather than the O(n)
7599    /// brute-force scan.
7600    pub fn has_vector_rule(&self, field: &str) -> bool {
7601        self.engine.hnsw_has_rule(field)
7602    }
7603
7604    /// Find nodes whose `field` vector is most similar to `q` (cosine
7605    /// similarity), returning up to `k` results with similarity ≥ `min`,
7606    /// sorted descending.
7607    ///
7608    /// When `label` is `None` the search spans all labels (via
7609    /// `hnsw_search_any_dst` or a full brute-force scan); when `label` is
7610    /// `Some(lbl)` it restricts to nodes with that label.
7611    ///
7612    /// Uses the HNSW index when one is available (fast path); otherwise falls
7613    /// back to an O(n) brute-force scan.
7614    pub fn find_similar_vector(
7615        &self,
7616        field: &str,
7617        label: Option<&str>,
7618        q: &[f64],
7619        k: usize,
7620        min: f64,
7621    ) -> Vec<(String, f64)> {
7622        // Ensure any HNSW blobs retained from the snapshot are deserialized
7623        // before the first ANN query on a clean-open (no-WAL) path.
7624        self.engine.ensure_hnsw_loaded();
7625        // L2-normalise query for cosine via dot product.
7626        let norm: f64 = q.iter().map(|x| x * x).sum::<f64>().sqrt();
7627        if norm == 0.0 {
7628            return vec![];
7629        }
7630        let q_unit: Vec<f64> = q.iter().map(|x| x / norm).collect();
7631
7632        // Try HNSW fast path.
7633        // `None` label searches across all VectorSimilar rules covering `field`
7634        // (merging their results); `Some(lbl)` restricts to rules whose
7635        // dst_label matches.  Returns `None` when no populated HNSW index
7636        // covers the request — the O(n) brute-force fallback handles that case.
7637        let hnsw_hits = match label {
7638            Some(lbl) => self.engine.hnsw_search_dst(field, lbl, &q_unit, k),
7639            None => self.engine.hnsw_search_any_dst(field, &q_unit, k),
7640        };
7641        if let Some(hits) = hnsw_hits {
7642            let mut out: Vec<(String, f64)> = hits
7643                .into_iter()
7644                .filter(|&(_, sim)| sim >= min)
7645                .filter_map(|(id, sim)| self.ids.key_of(id).map(|key| (key.to_string(), sim)))
7646                .collect();
7647            out.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
7648            out.truncate(k);
7649            return out;
7650        }
7651
7652        // Brute-force fallback: O(n) scan (only reached when no HNSW index
7653        // covers the request).
7654        let view = self.view();
7655        let candidate_ids: Vec<u32> = match label {
7656            Some(lbl) => view.nodes_with_label(lbl),
7657            None => view.nodes_all(),
7658        };
7659        let mut scored: Vec<(String, f64)> = candidate_ids
7660            .into_iter()
7661            .filter_map(|id| {
7662                let v = view.prop(id, field)?;
7663                let v_owned = v.into_value();
7664                let xs = value_as_float_list(&v_owned)?;
7665                let v_norm: f64 = xs.iter().map(|x| x * x).sum::<f64>().sqrt();
7666                if v_norm == 0.0 {
7667                    return None;
7668                }
7669                let dot: f64 = q_unit
7670                    .iter()
7671                    .zip(xs.iter())
7672                    .map(|(a, b)| a * (b / v_norm))
7673                    .sum();
7674                if dot < min {
7675                    return None;
7676                }
7677                let key = self.ids.key_of(id)?.to_string();
7678                Some((key, dot))
7679            })
7680            .collect();
7681        scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
7682        scored.truncate(k);
7683        scored
7684    }
7685
7686    /// Like [`find_similar_vector`] but restricts results to nodes visible in
7687    /// `mask`. Hidden nodes never appear in results; the mask is applied
7688    /// **before** k-truncation so a caller still receives up to `k` visible
7689    /// hits.
7690    ///
7691    /// # HNSW path (over-fetch policy)
7692    ///
7693    /// When an HNSW index covers the request, this function fetches `4 * k`
7694    /// candidates from the index and discards hidden nodes in the post-filter
7695    /// step.  If fewer than `k` visible nodes remain after filtering the caller
7696    /// receives whatever is available — we do not re-query the index.  The 4×
7697    /// multiplier is a heuristic suited for sparsely masked graphs; callers
7698    /// operating under a very selective mask should register a VectorSimilar
7699    /// rule with a non-approximate index, or use the brute-force path (no HNSW
7700    /// rule) which exhaustively filters through the masked [`GraphView`].
7701    ///
7702    /// # Brute-force path
7703    ///
7704    /// When no HNSW index covers the request the function builds a masked
7705    /// [`GraphView`] so that `nodes_all` / `nodes_with_label` return only
7706    /// visible nodes, guaranteeing exact `k` results (or all visible nodes if
7707    /// fewer than `k` exist).
7708    pub fn find_similar_vector_masked(
7709        &self,
7710        field: &str,
7711        label: Option<&str>,
7712        q: &[f64],
7713        k: usize,
7714        min: f64,
7715        mask: &crate::mask::NodeMask,
7716    ) -> Vec<(String, f64)> {
7717        self.engine.ensure_hnsw_loaded();
7718        let norm: f64 = q.iter().map(|x| x * x).sum::<f64>().sqrt();
7719        if norm == 0.0 {
7720            return vec![];
7721        }
7722        let q_unit: Vec<f64> = q.iter().map(|x| x / norm).collect();
7723
7724        // HNSW fast path — over-fetch 4×k so post-masking still yields up to k
7725        // visible hits.  See doc comment above for the policy rationale.
7726        let over_k = k.saturating_mul(4).max(k + 1);
7727        let hnsw_hits = match label {
7728            Some(lbl) => self.engine.hnsw_search_dst(field, lbl, &q_unit, over_k),
7729            None => self.engine.hnsw_search_any_dst(field, &q_unit, over_k),
7730        };
7731        if let Some(hits) = hnsw_hits {
7732            let mut out: Vec<(String, f64)> = hits
7733                .into_iter()
7734                .filter(|&(id, sim)| sim >= min && mask.visible.contains(&id))
7735                .filter_map(|(id, sim)| self.ids.key_of(id).map(|key| (key.to_string(), sim)))
7736                .collect();
7737            out.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
7738            out.truncate(k);
7739            return out;
7740        }
7741
7742        // Brute-force fallback — masked view ensures only visible nodes are
7743        // enumerated by nodes_all(); nodes_with_label() does not filter by
7744        // mask so we apply view.visible() explicitly for the labeled case.
7745        let view = self.view_masked(mask);
7746        let candidate_ids: Vec<u32> = match label {
7747            Some(lbl) => view
7748                .nodes_with_label(lbl)
7749                .into_iter()
7750                .filter(|&id| view.visible(id))
7751                .collect(),
7752            None => view.nodes_all(),
7753        };
7754        let mut scored: Vec<(String, f64)> = candidate_ids
7755            .into_iter()
7756            .filter_map(|id| {
7757                let v = view.prop(id, field)?;
7758                let v_owned = v.into_value();
7759                let xs = value_as_float_list(&v_owned)?;
7760                let v_norm: f64 = xs.iter().map(|x| x * x).sum::<f64>().sqrt();
7761                if v_norm == 0.0 {
7762                    return None;
7763                }
7764                let dot: f64 = q_unit
7765                    .iter()
7766                    .zip(xs.iter())
7767                    .map(|(a, b)| a * (b / v_norm))
7768                    .sum();
7769                if dot < min {
7770                    return None;
7771                }
7772                let key = self.ids.key_of(id)?.to_string();
7773                Some((key, dot))
7774            })
7775            .collect();
7776        scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
7777        scored.truncate(k);
7778        scored
7779    }
7780
7781    /// Read a single property from an edge.
7782    ///
7783    /// Returns `None` when the edge does not exist, the field is absent, or any
7784    /// of the string keys cannot be resolved to interned ids.  Only edge props
7785    /// written by rules (weight fields) are accessible without a `set_edge_prop`
7786    /// binding; topology-only edges (no props set) return `None` for every field.
7787    pub fn get_edge_prop(
7788        &self,
7789        edge_type: &str,
7790        src_key: &str,
7791        dst_key: &str,
7792        field: &str,
7793    ) -> Option<Value> {
7794        let etype = self.syms.get(edge_type)?;
7795        let src = self.ids.get(src_key)?;
7796        let dst = self.ids.get(dst_key)?;
7797        self.edge_props_view().get(etype, src, dst, field)
7798    }
7799
7800    /// Lex → parse → plan → execute `cypher` over a read-only view.
7801    /// Every pipeline `Err(String)` becomes `GraphError::QueryError` with a
7802    /// stage prefix (`lex:` / `parse:` / `plan:` / `execute:`).
7803    pub fn query(&self, cypher: &str, params: &BTreeMap<String, Value>) -> Result<ResultSet> {
7804        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
7805            detail: format!("lex: {e}"),
7806        })?;
7807        let union = parse_read(&tokens).map_err(|e| GraphError::QueryError {
7808            detail: format!("parse: {e}"),
7809        })?;
7810        let t0 = std::time::Instant::now();
7811        let result = execute_union(&self.view(), &union, &Params(params)).map_err(|e| {
7812            GraphError::QueryError {
7813                detail: format!("execute: {e}"),
7814            }
7815        });
7816        let elapsed_ms = t0.elapsed().as_millis() as u64;
7817        let threshold = self.slow_query_threshold_ms;
7818        if threshold > 0 && elapsed_ms >= threshold {
7819            eprintln!("[mushroomdb] slow query ({elapsed_ms}ms): {cypher}");
7820            let entry = SlowQueryEntry {
7821                ms: elapsed_ms,
7822                query: cypher.to_string(),
7823                at_commit: self.commit_seq,
7824            };
7825            if let Ok(mut log) = self.slow_queries.lock() {
7826                if log.entries.len() == SLOW_QUERY_RING_CAP {
7827                    log.entries.pop_front();
7828                }
7829                log.entries.push_back(entry);
7830                log.total += 1;
7831            }
7832        }
7833        result
7834    }
7835
7836    /// Convenience entry-point that accepts a slice of `(name, value)` pairs
7837    /// instead of a pre-built `BTreeMap`.  Equivalent to building the map and
7838    /// calling [`GraphDb::query`].
7839    pub fn query_with_params(&self, cypher: &str, params: &[(&str, Value)]) -> Result<ResultSet> {
7840        let map: BTreeMap<String, Value> = params
7841            .iter()
7842            .map(|(k, v)| (k.to_string(), v.clone()))
7843            .collect();
7844        self.query(cypher, &map)
7845    }
7846
7847    /// Execute a Cypher write statement (CREATE / MATCH…SET / MATCH…DELETE / MERGE).
7848    ///
7849    /// All mutations flow through the same `insert_node` / `set_prop` /
7850    /// `delete_edge` / `insert_edge` path as the Rust API so the rule engine
7851    /// fires and the WAL captures everything with one fsync per statement.
7852    ///
7853    /// Returns a one-row [`ResultSet`] with columns `created`, `properties_set`,
7854    /// and `deleted` matching the write-result contract.
7855    ///
7856    /// **Mutation routing**: mutations are collected into a single
7857    /// [`BatchBuilder`] and committed atomically (one WAL `Batch` frame, one
7858    /// fsync). The MATCH phase for SET/DELETE uses a read-only `execute` call
7859    /// over `self.view()` — the borrow is dropped before the batch is opened.
7860    ///
7861    /// **Limitations (v1)**:
7862    /// - SET RHS must be a literal, `$param`, or arithmetic; bare property copy → named error.
7863    /// - `DETACH DELETE n` → calls `delete_node` for each matched node (removes all edges).
7864    /// - Bare `DELETE n` → error if n has any incident edges; succeeds for isolated nodes.
7865    /// - MERGE supports `ON CREATE SET` / `ON MATCH SET` in the same write batch.
7866    /// - Deleting a derived edge → named error "cannot delete derived edge".
7867    pub fn query_write(
7868        &mut self,
7869        cypher: &str,
7870        params: &BTreeMap<String, Value>,
7871    ) -> Result<ResultSet> {
7872        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
7873            detail: format!("lex: {e}"),
7874        })?;
7875        let stmt = parse_write(&tokens).map_err(|e| GraphError::QueryError {
7876            detail: format!("parse: {e}"),
7877        })?;
7878        self.exec_write_stmt(stmt, params)
7879    }
7880
7881    fn exec_write_stmt(
7882        &mut self,
7883        stmt: WriteStatement,
7884        params: &BTreeMap<String, Value>,
7885    ) -> Result<ResultSet> {
7886        match stmt {
7887            WriteStatement::Create(s) => self.exec_create(s, params),
7888            WriteStatement::MatchSet(s) => self.exec_match_set(s, params),
7889            WriteStatement::MatchDelete(s) => self.exec_match_delete(s, params),
7890            WriteStatement::MatchDeleteNode(s) => self.exec_match_delete_node(s, params),
7891            WriteStatement::Merge(s) => self.exec_merge(s, params),
7892        }
7893    }
7894
7895    fn exec_create(
7896        &mut self,
7897        stmt: core_query::cypher::CreateStmt,
7898        params: &BTreeMap<String, Value>,
7899    ) -> Result<ResultSet> {
7900        // Extract the node key from props: require a string-valued `id` field.
7901        let mut var_to_key: BTreeMap<String, String> = BTreeMap::new();
7902        for node in &stmt.nodes {
7903            let var = node.var.as_deref().unwrap_or("_cn0");
7904            let key = node
7905                .props
7906                .iter()
7907                .find(|(f, _)| f == "id")
7908                .and_then(|(_, v)| {
7909                    if let Value::Str(s) = v {
7910                        Some(s.clone())
7911                    } else {
7912                        None
7913                    }
7914                })
7915                .ok_or_else(|| GraphError::QueryError {
7916                    detail: format!(
7917                        "CREATE node ({}:{}) requires a string 'id' property",
7918                        var, node.label
7919                    ),
7920                })?;
7921            var_to_key.insert(var.to_string(), key);
7922        }
7923
7924        let mut batch = self.batch();
7925        let mut created: usize = 0;
7926        for node in &stmt.nodes {
7927            let var = node.var.as_deref().unwrap_or("_cn0");
7928            let key = &var_to_key[var];
7929            batch.insert_node(&node.label, key, node.props.clone());
7930            created += 1;
7931        }
7932        for edge in &stmt.edges {
7933            let src_key = var_to_key
7934                .get(&edge.src_var)
7935                .ok_or_else(|| GraphError::QueryError {
7936                    detail: format!("CREATE edge src variable '{}' is not bound", edge.src_var),
7937                })?;
7938            let dst_key = var_to_key
7939                .get(&edge.dst_var)
7940                .ok_or_else(|| GraphError::QueryError {
7941                    detail: format!("CREATE edge dst variable '{}' is not bound", edge.dst_var),
7942                })?;
7943            batch.insert_edge(&edge.etype, src_key, dst_key);
7944        }
7945        batch.commit()?;
7946
7947        // Optional RETURN clause: project created bindings as a read result.
7948        if let Some(returns) = stmt.returns {
7949            // Each created node is looked up by its key via a separate MATCH pattern.
7950            // Multiple single-node patterns cross-join to produce 1 output row with
7951            // all variables bound (each pattern returns exactly 1 row).
7952            let patterns: Vec<Pattern> = stmt
7953                .nodes
7954                .iter()
7955                .map(|node| {
7956                    let var = node.var.as_deref().unwrap_or("_cn0");
7957                    let key = var_to_key[var].clone();
7958                    Pattern {
7959                        start: NodePat {
7960                            var: Some(var.to_string()),
7961                            label: Some(node.label.clone()),
7962                            props: vec![("id".to_string(), Operand::Lit(Value::Str(key)))],
7963                        },
7964                        chain: vec![],
7965                        shortest: false,
7966                    }
7967                })
7968                .collect();
7969            let q = Query {
7970                matches: patterns,
7971                optional_clauses: vec![],
7972                where_expr: None,
7973                unwinds: vec![],
7974                post_unwind_where: None,
7975                stages: vec![],
7976                returns,
7977                distinct: false,
7978                order_by: vec![],
7979                skip: None,
7980                limit: None,
7981            };
7982            let ops = plan(&q).map_err(|e| GraphError::QueryError {
7983                detail: format!("plan: {e}"),
7984            })?;
7985            return execute(&self.view(), &ops, &Params(params)).map_err(|e| {
7986                GraphError::QueryError {
7987                    detail: format!("execute: {e}"),
7988                }
7989            });
7990        }
7991
7992        let mut rs = write_result_set();
7993        rs.push_row(vec![
7994            Some(Value::Int(created as i64)),
7995            Some(Value::Int(0)),
7996            Some(Value::Int(0)),
7997        ]);
7998        Ok(rs)
7999    }
8000
8001    fn exec_match_set(
8002        &mut self,
8003        stmt: core_query::cypher::MatchSetStmt,
8004        params: &BTreeMap<String, Value>,
8005    ) -> Result<ResultSet> {
8006        let project_returns = stmt.returns.clone();
8007        // Collect unique node vars targeted by SET clauses, plus RETURN bindings
8008        // so the post-write projection can look them up by key.
8009        let mut set_vars: Vec<String> = Vec::new();
8010        for s in &stmt.sets {
8011            if !set_vars.contains(&s.var) {
8012                set_vars.push(s.var.clone());
8013            }
8014        }
8015        let rel_vars = pattern_rel_vars(&stmt.matches);
8016        let mut lookup_vars = set_vars.clone();
8017        for v in pattern_node_vars(&stmt.matches) {
8018            add_var(&mut lookup_vars, &v);
8019        }
8020        if let Some(ref returns) = project_returns {
8021            for v in ret_node_vars(returns) {
8022                if !rel_vars.iter().any(|r| r == &v) {
8023                    add_var(&mut lookup_vars, &v);
8024                }
8025            }
8026        }
8027
8028        // Synthesize a read query: MATCH … WHERE … RETURN <lookup_vars>, <set_values…>
8029        // SET values are projected as ScalarExpr items so that arithmetic expressions
8030        // (e.g. `SET n.score = n.score * 1.5`) are evaluated in the matched-row context.
8031        let mut set_returns: Vec<RetItem> = lookup_vars
8032            .iter()
8033            .map(|v| RetItem {
8034                value: RetVal::Var(v.clone()),
8035                alias: None,
8036            })
8037            .collect();
8038        // One computed column per SET clause; alias is `__sv_<i>`.
8039        let set_val_cols: Vec<String> = stmt
8040            .sets
8041            .iter()
8042            .enumerate()
8043            .map(|(i, _)| format!("__sv_{i}"))
8044            .collect();
8045        for (sc, col) in stmt.sets.iter().zip(&set_val_cols) {
8046            set_returns.push(RetItem {
8047                value: RetVal::ScalarExpr(sc.value.clone()),
8048                alias: Some(col.clone()),
8049            });
8050        }
8051        // Capture relationship types while r is bound; SET does not change them.
8052        for r in &rel_vars {
8053            set_returns.push(RetItem {
8054                value: RetVal::FuncCall {
8055                    name: "type".into(),
8056                    args: vec![Operand::Var(r.clone())],
8057                },
8058                alias: Some(rel_type_alias(r)),
8059            });
8060        }
8061
8062        let read_q = Query {
8063            matches: stmt.matches.clone(),
8064            optional_clauses: vec![],
8065            where_expr: stmt.where_expr.clone(),
8066            unwinds: vec![],
8067            post_unwind_where: None,
8068            stages: vec![],
8069            returns: set_returns,
8070            distinct: false,
8071            order_by: vec![],
8072            skip: None,
8073            limit: None,
8074        };
8075        let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
8076            detail: format!("plan: {e}"),
8077        })?;
8078        // MATCH phase is read-only; borrow ends before batch opens.
8079        //
8080        // When a role-scoped write is in flight, run the MATCH read through
8081        // view_masked so hidden nodes are invisible → hidden ≡ absent ≡
8082        // zero-rows (no SetProp ops generated, no existence-oracle 403).
8083        // Full-authority writes (pending_write_authz=None) keep view().
8084        let match_rs = {
8085            let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
8086            if let Some(ref mask) = mask_opt {
8087                execute(&self.view_masked(mask), &ops, &Params(params))
8088            } else {
8089                execute(&self.view(), &ops, &Params(params))
8090            }
8091        }
8092        .map_err(|e| GraphError::QueryError {
8093            detail: format!("execute: {e}"),
8094        })?;
8095
8096        // Collect (key, field, value) for each matched row × each SET clause.
8097        let mut set_ops: Vec<(String, String, Value)> = Vec::new();
8098        for row_i in 0..match_rs.len() {
8099            for (sc, col) in stmt.sets.iter().zip(&set_val_cols) {
8100                let key = match match_rs.get(row_i, &sc.var) {
8101                    Some(Value::Str(k)) => k.clone(),
8102                    _ => {
8103                        return Err(GraphError::QueryError {
8104                            detail: format!(
8105                                "SET variable '{}' did not resolve to a node key",
8106                                sc.var
8107                            ),
8108                        })
8109                    }
8110                };
8111                // The SET value was already evaluated by the executor.
8112                let value = match match_rs.get(row_i, col) {
8113                    Some(v) => v.clone(),
8114                    None => {
8115                        return Err(GraphError::QueryError {
8116                            detail: format!(
8117                                "SET value for {}.{} evaluated to null",
8118                                sc.var, sc.field
8119                            ),
8120                        })
8121                    }
8122                };
8123                set_ops.push((key, sc.field.clone(), value));
8124            }
8125        }
8126
8127        // Apply as one atomic batch.
8128        let props_set = set_ops.len();
8129        let mut batch = self.batch();
8130        for (key, field, value) in set_ops {
8131            batch.set_prop(&key, &field, value);
8132        }
8133        batch.commit()?;
8134
8135        if let Some(returns) = project_returns {
8136            return project_set_return_rows(self, &rel_vars, &match_rs, &returns, params);
8137        }
8138
8139        let mut rs = write_result_set();
8140        rs.push_row(vec![
8141            Some(Value::Int(0)),
8142            Some(Value::Int(props_set as i64)),
8143            Some(Value::Int(0)),
8144        ]);
8145        Ok(rs)
8146    }
8147
8148    fn exec_match_delete(
8149        &mut self,
8150        stmt: core_query::cypher::MatchDeleteStmt,
8151        params: &BTreeMap<String, Value>,
8152    ) -> Result<ResultSet> {
8153        // Collect unique node vars needed to identify edge endpoints.
8154        let mut node_vars: Vec<String> = Vec::new();
8155        for ed in &stmt.deletes {
8156            if !node_vars.contains(&ed.src_var) {
8157                node_vars.push(ed.src_var.clone());
8158            }
8159            if !node_vars.contains(&ed.dst_var) {
8160                node_vars.push(ed.dst_var.clone());
8161            }
8162        }
8163
8164        // Synthesize read query.
8165        let returns: Vec<RetItem> = node_vars
8166            .iter()
8167            .map(|v| RetItem {
8168                value: RetVal::Var(v.clone()),
8169                alias: None,
8170            })
8171            .collect();
8172        let read_q = Query {
8173            matches: stmt.matches,
8174            optional_clauses: vec![],
8175            where_expr: stmt.where_expr,
8176            unwinds: vec![],
8177            post_unwind_where: None,
8178            stages: vec![],
8179            returns,
8180            distinct: false,
8181            order_by: vec![],
8182            skip: None,
8183            limit: None,
8184        };
8185        let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
8186            detail: format!("plan: {e}"),
8187        })?;
8188        // Role-scoped writes: mask the MATCH read phase so hidden nodes are
8189        // invisible → hidden ≡ absent ≡ zero-rows (spec §3.1, hidden ≡ absent).
8190        let match_rs = {
8191            let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
8192            if let Some(ref mask) = mask_opt {
8193                execute(&self.view_masked(mask), &ops, &Params(params))
8194            } else {
8195                execute(&self.view(), &ops, &Params(params))
8196            }
8197        }
8198        .map_err(|e| GraphError::QueryError {
8199            detail: format!("execute: {e}"),
8200        })?;
8201
8202        // Collect (etype, src_key, dst_key) for each row × each delete target.
8203        let mut del_ops: Vec<(String, String, String)> = Vec::new();
8204        for row_i in 0..match_rs.len() {
8205            for ed in &stmt.deletes {
8206                let src_key = match match_rs.get(row_i, &ed.src_var) {
8207                    Some(Value::Str(k)) => k.clone(),
8208                    _ => {
8209                        return Err(GraphError::QueryError {
8210                            detail: format!(
8211                                "DELETE src variable '{}' did not resolve to a node key",
8212                                ed.src_var
8213                            ),
8214                        })
8215                    }
8216                };
8217                let dst_key = match match_rs.get(row_i, &ed.dst_var) {
8218                    Some(Value::Str(k)) => k.clone(),
8219                    _ => {
8220                        return Err(GraphError::QueryError {
8221                            detail: format!(
8222                                "DELETE dst variable '{}' did not resolve to a node key",
8223                                ed.dst_var
8224                            ),
8225                        })
8226                    }
8227                };
8228                del_ops.push((ed.etype.clone(), src_key, dst_key));
8229            }
8230        }
8231
8232        // Apply as one atomic batch.
8233        let deleted = del_ops.len();
8234        let mut batch = self.batch();
8235        for (etype, src_key, dst_key) in del_ops {
8236            batch.delete_edge(&etype, &src_key, &dst_key);
8237        }
8238        batch.commit().map_err(|e| match e {
8239            GraphError::RuleOwned { .. } => GraphError::QueryError {
8240                detail: "cannot delete derived edge; retract via the rule or change the property"
8241                    .to_string(),
8242            },
8243            other => other,
8244        })?;
8245
8246        let mut rs = write_result_set();
8247        rs.push_row(vec![
8248            Some(Value::Int(0)),
8249            Some(Value::Int(0)),
8250            Some(Value::Int(deleted as i64)),
8251        ]);
8252        Ok(rs)
8253    }
8254
8255    /// Execute `MATCH … [DETACH] DELETE <node_var> [, …]`.
8256    ///
8257    /// Collects the matching node keys via an ephemeral read query, then calls
8258    /// `delete_node` on each one.  When `stmt.detach` is `false` (bare DELETE)
8259    /// the executor first checks that the node has no incident edges; if any
8260    /// remain it returns a named error matching openCypher semantics.
8261    fn exec_match_delete_node(
8262        &mut self,
8263        stmt: MatchDeleteNodeStmt,
8264        params: &BTreeMap<String, Value>,
8265    ) -> Result<ResultSet> {
8266        // Build a read query returning only the node keys we need.
8267        let returns: Vec<RetItem> = stmt
8268            .node_vars
8269            .iter()
8270            .map(|v| RetItem {
8271                value: RetVal::Var(v.clone()),
8272                alias: None,
8273            })
8274            .collect();
8275        let read_q = Query {
8276            matches: stmt.matches,
8277            optional_clauses: vec![],
8278            where_expr: stmt.where_expr,
8279            unwinds: vec![],
8280            post_unwind_where: None,
8281            stages: vec![],
8282            returns,
8283            distinct: false,
8284            order_by: vec![],
8285            skip: None,
8286            limit: None,
8287        };
8288        let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
8289            detail: format!("plan: {e}"),
8290        })?;
8291        // Role-scoped writes: mask the MATCH read phase so hidden nodes are
8292        // invisible → hidden ≡ absent ≡ zero-rows (spec §3.1, hidden ≡ absent).
8293        let match_rs = {
8294            let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
8295            if let Some(ref mask) = mask_opt {
8296                execute(&self.view_masked(mask), &ops, &Params(params))
8297            } else {
8298                execute(&self.view(), &ops, &Params(params))
8299            }
8300        }
8301        .map_err(|e| GraphError::QueryError {
8302            detail: format!("execute: {e}"),
8303        })?;
8304
8305        // Collect unique node keys to delete (deduplicate across rows × vars).
8306        let mut keys: Vec<String> = Vec::new();
8307        for row_i in 0..match_rs.len() {
8308            for var in &stmt.node_vars {
8309                if let Some(Value::Str(k)) = match_rs.get(row_i, var) {
8310                    if !keys.contains(k) {
8311                        keys.push(k.clone());
8312                    }
8313                }
8314            }
8315        }
8316
8317        if !stmt.detach {
8318            // openCypher bare DELETE: error if any matched node has incident edges.
8319            for key in &keys {
8320                if let Some(id) = self.ids.get(key) {
8321                    let tv = self.topo_view();
8322                    let has_edges = tv.etypes().any(|et| {
8323                        !tv.neighbors(et, Direction::Out, id).is_empty()
8324                            || !tv.neighbors(et, Direction::In, id).is_empty()
8325                    });
8326                    if has_edges {
8327                        return Err(GraphError::QueryError {
8328                            detail: format!(
8329                                "Cannot delete node `{key}` because it still has incident edges. \
8330                                 Use DETACH DELETE to remove the node and all its edges."
8331                            ),
8332                        });
8333                    }
8334                }
8335            }
8336        }
8337
8338        let mut nodes_deleted = 0i64;
8339        let mut edges_deleted = 0i64;
8340        for key in keys {
8341            match self.delete_node(&key) {
8342                Ok(report) => {
8343                    nodes_deleted += 1;
8344                    edges_deleted += (report.manual_edges + report.derived_edges) as i64;
8345                }
8346                Err(GraphError::KeyNotFound { .. }) => {
8347                    // Node may have been deleted by an earlier iteration (e.g., via
8348                    // multiple MATCH rows for the same node).  Safe to skip.
8349                }
8350                Err(e) => return Err(e),
8351            }
8352        }
8353
8354        let mut rs = write_result_set();
8355        rs.push_row(vec![
8356            Some(Value::Int(0)),
8357            Some(Value::Int(0)),
8358            Some(Value::Int(nodes_deleted + edges_deleted)),
8359        ]);
8360        Ok(rs)
8361    }
8362
8363    fn exec_merge(
8364        &mut self,
8365        stmt: core_query::cypher::MergeStmt,
8366        params: &BTreeMap<String, Value>,
8367    ) -> Result<ResultSet> {
8368        // MERGE: check if a node with the given key already exists.
8369        let key = match &stmt.key_value {
8370            Value::Str(s) => s.clone(),
8371            _ => {
8372                return Err(GraphError::QueryError {
8373                    detail: format!(
8374                        "MERGE key value must be a string (got {:?})",
8375                        stmt.key_value
8376                    ),
8377                })
8378            }
8379        };
8380
8381        if let Some(var) = stmt.var.as_deref() {
8382            for sc in stmt.on_create.iter().chain(&stmt.on_match) {
8383                if sc.var != var {
8384                    return Err(GraphError::QueryError {
8385                        detail: format!(
8386                            "SET variable '{}' does not match MERGE variable '{var}'",
8387                            sc.var
8388                        ),
8389                    });
8390                }
8391            }
8392        }
8393
8394        // ── MERGE authz pre-check (when role-scoped) ─────────────────────────
8395        //
8396        // MERGE scope precondition: check create OR update scope for the
8397        // declared label BEFORE calling `has_node` (timing-oracle closure,
8398        // spec §6.2 "MERGE visibility oracle" item: hidden ≡ absent for
8399        // unscoped roles — the scope denial fires without touching the key store).
8400        //
8401        // Clone to avoid holding a borrow on `self.pending_write_authz` while
8402        // also calling `self.ids.get(key)`.
8403        let merge_existed: bool = if let Some(authz) = self.pending_write_authz.clone() {
8404            let has_create = authz.scope.create_labels.contains(&stmt.label);
8405            let has_update = authz.scope.update_labels.contains(&stmt.label);
8406            if !has_create && !has_update {
8407                // Scope-before-lookup: 403 without has_node call (timing oracle
8408                // closure — see test_merge_unscoped_no_key_lookup).
8409                return Err(GraphError::RoleWriteDenied {
8410                    reason: format!(
8411                        "role-bound token: label '{}' not in write scope (create_labels)",
8412                        stmt.label
8413                    ),
8414                });
8415            }
8416            // Key lookup under mask.
8417            match self.ids.get(key.as_str()) {
8418                Some(id) if authz.mask.contains_id(id) => {
8419                    // Visible: must have update scope to proceed to match arm.
8420                    if !has_update {
8421                        return Err(GraphError::RoleWriteDenied {
8422                            reason: format!(
8423                                "role-bound token: label '{}' not in write scope (update_labels)",
8424                                stmt.label
8425                            ),
8426                        });
8427                    }
8428                    true // existed = true → match arm
8429                }
8430                Some(_) => {
8431                    // Hidden: same error as absent to the role (spec §3.1/§3.3).
8432                    return Err(GraphError::RoleWriteDenied {
8433                        reason: "role-bound token: target node not visible".into(),
8434                    });
8435                }
8436                None => {
8437                    // Absent: must have create scope to proceed to the create arm.
8438                    //
8439                    // Update-only roles (create_labels empty, update_labels set):
8440                    // return the SAME "not visible" error as the hidden-key branch
8441                    // so hidden ≡ absent — no distinguishing oracle (spec §6.1
8442                    // "confirm existence of hidden nodes: No").
8443                    //
8444                    // Create-scoped roles (has_create=true): absent → create arm
8445                    // as before.  The accepted structural key-existence disclosure
8446                    // (§THREAT-MODEL) applies only when the role holds create scope.
8447                    if !has_create {
8448                        return Err(GraphError::RoleWriteDenied {
8449                            reason: "role-bound token: target node not visible".into(),
8450                        });
8451                    }
8452                    false // existed = false → create arm
8453                }
8454            }
8455        } else {
8456            // Full authority: use the existing non-masked has_node check.
8457            self.has_node(&key)
8458        };
8459
8460        let existed = merge_existed;
8461        let mut created = 0i64;
8462        if !existed || !stmt.on_match.is_empty() {
8463            let mut batch = self.batch();
8464            if !existed {
8465                let props = vec![(stmt.key_field.clone(), stmt.key_value.clone())];
8466                batch.insert_node(&stmt.label, &key, props);
8467                for sc in &stmt.on_create {
8468                    let value = resolve_merge_set_value(&sc.value, params)?;
8469                    batch.set_prop(&key, &sc.field, value);
8470                }
8471                created = 1;
8472            } else {
8473                for sc in &stmt.on_match {
8474                    let value = resolve_merge_set_value(&sc.value, params)?;
8475                    batch.set_prop(&key, &sc.field, value);
8476                }
8477            }
8478            batch.commit()?;
8479        }
8480
8481        // Refresh the role mask so the just-created node is visible to this
8482        // statement's RETURN (read-after-write). Safe: create_labels ⊆ read labels
8483        // (apply_schema subset rule), so the new node's label is already in the
8484        // role's read scope — this never widens beyond the role's declared labels.
8485        if !existed {
8486            if let Some(role) = self.pending_write_authz.as_ref().map(|a| a.role.clone()) {
8487                let new_mask = self.mask_for_role(&role)?;
8488                if let Some(a) = self.pending_write_authz.as_mut() {
8489                    a.mask = new_mask;
8490                }
8491            }
8492        }
8493
8494        // Optional RETURN clause: project the node (created or matched) as a read result.
8495        if let Some(returns) = stmt.returns {
8496            let var = stmt.var.as_deref().unwrap_or("_mn0");
8497            let q = Query {
8498                matches: vec![Pattern {
8499                    start: NodePat {
8500                        var: Some(var.to_string()),
8501                        label: Some(stmt.label.clone()),
8502                        props: vec![("id".to_string(), Operand::Lit(stmt.key_value.clone()))],
8503                    },
8504                    chain: vec![],
8505                    shortest: false,
8506                }],
8507                optional_clauses: vec![],
8508                where_expr: None,
8509                unwinds: vec![],
8510                post_unwind_where: None,
8511                stages: vec![],
8512                returns,
8513                distinct: false,
8514                order_by: vec![],
8515                skip: None,
8516                limit: None,
8517            };
8518            let ops = plan(&q).map_err(|e| GraphError::QueryError {
8519                detail: format!("plan: {e}"),
8520            })?;
8521            // Use view_masked when a role-scoped write is in flight so the
8522            // post-merge projection is consistent with the masked read phase.
8523            let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
8524            return (if let Some(ref mask) = mask_opt {
8525                execute(&self.view_masked(mask), &ops, &Params(params))
8526            } else {
8527                execute(&self.view(), &ops, &Params(params))
8528            })
8529            .map_err(|e| GraphError::QueryError {
8530                detail: format!("execute: {e}"),
8531            });
8532        }
8533
8534        let mut rs = write_result_set();
8535        rs.push_row(vec![
8536            Some(Value::Int(created)),
8537            Some(Value::Int(0)),
8538            Some(Value::Int(0)),
8539        ]);
8540        Ok(rs)
8541    }
8542
8543    /// Return all rule-owned edges between `key_a` and `key_b` (either direction),
8544    /// annotated with rule name, edge type, direction, and weight.
8545    /// Results are sorted by (rule, edge_type).
8546    /// Returns `Err(KeyNotFound)` if either key is unknown.
8547    pub fn explain(&self, key_a: &str, key_b: &str) -> Result<Vec<Explanation>> {
8548        self.ensure_v8_base_sections_loaded();
8549        let id_a = self
8550            .ids
8551            .get(key_a)
8552            .ok_or_else(|| GraphError::KeyNotFound { key: key_a.into() })?;
8553        let id_b = self
8554            .ids
8555            .get(key_b)
8556            .ok_or_else(|| GraphError::KeyNotFound { key: key_b.into() })?;
8557
8558        let mut results = Vec::new();
8559
8560        // Walk the smaller incident set so explain is O(min(deg(a), deg(b)))
8561        // rather than O(total provenance).
8562        let scan = if self.engine.provenance_touching_len(id_a)
8563            <= self.engine.provenance_touching_len(id_b)
8564        {
8565            id_a
8566        } else {
8567            id_b
8568        };
8569        for (rule_name, etype, src, dst) in self.engine.provenance_touching(scan) {
8570            if !((src == id_a && dst == id_b) || (src == id_b && dst == id_a)) {
8571                continue;
8572            }
8573            let Some(rule_def) = self.engine.rules().find(|r| r.name == rule_name) else {
8574                continue;
8575            };
8576            let edge_type = match self.syms.resolve(etype) {
8577                Some(s) => s.to_string(),
8578                None => continue,
8579            };
8580            // Provenance (src, dst) ids come from the archived PROVENANCE section
8581            // (large, no eager CRC).  A corrupt section can produce ids that are
8582            // out of range; return Corrupt rather than panic.
8583            let src_key = self
8584                .ids
8585                .key_of(src)
8586                .ok_or_else(|| GraphError::Corrupt {
8587                    detail: format!("v8: provenance src id {src} not in id table"),
8588                })?
8589                .to_string();
8590            let dst_key = self
8591                .ids
8592                .key_of(dst)
8593                .ok_or_else(|| GraphError::Corrupt {
8594                    detail: format!("v8: provenance dst id {dst} not in id table"),
8595                })?
8596                .to_string();
8597            let stored = rule_def.weight_prop.as_deref().and_then(|prop| {
8598                self.edge_props_view()
8599                    .get(etype, src, dst, prop)
8600                    .and_then(|v| {
8601                        if let Value::Float(f) = v {
8602                            Some(f)
8603                        } else {
8604                            None
8605                        }
8606                    })
8607            });
8608            // Rules that store no weight (KeyMatch/FieldEqual defaults, auto-FK)
8609            // still have a score: recompute it from the predicate so explain
8610            // never reports "no score" for an edge the engine scored.  Via-hop
8611            // rules score over their via set, not over (src, dst), so leave
8612            // those None rather than report a number the rule did not produce.
8613            let weight = stored.or_else(|| {
8614                if rule_def.via_edge.is_some() {
8615                    return None;
8616                }
8617                let props_view = build_props_view(&self.props, &self.base);
8618                let src_get = |field: &str| props_view.get(src, field).map(|vr| vr.into_value());
8619                let dst_get = |field: &str| props_view.get(dst, field).map(|vr| vr.into_value());
8620                let src_view = NodeView {
8621                    key: &src_key,
8622                    props: &src_get,
8623                };
8624                let dst_view = NodeView {
8625                    key: &dst_key,
8626                    props: &dst_get,
8627                };
8628                evaluate(&rule_def.predicate, &src_view, &dst_view)
8629            });
8630            results.push(Explanation {
8631                rule: rule_name.to_string(),
8632                edge_type,
8633                src_key,
8634                dst_key,
8635                weight,
8636                predicate: PredicateSummary {
8637                    approximate: rule_def.approximate,
8638                    ..PredicateSummary::from(&rule_def.predicate)
8639                },
8640                via_edge: rule_def.via_edge.clone(),
8641            });
8642        }
8643
8644        results.sort_by(|a, b| a.rule.cmp(&b.rule).then(a.edge_type.cmp(&b.edge_type)));
8645        Ok(results)
8646    }
8647
8648    pub fn neighbors(&self, key: &str, edge_type: &str, dir: Direction) -> Result<Vec<String>> {
8649        let id = self
8650            .ids
8651            .get(key)
8652            .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
8653        let Some(sym) = self.syms.get(edge_type) else {
8654            return Ok(Vec::new());
8655        };
8656        self.topo_view()
8657            .neighbors(sym, dir, id)
8658            .iter()
8659            .map(|&n| {
8660                self.ids
8661                    .key_of(n)
8662                    .map(|k| k.to_string())
8663                    .ok_or_else(|| GraphError::Corrupt {
8664                        detail: format!("topology id {n} has no key"),
8665                    })
8666            })
8667            .collect::<Result<Vec<_>>>()
8668    }
8669
8670    /// Return the last-change commit sequence for `key`, or `None` if the node
8671    /// does not exist or has never been mutated since the last V5-V7 snapshot
8672    /// (horizon-bounded for legacy stores).
8673    ///
8674    /// The returned sequence is a monotonically increasing counter that starts
8675    /// at 1 for the first commit after `open` and increments with every
8676    /// successful write.  WAL replay at open also assigns sequences (1..N for N
8677    /// replayed frames), so sequences are consistent across snapshot+WAL cycles.
8678    ///
8679    /// For V5-V7 stores opened without a V8 snapshot, nodes that were present
8680    /// in the snapshot but not touched by any WAL frame will return `None`
8681    /// (horizon-bounded: CAS against such nodes is only safe after the first
8682    /// V8 snapshot or after the node is next mutated).
8683    pub fn last_changed(&self, key: &str) -> Option<u64> {
8684        let id = self.ids.get(key)?;
8685        self.last_change.get(&id).copied()
8686    }
8687
8688    /// The current commit sequence (number of successful commits since open,
8689    /// including WAL replay frames).  Useful for recording a baseline before
8690    /// a read-modify-write cycle.
8691    pub fn commit_seq(&self) -> u64 {
8692        self.commit_seq
8693    }
8694
8695    /// Check that all `preconds` are satisfied against the current db state.
8696    /// Returns `Err(GraphError::CasConflict)` on the first failing precondition.
8697    pub(crate) fn check_preconditions(&self, preconds: &[Precondition]) -> Result<()> {
8698        for precond in preconds {
8699            match precond {
8700                Precondition::NodeUnchangedSince { key, expected } => {
8701                    // Missing entry means the node predates the WAL window or
8702                    // does not exist; treat as 0 (before any commit).
8703                    let actual = self.last_changed(key).unwrap_or_default();
8704                    if actual != *expected {
8705                        return Err(GraphError::CasConflict {
8706                            key: key.clone(),
8707                            expected: *expected,
8708                            actual,
8709                        });
8710                    }
8711                }
8712                Precondition::NodeAbsent { key } => {
8713                    // Node must not exist (not live).
8714                    if self.ids.get(key).is_some() {
8715                        let actual = self.last_changed(key).unwrap_or(0);
8716                        return Err(GraphError::CasConflict {
8717                            key: key.clone(),
8718                            expected: u64::MAX,
8719                            actual,
8720                        });
8721                    }
8722                }
8723            }
8724        }
8725        Ok(())
8726    }
8727
8728    /// Apply a batch of mutations with compare-and-set preconditions.
8729    ///
8730    /// All preconditions are checked atomically before any operation is applied.
8731    /// If any precondition fails, the entire batch is rejected with
8732    /// [`GraphError::CasConflict`] and no WAL frame is written.
8733    ///
8734    /// # Returns
8735    /// `(nodes_inserted, edges_inserted)` on success, same as [`write_batch`].
8736    ///
8737    /// # Errors
8738    /// - [`GraphError::CasConflict`] if any precondition is not satisfied.
8739    /// - Any error that [`write_batch`] would return for the ops themselves.
8740    pub fn write_batch_cas(
8741        &mut self,
8742        preconds: Vec<Precondition>,
8743        ops: Vec<BatchOp>,
8744    ) -> Result<(usize, usize)> {
8745        self.check_preconditions(&preconds)?;
8746        self.commit_logged_batch(ops, None, None)
8747    }
8748
8749    /// Update the per-node last-change map for a WAL record at commit `seq`.
8750    ///
8751    /// Called after a successful apply to record which nodes were touched.
8752    /// For replay, called with the WAL-frame's replayed seq.
8753    ///
8754    /// Touch definition (see [`Precondition`] doc):
8755    /// - InsertNode / InsertNodeId / SetProp / SetPropId / RemoveProp → the node.
8756    /// - InsertEdge / InsertEdgeId / DeleteEdge → both src and dst.
8757    /// - DeleteNode → node tombstoned; last_changed() returns None so no update needed.
8758    /// - DerivedEdge markers, Intern, rule/view records → no-ops.
8759    /// - Batch → recurse into inner records.
8760    fn update_last_change_from_rec(&mut self, rec: &WalRecord, seq: u64) {
8761        match rec {
8762            WalRecord::InsertNode { key, .. }
8763            | WalRecord::SetProp { key, .. }
8764            | WalRecord::RemoveProp { key, .. } => {
8765                if let Some(id) = self.ids.get(key) {
8766                    self.last_change.insert(id, seq);
8767                }
8768            }
8769            WalRecord::InsertNodeId { key, .. } => {
8770                if let Some(id) = self.ids.get(key) {
8771                    self.last_change.insert(id, seq);
8772                }
8773            }
8774            WalRecord::SetPropId { id, .. } => {
8775                self.last_change.insert(*id, seq);
8776            }
8777            WalRecord::InsertEdge {
8778                src_key, dst_key, ..
8779            }
8780            | WalRecord::DeleteEdge {
8781                src_key, dst_key, ..
8782            } => {
8783                if let Some(src_id) = self.ids.get(src_key) {
8784                    self.last_change.insert(src_id, seq);
8785                }
8786                if let Some(dst_id) = self.ids.get(dst_key) {
8787                    self.last_change.insert(dst_id, seq);
8788                }
8789            }
8790            WalRecord::InsertEdgeId { src, dst, .. } => {
8791                self.last_change.insert(*src, seq);
8792                self.last_change.insert(*dst, seq);
8793            }
8794            // DeleteNode: node is tombstoned; last_changed(key) returns None for
8795            // deleted keys (ids.get() returns None post-tombstone), so no update needed.
8796            // History markers: state no-ops; the underlying mutation already
8797            // touched the relevant nodes' last_change entries.
8798            WalRecord::DeleteNode { .. }
8799            | WalRecord::DerivedEdgeAdded { .. }
8800            | WalRecord::DerivedEdgeRetracted { .. }
8801            | WalRecord::Intern { .. }
8802            | WalRecord::CreateRule { .. }
8803            | WalRecord::DeleteRule { .. }
8804            | WalRecord::RebuildRule { .. }
8805            | WalRecord::CreateView { .. }
8806            | WalRecord::DeleteView { .. }
8807            | WalRecord::EnableFulltext { .. }
8808            | WalRecord::DisableFulltext { .. }
8809            | WalRecord::EnableIndex { .. }
8810            | WalRecord::DisableIndex { .. } => {}
8811            // RenameNode: node id is stable; update last_change via the new key.
8812            // Called after apply(), so ids already reflects new_key.
8813            WalRecord::RenameNode { new_key, .. } => {
8814                if let Some(id) = self.ids.get(new_key) {
8815                    self.last_change.insert(id, seq);
8816                }
8817            }
8818            WalRecord::Batch(inner) => {
8819                for inner_rec in inner {
8820                    self.update_last_change_from_rec(inner_rec, seq);
8821                }
8822            }
8823        }
8824    }
8825
8826    pub fn node_count(&self) -> usize {
8827        self.ids.len()
8828    }
8829
8830    /// Configure archive retention: keep the `N` newest WAL archives at each
8831    /// [`snapshot_with`] call when `archive_wal: true`.
8832    ///
8833    /// `Some(N)` where N > 0 → prune oldest archives keeping the newest N.
8834    /// `Some(0)` or `None` → unlimited (no pruning).
8835    ///
8836    /// Pruning only ever happens inside [`snapshot_with`]; this method only
8837    /// stores the policy.  Archives below the retention limit are deleted
8838    /// oldest-first.  The horizon floor is updated so that
8839    /// [`was_linked`] / history APIs return `CommitOutOfRange` for commits
8840    /// in pruned archives rather than silently returning wrong data.
8841    pub fn set_wal_archive_retention(&mut self, keep: Option<u32>) {
8842        self.wal_archive_retention = keep;
8843    }
8844
8845    /// Delete any WAL archives that are fully below the current horizon floor.
8846    ///
8847    /// Orphaned archives arise when the floor is written first during retention
8848    /// pruning and then a crash interrupts the archive-delete sequence.  The
8849    /// opening cleanup ensures no subsequent read path sees stale data.
8850    ///
8851    /// Under the monotonic naming scheme, the archive name N equals the
8852    /// cumulative end-frame index of the archive in global commit space (i.e.
8853    /// the archive covers global frames `[prev_n, N)`).  An archive is
8854    /// fully orphaned when `N <= wal_horizon_floor`: all of its frames fall
8855    /// below the floor and have already been counted in it.
8856    fn cleanup_orphaned_archives(&mut self) -> Result<()> {
8857        if self.wal_horizon_floor == 0 {
8858            // Floor at 0 means no pruning has ever occurred; nothing to clean.
8859            return Ok(());
8860        }
8861        let archive_ns = self.fs.list_archives()?;
8862        for n in archive_ns {
8863            if n <= self.wal_horizon_floor {
8864                // Archive N ends at global frame N; all its frames are below
8865                // the floor (floor already accounts for them) → orphaned.
8866                self.fs.delete_archive(n).map_err(GraphError::Io)?;
8867            } else {
8868                // Archives are sorted ascending; first one above floor stops scan.
8869                break;
8870            }
8871        }
8872        Ok(())
8873    }
8874
8875    /// Collect all WAL frames from surviving archives (oldest-first) then the
8876    /// live WAL into one flat list, and return the total along with the number
8877    /// of archive frames at the front of the list.
8878    ///
8879    /// Commit indices into the returned list are LOCAL (0 = first frame of
8880    /// oldest surviving archive).  To obtain the GLOBAL index add
8881    /// `self.wal_horizon_floor`.
8882    fn all_frames(&self) -> Result<(Vec<WalRecord>, u64)> {
8883        let archive_ns = self.fs.list_archives()?;
8884        let mut all: Vec<WalRecord> = Vec::new();
8885        for n in archive_ns {
8886            let bytes = self.fs.read_archive(n)?;
8887            let (frames, _) = decode_all(&bytes);
8888            all.extend(frames);
8889        }
8890        let archive_count = all.len() as u64;
8891        let live_bytes = self.fs.read(FileId::Wal)?;
8892        let (live_frames, _) = decode_all(&live_bytes);
8893        all.extend(live_frames);
8894        Ok((all, archive_count))
8895    }
8896
8897    /// Return the total number of committed WAL frames visible in the current
8898    /// horizon window, including frames in surviving WAL archives.
8899    ///
8900    /// This is the exclusive upper bound for valid `at_commit` indices in
8901    /// `was_linked`. Valid indices are `wal_horizon_floor()..wal_total_commits()`.
8902    ///
8903    /// Returns the horizon floor when all surviving history is empty.
8904    pub fn wal_total_commits(&self) -> Result<u64> {
8905        let (frames, _) = self.all_frames()?;
8906        Ok(self.wal_horizon_floor + frames.len() as u64)
8907    }
8908
8909    /// The global frame index of the first commit reachable through surviving
8910    /// archives (0 when no archives have been pruned).
8911    pub fn wal_horizon_floor(&self) -> u64 {
8912        self.wal_horizon_floor
8913    }
8914
8915    /// Return the per-node change history for `key` by scanning the on-disk WAL.
8916    ///
8917    /// ## Horizon
8918    ///
8919    /// History reaches back only to the last WAL-truncating snapshot, exactly like `open_at`.
8920    /// Snapshots written with `keep_wal: true` preserve deeper history. This is the honest,
8921    /// zero-cost contract; a durable history log is out of scope.
8922    ///
8923    /// ## Derived edges
8924    ///
8925    /// Rule-created (derived) edges are **not** in the WAL and therefore do not appear in
8926    /// history. Only edges written directly by the application are recorded.
8927    ///
8928    /// ## Deleted nodes
8929    ///
8930    /// For nodes that have been deleted, dense-id records (SetPropId, InsertEdgeId) that
8931    /// predate the deletion may not resolve (the id is tombstoned in the live map). The
8932    /// string-keyed `DeleteNode` record still matches and produces a `NodeDeleted` entry.
8933    /// Prop/edge history of a deleted node may therefore be partially unresolvable.
8934    ///
8935    /// ## Dense-id edge entries and tombstoned partners
8936    ///
8937    /// Edge entries from dense-id WAL records (`InsertEdgeId`) are omitted when the partner
8938    /// endpoint's dense id is tombstoned. As a result, a live node's history can contain an
8939    /// `EdgeRemoved` (string-keyed, always resolves) without a corresponding `EdgeAdded`.
8940    /// Build commit-bounded alias intervals for `queried_key`.
8941    ///
8942    /// Returns a list of `(key, valid_from_inclusive, valid_until_exclusive)` tuples.
8943    /// A record written under `key` at commit `c` matches the queried identity iff
8944    /// `c >= valid_from && (valid_until.is_none() || c < valid_until)`.
8945    ///
8946    /// Each alias entry carries both a lower and an upper bound so that key-reuse
8947    /// after a rename is handled correctly: if "a" is renamed to "b" at commit 5,
8948    /// then a NEW node is created as "a" at commit 7 and renamed to "c" at commit 10,
8949    /// querying "c" must NOT surface identity-1's events (commits 0–4 under "a");
8950    /// only identity-2's events (commits 7–9 under "a") are in scope.
8951    ///
8952    /// Only **forward aliasing**: querying the *new* key surfaces events written
8953    /// under the *old* key.  The reverse direction is not supported.
8954    fn build_key_alias_intervals(
8955        &self,
8956        frames: &[core_storage::wal::WalRecord],
8957        queried_key: &str,
8958    ) -> Vec<(String, u64, Option<u64>)> {
8959        use core_storage::wal::WalRecord;
8960
8961        // Pre-pass: build reverse_rename and key_starts maps.
8962        let mut reverse_rename: HashMap<String, (String, u64)> = HashMap::new();
8963        let mut key_starts: HashMap<String, Vec<u64>> = HashMap::new();
8964
8965        for (local_i, frame) in frames.iter().enumerate() {
8966            let commit = self.wal_horizon_floor + local_i as u64;
8967            let records: &[WalRecord] = match frame {
8968                WalRecord::Batch(inner) => inner.as_slice(),
8969                single => std::slice::from_ref(single),
8970            };
8971            for rec in records {
8972                match rec {
8973                    WalRecord::InsertNode { key, .. } | WalRecord::InsertNodeId { key, .. } => {
8974                        key_starts.entry(key.clone()).or_default().push(commit);
8975                    }
8976                    WalRecord::RenameNode { old_key, new_key } => {
8977                        // new_key came into existence at this commit.
8978                        key_starts.entry(new_key.clone()).or_default().push(commit);
8979                        // Record the reverse rename: new_key was introduced by renaming old_key.
8980                        reverse_rename.insert(new_key.clone(), (old_key.clone(), commit));
8981                    }
8982                    _ => {}
8983                }
8984            }
8985        }
8986
8987        // Build alias intervals by following the reverse rename chain.
8988        let mut result: Vec<(String, u64, Option<u64>)> = Vec::new();
8989        let mut current_key = queried_key.to_string();
8990        let mut current_valid_until: Option<u64> = None;
8991
8992        loop {
8993            // valid_from: the most recent commit where current_key was assigned to this
8994            // identity.  For aliases (valid_until = Some(vu)), find the last start event
8995            // for the key strictly before vu — this is where the alias's occupancy by
8996            // this identity began, correctly excluding prior identities that reused the key.
8997            let valid_from = if let Some(vu) = current_valid_until {
8998                key_starts
8999                    .get(&current_key)
9000                    .and_then(|starts| starts.iter().rev().find(|&&s| s < vu).copied())
9001                    .unwrap_or(self.wal_horizon_floor)
9002            } else {
9003                // Queried key — no upper bound; may have been introduced at any commit.
9004                self.wal_horizon_floor
9005            };
9006
9007            result.push((current_key.clone(), valid_from, current_valid_until));
9008
9009            match reverse_rename.get(&current_key) {
9010                Some((old_key, rename_commit)) => {
9011                    current_valid_until = Some(*rename_commit);
9012                    current_key = old_key.clone();
9013                }
9014                None => break,
9015            }
9016        }
9017
9018        result
9019    }
9020
9021    /// Returns true if `record_key` matches any alias interval that covers `commit`.
9022    fn aliases_match(
9023        intervals: &[(String, u64, Option<u64>)],
9024        record_key: &str,
9025        commit: u64,
9026    ) -> bool {
9027        intervals
9028            .iter()
9029            .any(|(k, vf, vu)| k == record_key && commit >= *vf && vu.is_none_or(|u| commit < u))
9030    }
9031
9032    pub fn node_history(&self, key: &str) -> Result<Vec<crate::history::HistoryEntry>> {
9033        use crate::history::{HistoryChange, HistoryEntry};
9034        use core_storage::wal::WalRecord;
9035
9036        let (frames, _) = self.all_frames()?;
9037
9038        // Resolve commit-bounded alias intervals for `key` (handles renames in the WAL).
9039        let alias_intervals = self.build_key_alias_intervals(&frames, key);
9040
9041        let mut out: Vec<HistoryEntry> = Vec::new();
9042
9043        for (local_i, frame) in frames.iter().enumerate() {
9044            let commit = self.wal_horizon_floor + local_i as u64;
9045            // Collect the inner records to process — Batch is one commit, single records are one commit.
9046            let records: &[WalRecord] = match frame {
9047                WalRecord::Batch(inner) => inner.as_slice(),
9048                single => std::slice::from_ref(single),
9049            };
9050
9051            for rec in records {
9052                let change = match rec {
9053                    WalRecord::InsertNode { label, key: k, .. }
9054                        if Self::aliases_match(&alias_intervals, k, commit) =>
9055                    {
9056                        Some(HistoryChange::NodeInserted {
9057                            label: label.clone(),
9058                        })
9059                    }
9060                    WalRecord::InsertNodeId { label, key: k, .. }
9061                        if Self::aliases_match(&alias_intervals, k, commit) =>
9062                    {
9063                        let label_str = match self.syms.resolve(*label) {
9064                            Some(s) => s.to_string(),
9065                            None => continue,
9066                        };
9067                        Some(HistoryChange::NodeInserted { label: label_str })
9068                    }
9069                    WalRecord::SetProp {
9070                        key: k,
9071                        field,
9072                        value,
9073                    } if Self::aliases_match(&alias_intervals, k, commit) => {
9074                        Some(HistoryChange::PropSet {
9075                            field: field.clone(),
9076                            value: value.clone(),
9077                        })
9078                    }
9079                    WalRecord::SetPropId { id, field, value } => {
9080                        // Use key_of_historical (not key_of) so a node's prop_set
9081                        // events remain visible after the node is later deleted:
9082                        // key_of returns None for a tombstoned id, which would
9083                        // silently drop every PropSet between insert and delete.
9084                        // Mirrors the InsertEdgeId arm below and edge_history's
9085                        // own id-keyed arms.
9086                        match self.ids.key_of_historical(*id) {
9087                            // key_of_historical returns the last-known (possibly
9088                            // post-rename, possibly post-delete) key; compare to queried key.
9089                            Some(resolved) if resolved == key => {
9090                                let field_str = match self.syms.resolve(*field) {
9091                                    Some(s) => s.to_string(),
9092                                    None => continue,
9093                                };
9094                                Some(HistoryChange::PropSet {
9095                                    field: field_str,
9096                                    value: value.clone(),
9097                                })
9098                            }
9099                            _ => None,
9100                        }
9101                    }
9102                    WalRecord::RemoveProp { key: k, field }
9103                        if Self::aliases_match(&alias_intervals, k, commit) =>
9104                    {
9105                        Some(HistoryChange::PropRemoved {
9106                            field: field.clone(),
9107                        })
9108                    }
9109                    WalRecord::InsertEdge {
9110                        edge_type,
9111                        src_key,
9112                        dst_key,
9113                    } => {
9114                        if Self::aliases_match(&alias_intervals, src_key, commit) {
9115                            Some(HistoryChange::EdgeAdded {
9116                                edge_type: edge_type.clone(),
9117                                other: dst_key.clone(),
9118                                outgoing: true,
9119                            })
9120                        } else if Self::aliases_match(&alias_intervals, dst_key, commit) {
9121                            Some(HistoryChange::EdgeAdded {
9122                                edge_type: edge_type.clone(),
9123                                other: src_key.clone(),
9124                                outgoing: false,
9125                            })
9126                        } else {
9127                            None
9128                        }
9129                    }
9130                    WalRecord::InsertEdgeId { etype, src, dst } => {
9131                        let etype_str = match self.syms.resolve(*etype) {
9132                            Some(s) => s.to_string(),
9133                            None => continue,
9134                        };
9135                        // key_of_historical (not key_of): an edge added before
9136                        // either endpoint was later deleted must still resolve —
9137                        // see the SetPropId arm above and edge_history's
9138                        // InsertEdgeId arm, which use the same lookup for the
9139                        // same reason.
9140                        let src_key = self.ids.key_of_historical(*src);
9141                        let dst_key = self.ids.key_of_historical(*dst);
9142                        if src_key == Some(key) {
9143                            let other = match dst_key {
9144                                Some(s) => s.to_string(),
9145                                None => continue,
9146                            };
9147                            Some(HistoryChange::EdgeAdded {
9148                                edge_type: etype_str,
9149                                other,
9150                                outgoing: true,
9151                            })
9152                        } else if dst_key == Some(key) {
9153                            let other = match src_key {
9154                                Some(s) => s.to_string(),
9155                                None => continue,
9156                            };
9157                            Some(HistoryChange::EdgeAdded {
9158                                edge_type: etype_str,
9159                                other,
9160                                outgoing: false,
9161                            })
9162                        } else {
9163                            None
9164                        }
9165                    }
9166                    WalRecord::DeleteEdge {
9167                        edge_type,
9168                        src_key,
9169                        dst_key,
9170                    } => {
9171                        if Self::aliases_match(&alias_intervals, src_key, commit) {
9172                            Some(HistoryChange::EdgeRemoved {
9173                                edge_type: edge_type.clone(),
9174                                other: dst_key.clone(),
9175                                outgoing: true,
9176                            })
9177                        } else if Self::aliases_match(&alias_intervals, dst_key, commit) {
9178                            Some(HistoryChange::EdgeRemoved {
9179                                edge_type: edge_type.clone(),
9180                                other: src_key.clone(),
9181                                outgoing: false,
9182                            })
9183                        } else {
9184                            None
9185                        }
9186                    }
9187                    WalRecord::DeleteNode { key: k }
9188                        if Self::aliases_match(&alias_intervals, k, commit) =>
9189                    {
9190                        Some(HistoryChange::NodeDeleted)
9191                    }
9192                    // Skip: rule/view/fulltext/intern metadata; Batch wrapper handled above.
9193                    _ => None,
9194                };
9195
9196                if let Some(change) = change {
9197                    out.push(HistoryEntry { commit, change });
9198                }
9199            }
9200        }
9201
9202        Ok(out)
9203    }
9204
9205    /// Return the per-edge change history between nodes `a` and `b` by scanning
9206    /// the on-disk WAL.
9207    ///
9208    /// ## Horizon
9209    ///
9210    /// History reaches back only to the last WAL-truncating snapshot, exactly
9211    /// like `node_history` and `open_at`. The returned [`HistoryResult`] carries
9212    /// `total_commits` (= number of WAL frames), which is the exclusive upper
9213    /// bound for valid commit indices.
9214    ///
9215    /// ## Derived edges
9216    ///
9217    /// Rule-derived edges appear via `DerivedEdgeAdded` / `DerivedEdgeRetracted`
9218    /// WAL markers written by `log_then_apply_with` after each rule-firing
9219    /// mutation. The `rule` field of those events carries the rule name.
9220    ///
9221    /// ## DeleteNode
9222    ///
9223    /// When a node is deleted, its manual incident edges are swept inline without
9224    /// individual `DeleteEdge` WAL records. `edge_history` detects `DeleteNode`
9225    /// events for either endpoint and synthesises `Retracted(rule:None)` events
9226    /// for each manual edge that was active at that point. Derived edges active at
9227    /// the time of deletion are handled by the `DerivedEdgeRetracted` marker that
9228    /// the engine appends immediately after the `DeleteNode` record; those events
9229    /// carry correct rule attribution and are emitted by the marker arm, not the
9230    /// synthetic sweep.
9231    ///
9232    /// ## Masks
9233    ///
9234    /// Like `node_history`, this method has no mask parameter and returns WAL
9235    /// history regardless of any role mask. For masked history semantics, apply
9236    /// the mask at the caller level.
9237    pub fn edge_history(
9238        &self,
9239        a: &str,
9240        b: &str,
9241    ) -> Result<crate::history::HistoryResult<crate::history::EdgeHistoryEvent>> {
9242        use crate::history::{EdgeEvent, EdgeHistoryEvent, HistoryResult};
9243        use core_storage::wal::WalRecord;
9244
9245        let (frames, _) = self.all_frames()?;
9246        let total_commits = self.wal_horizon_floor + frames.len() as u64;
9247
9248        // Resolve all historical names for a and b (handles RenameNode in the WAL).
9249        // Intervals are commit-bounded so recycled keys don't contaminate histories.
9250        let alias_a = self.build_key_alias_intervals(&frames, a);
9251        let alias_b = self.build_key_alias_intervals(&frames, b);
9252
9253        // Active edges between a and b tracked as (edge_type, src_key, dst_key, is_derived).
9254        // The is_derived flag is used by the DeleteNode sweep: manual edges are
9255        // swept with a synthetic Retracted(rule:None); derived edges are skipped
9256        // because the engine writes a DerivedEdgeRetracted marker immediately after
9257        // the DeleteNode record, which carries the correct rule attribution.
9258        let mut active: Vec<(String, String, String, bool)> = Vec::new();
9259        let mut out: Vec<EdgeHistoryEvent> = Vec::new();
9260
9261        for (local_i, frame) in frames.iter().enumerate() {
9262            let commit = self.wal_horizon_floor + local_i as u64;
9263            let records: &[WalRecord] = match frame {
9264                WalRecord::Batch(inner) => inner.as_slice(),
9265                single => std::slice::from_ref(single),
9266            };
9267
9268            for rec in records {
9269                match rec {
9270                    WalRecord::InsertEdge {
9271                        edge_type,
9272                        src_key,
9273                        dst_key,
9274                    } => {
9275                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
9276                            && Self::aliases_match(&alias_b, dst_key, commit);
9277                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
9278                            && Self::aliases_match(&alias_a, dst_key, commit);
9279                        if is_ab || is_ba {
9280                            active.push((
9281                                edge_type.clone(),
9282                                src_key.clone(),
9283                                dst_key.clone(),
9284                                false,
9285                            ));
9286                            out.push(EdgeHistoryEvent {
9287                                edge_type: edge_type.clone(),
9288                                commit,
9289                                event: EdgeEvent::Added,
9290                                rule: None,
9291                            });
9292                        }
9293                    }
9294                    WalRecord::InsertEdgeId { etype, src, dst } => {
9295                        let etype_str = match self.syms.resolve(*etype) {
9296                            Some(s) => s.to_string(),
9297                            None => continue,
9298                        };
9299                        // Use key_of_historical so tombstoned nodes (deleted
9300                        // later in the WAL) still resolve during the scan.
9301                        let src_key = self.ids.key_of_historical(*src);
9302                        let dst_key = self.ids.key_of_historical(*dst);
9303                        let is_ab = src_key == Some(a) && dst_key == Some(b);
9304                        let is_ba = src_key == Some(b) && dst_key == Some(a);
9305                        if is_ab || is_ba {
9306                            let src_str = src_key.unwrap().to_string();
9307                            let dst_str = dst_key.unwrap().to_string();
9308                            active.push((etype_str.clone(), src_str, dst_str, false));
9309                            out.push(EdgeHistoryEvent {
9310                                edge_type: etype_str,
9311                                commit,
9312                                event: EdgeEvent::Added,
9313                                rule: None,
9314                            });
9315                        }
9316                    }
9317                    WalRecord::DeleteEdge {
9318                        edge_type,
9319                        src_key,
9320                        dst_key,
9321                    } => {
9322                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
9323                            && Self::aliases_match(&alias_b, dst_key, commit);
9324                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
9325                            && Self::aliases_match(&alias_a, dst_key, commit);
9326                        if is_ab || is_ba {
9327                            // Remove the first matching active entry (flag ignored).
9328                            if let Some(pos) = active.iter().position(|(et, s, d, _)| {
9329                                et == edge_type && s == src_key && d == dst_key
9330                            }) {
9331                                active.remove(pos);
9332                            }
9333                            out.push(EdgeHistoryEvent {
9334                                edge_type: edge_type.clone(),
9335                                commit,
9336                                event: EdgeEvent::Retracted,
9337                                rule: None,
9338                            });
9339                        }
9340                    }
9341                    WalRecord::DeleteNode { key: k }
9342                        if Self::aliases_match(&alias_a, k, commit)
9343                            || Self::aliases_match(&alias_b, k, commit) =>
9344                    {
9345                        // Sweep: implicitly retract only MANUAL active edges.
9346                        // Derived active edges are skipped here because the rule
9347                        // engine appends a DerivedEdgeRetracted marker immediately
9348                        // after this DeleteNode record; that marker produces the
9349                        // single correctly-attributed Retracted event.  Derived
9350                        // entries are dropped from `active` (the marker arm's
9351                        // idempotent retain finds nothing to remove).
9352                        for (et, _, _, is_derived) in active.drain(..) {
9353                            if !is_derived {
9354                                out.push(EdgeHistoryEvent {
9355                                    edge_type: et,
9356                                    commit,
9357                                    event: EdgeEvent::Retracted,
9358                                    rule: None,
9359                                });
9360                            }
9361                            // Derived: drop silently; marker carries the Retracted event.
9362                        }
9363                    }
9364                    WalRecord::DerivedEdgeAdded {
9365                        rule,
9366                        edge_type: et,
9367                        src_key,
9368                        dst_key,
9369                    } => {
9370                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
9371                            && Self::aliases_match(&alias_b, dst_key, commit);
9372                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
9373                            && Self::aliases_match(&alias_a, dst_key, commit);
9374                        if is_ab || is_ba {
9375                            active.push((et.clone(), src_key.clone(), dst_key.clone(), true));
9376                            out.push(EdgeHistoryEvent {
9377                                edge_type: et.clone(),
9378                                commit,
9379                                event: EdgeEvent::Added,
9380                                rule: Some(rule.clone()),
9381                            });
9382                        }
9383                    }
9384                    WalRecord::DerivedEdgeRetracted {
9385                        rule,
9386                        edge_type: et,
9387                        src_key,
9388                        dst_key,
9389                    } => {
9390                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
9391                            && Self::aliases_match(&alias_b, dst_key, commit);
9392                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
9393                            && Self::aliases_match(&alias_a, dst_key, commit);
9394                        if is_ab || is_ba {
9395                            // Push unconditionally: a derived edge whose Added marker
9396                            // predates the history horizon has no `active` entry, but
9397                            // the retraction is still a real in-window event.
9398                            // Remove from active idempotently if present.
9399                            active.retain(|(aet, s, d, _)| {
9400                                !(aet == et && s == src_key && d == dst_key)
9401                            });
9402                            out.push(EdgeHistoryEvent {
9403                                edge_type: et.clone(),
9404                                commit,
9405                                event: EdgeEvent::Retracted,
9406                                rule: Some(rule.clone()),
9407                            });
9408                        }
9409                    }
9410                    // All other records (InsertNode, SetProp, CreateRule, etc.)
9411                    // do not affect edges between a and b.
9412                    _ => {}
9413                }
9414            }
9415        }
9416
9417        Ok(HistoryResult {
9418            items: out,
9419            total_commits,
9420        })
9421    }
9422
9423    /// Return `true` iff an edge of `edge_type` existed between `a` and `b`
9424    /// (in either direction) at the WAL commit `at_commit`.
9425    ///
9426    /// ## Horizon
9427    ///
9428    /// Valid commit indices are `0..total_commits` where `total_commits` is the
9429    /// number of WAL frames. An `at_commit >= total_commits` is outside the
9430    /// visible horizon and returns [`GraphError::CommitOutOfRange`].
9431    ///
9432    /// ## Derived edges
9433    ///
9434    /// Rule-derived edges are tracked via `DerivedEdgeAdded` / `DerivedEdgeRetracted`
9435    /// WAL markers appended at firing time (Task 1). `was_linked` reads these markers
9436    /// and therefore includes derived edges in its point-in-time evaluation,
9437    /// matching `edge_history`'s fidelity.
9438    pub fn was_linked(&self, a: &str, b: &str, edge_type: &str, at_commit: u64) -> Result<bool> {
9439        use core_storage::wal::WalRecord;
9440
9441        let (frames, _) = self.all_frames()?;
9442        let total_commits = self.wal_horizon_floor + frames.len() as u64;
9443
9444        // Horizon floor: commits in pruned archives are unreachable.
9445        if at_commit < self.wal_horizon_floor {
9446            return Err(GraphError::CommitOutOfRange {
9447                commit: at_commit,
9448                total: total_commits,
9449            });
9450        }
9451        if at_commit >= total_commits {
9452            return Err(GraphError::CommitOutOfRange {
9453                commit: at_commit,
9454                total: total_commits,
9455            });
9456        }
9457
9458        // Resolve all historical names for a and b (handles RenameNode in the WAL).
9459        // Intervals are commit-bounded so recycled keys don't contaminate point-in-time reads.
9460        let alias_a = self.build_key_alias_intervals(&frames, a);
9461        let alias_b = self.build_key_alias_intervals(&frames, b);
9462
9463        // Local index into surviving frames (0 = first frame of oldest archive).
9464        let local_commit = at_commit - self.wal_horizon_floor;
9465
9466        // Replay local frames 0..=local_commit, tracking active edges.
9467        let mut active: BTreeSet<(String, String, String)> = BTreeSet::new();
9468
9469        for (local_i, frame) in frames.iter().enumerate().take((local_commit + 1) as usize) {
9470            let commit = self.wal_horizon_floor + local_i as u64;
9471            let records: &[WalRecord] = match frame {
9472                WalRecord::Batch(inner) => inner.as_slice(),
9473                single => std::slice::from_ref(single),
9474            };
9475
9476            for rec in records {
9477                match rec {
9478                    WalRecord::InsertEdge {
9479                        edge_type: et,
9480                        src_key,
9481                        dst_key,
9482                    } => {
9483                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
9484                            && Self::aliases_match(&alias_b, dst_key, commit);
9485                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
9486                            && Self::aliases_match(&alias_a, dst_key, commit);
9487                        if is_ab || is_ba {
9488                            active.insert((et.clone(), src_key.clone(), dst_key.clone()));
9489                        }
9490                    }
9491                    WalRecord::InsertEdgeId { etype, src, dst } => {
9492                        let etype_str = match self.syms.resolve(*etype) {
9493                            Some(s) => s.to_string(),
9494                            None => continue,
9495                        };
9496                        // Use key_of_historical so tombstoned nodes resolve.
9497                        let src_key = self.ids.key_of_historical(*src);
9498                        let dst_key = self.ids.key_of_historical(*dst);
9499                        let is_ab = src_key == Some(a) && dst_key == Some(b);
9500                        let is_ba = src_key == Some(b) && dst_key == Some(a);
9501                        if is_ab || is_ba {
9502                            active.insert((
9503                                etype_str,
9504                                src_key.unwrap().to_string(),
9505                                dst_key.unwrap().to_string(),
9506                            ));
9507                        }
9508                    }
9509                    WalRecord::DeleteEdge {
9510                        edge_type: et,
9511                        src_key,
9512                        dst_key,
9513                    } => {
9514                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
9515                            && Self::aliases_match(&alias_b, dst_key, commit);
9516                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
9517                            && Self::aliases_match(&alias_a, dst_key, commit);
9518                        if is_ab || is_ba {
9519                            active.remove(&(et.clone(), src_key.clone(), dst_key.clone()));
9520                        }
9521                    }
9522                    WalRecord::DeleteNode { key: k }
9523                        if Self::aliases_match(&alias_a, k, commit)
9524                            || Self::aliases_match(&alias_b, k, commit) =>
9525                    {
9526                        // All edges touching the deleted node are gone.
9527                        active.retain(|(_, s, d)| s != k && d != k);
9528                    }
9529                    WalRecord::DerivedEdgeAdded {
9530                        edge_type: et,
9531                        src_key,
9532                        dst_key,
9533                        ..
9534                    } => {
9535                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
9536                            && Self::aliases_match(&alias_b, dst_key, commit);
9537                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
9538                            && Self::aliases_match(&alias_a, dst_key, commit);
9539                        if is_ab || is_ba {
9540                            active.insert((et.clone(), src_key.clone(), dst_key.clone()));
9541                        }
9542                    }
9543                    WalRecord::DerivedEdgeRetracted {
9544                        edge_type: et,
9545                        src_key,
9546                        dst_key,
9547                        ..
9548                    } => {
9549                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
9550                            && Self::aliases_match(&alias_b, dst_key, commit);
9551                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
9552                            && Self::aliases_match(&alias_a, dst_key, commit);
9553                        if is_ab || is_ba {
9554                            active.remove(&(et.clone(), src_key.clone(), dst_key.clone()));
9555                        }
9556                    }
9557                    _ => {}
9558                }
9559            }
9560        }
9561
9562        Ok(active.iter().any(|(et, _, _)| et == edge_type))
9563    }
9564
9565    /// Every edge incident to `key` — either endpoint — that existed at WAL
9566    /// commit `commit`, from ONE scan of the WAL.
9567    ///
9568    /// This is the bulk form of [`was_linked`](GraphDb::was_linked): answering
9569    /// "what did K's relationships look like at commit C" with one call instead
9570    /// of one [`edge_history`](GraphDb::edge_history) per candidate partner.
9571    /// The two agree edge for edge.
9572    ///
9573    /// Results are sorted by `(edge_type, src_key, dst_key)`.
9574    ///
9575    /// ## Horizon
9576    ///
9577    /// Valid commit indices are `wal_horizon_floor()..wal_total_commits()`;
9578    /// anything outside is [`GraphError::CommitOutOfRange`], exactly like
9579    /// `was_linked`. An unknown key is not an error — it simply had no edges.
9580    ///
9581    /// ## Derived edges
9582    ///
9583    /// `DerivedEdgeAdded` / `DerivedEdgeRetracted` markers carry rule
9584    /// attribution, so a rule-owned edge comes back with `derived: true` and
9585    /// `rule: Some(name)`.
9586    ///
9587    /// ## Renames
9588    ///
9589    /// `key` is matched through the same commit-bounded alias intervals
9590    /// `edge_history` uses, so querying a node's *current* key surfaces edges
9591    /// written under an earlier name. Endpoint keys in the result are reported
9592    /// under the name the node carries today, so they can be fed straight back
9593    /// into `node_info`, `explain` or another `edges_at`.
9594    ///
9595    /// ## Masks
9596    ///
9597    /// Like `edge_history` and `node_history`, this reads the WAL regardless of
9598    /// any role mask. Apply masking at the caller level.
9599    pub fn edges_at(&self, key: &str, commit: u64) -> Result<Vec<EdgeAt>> {
9600        use core_storage::wal::WalRecord;
9601
9602        let (frames, _) = self.all_frames()?;
9603        let total_commits = self.wal_horizon_floor + frames.len() as u64;
9604
9605        // Horizon floor: commits in pruned archives are unreachable.
9606        if commit < self.wal_horizon_floor || commit >= total_commits {
9607            return Err(GraphError::CommitOutOfRange {
9608                commit,
9609                total: total_commits,
9610            });
9611        }
9612
9613        // Commit-bounded historical names of `key` (handles RenameNode).
9614        let alias = self.build_key_alias_intervals(&frames, key);
9615
9616        // Forward rename chain, for reporting endpoints under their current
9617        // names: old key → [(commit, new key)] in ascending commit order.
9618        // Built over the whole WAL, not just the prefix up to `commit`, because
9619        // a rename after `commit` still changes what the node is called today.
9620        let mut renames: HashMap<String, Vec<(u64, String)>> = HashMap::new();
9621        for (local_i, frame) in frames.iter().enumerate() {
9622            let c = self.wal_horizon_floor + local_i as u64;
9623            let records: &[WalRecord] = match frame {
9624                WalRecord::Batch(inner) => inner.as_slice(),
9625                single => std::slice::from_ref(single),
9626            };
9627            for rec in records {
9628                if let WalRecord::RenameNode { old_key, new_key } = rec {
9629                    renames
9630                        .entry(old_key.clone())
9631                        .or_default()
9632                        .push((c, new_key.clone()));
9633                }
9634            }
9635        }
9636
9637        // The name a node written as `k` at commit `from` carries today.
9638        // Follows the first rename at or after `from`, then keeps going. The
9639        // iteration cap bounds a rename cycle inside a single batch.
9640        let canon = |k: &str, from: u64| -> String {
9641            if renames.is_empty() {
9642                return k.to_string();
9643            }
9644            let mut cur = k.to_string();
9645            let mut at = from;
9646            for _ in 0..64 {
9647                match renames
9648                    .get(&cur)
9649                    .and_then(|v| v.iter().find(|(c, _)| *c >= at))
9650                {
9651                    Some((c, new)) => {
9652                        at = *c;
9653                        cur = new.clone();
9654                    }
9655                    None => break,
9656                }
9657            }
9658            cur
9659        };
9660
9661        let local_commit = commit - self.wal_horizon_floor;
9662        // (edge_type, src_key, dst_key) → (derived, rule)
9663        let mut active: BTreeMap<(String, String, String), (bool, Option<String>)> =
9664            BTreeMap::new();
9665
9666        for (local_i, frame) in frames.iter().enumerate().take((local_commit + 1) as usize) {
9667            let c = self.wal_horizon_floor + local_i as u64;
9668            let records: &[WalRecord] = match frame {
9669                WalRecord::Batch(inner) => inner.as_slice(),
9670                single => std::slice::from_ref(single),
9671            };
9672
9673            for rec in records {
9674                match rec {
9675                    WalRecord::InsertEdge {
9676                        edge_type,
9677                        src_key,
9678                        dst_key,
9679                    } => {
9680                        if Self::aliases_match(&alias, src_key, c)
9681                            || Self::aliases_match(&alias, dst_key, c)
9682                        {
9683                            active.insert(
9684                                (edge_type.clone(), canon(src_key, c), canon(dst_key, c)),
9685                                (false, None),
9686                            );
9687                        }
9688                    }
9689                    WalRecord::InsertEdgeId { etype, src, dst } => {
9690                        let Some(etype_str) = self.syms.resolve(*etype) else {
9691                            continue;
9692                        };
9693                        // `key_of_historical` resolves tombstoned ids too, and
9694                        // already returns the node's current key — no rename
9695                        // canonicalisation needed on this arm.
9696                        let (Some(src_key), Some(dst_key)) = (
9697                            self.ids.key_of_historical(*src),
9698                            self.ids.key_of_historical(*dst),
9699                        ) else {
9700                            continue;
9701                        };
9702                        if src_key == key || dst_key == key {
9703                            active.insert(
9704                                (
9705                                    etype_str.to_string(),
9706                                    src_key.to_string(),
9707                                    dst_key.to_string(),
9708                                ),
9709                                (false, None),
9710                            );
9711                        }
9712                    }
9713                    WalRecord::DeleteEdge {
9714                        edge_type,
9715                        src_key,
9716                        dst_key,
9717                    } => {
9718                        if Self::aliases_match(&alias, src_key, c)
9719                            || Self::aliases_match(&alias, dst_key, c)
9720                        {
9721                            active.remove(&(
9722                                edge_type.clone(),
9723                                canon(src_key, c),
9724                                canon(dst_key, c),
9725                            ));
9726                        }
9727                    }
9728                    WalRecord::DeleteNode { key: k } => {
9729                        if active.is_empty() {
9730                            continue;
9731                        }
9732                        if Self::aliases_match(&alias, k, c) {
9733                            // Our node is gone; every incident edge goes with it.
9734                            active.clear();
9735                        } else {
9736                            // A partner is gone; its edges to us go with it.
9737                            let ck = canon(k, c);
9738                            active.retain(|(_, s, d), _| *s != ck && *d != ck);
9739                        }
9740                    }
9741                    WalRecord::DerivedEdgeAdded {
9742                        rule,
9743                        edge_type,
9744                        src_key,
9745                        dst_key,
9746                    } => {
9747                        if Self::aliases_match(&alias, src_key, c)
9748                            || Self::aliases_match(&alias, dst_key, c)
9749                        {
9750                            active.insert(
9751                                (edge_type.clone(), canon(src_key, c), canon(dst_key, c)),
9752                                (true, Some(rule.clone())),
9753                            );
9754                        }
9755                    }
9756                    WalRecord::DerivedEdgeRetracted {
9757                        edge_type,
9758                        src_key,
9759                        dst_key,
9760                        ..
9761                    } => {
9762                        if Self::aliases_match(&alias, src_key, c)
9763                            || Self::aliases_match(&alias, dst_key, c)
9764                        {
9765                            active.remove(&(
9766                                edge_type.clone(),
9767                                canon(src_key, c),
9768                                canon(dst_key, c),
9769                            ));
9770                        }
9771                    }
9772                    // InsertNode, SetProp, CreateRule, … do not move edges.
9773                    _ => {}
9774                }
9775            }
9776        }
9777
9778        // BTreeMap iteration is already (edge_type, src, dst) order.
9779        Ok(active
9780            .into_iter()
9781            .map(|((edge_type, src_key, dst_key), (derived, rule))| EdgeAt {
9782                edge_type,
9783                src_key,
9784                dst_key,
9785                derived,
9786                rule,
9787            })
9788            .collect())
9789    }
9790
9791    /// The derived edges that would be retracted and derived if `key.field`
9792    /// were set to `value` — computed WITHOUT writing anything.
9793    ///
9794    /// Nothing is committed and nothing on `self` is mutated: the rule engine's
9795    /// provenance, its candidate indexes, the topology and the property columns
9796    /// are all cloned first, the change is applied to the clone, and the real
9797    /// per-node re-derivation (`RuleEngine::on_node_changed` — the same call
9798    /// `set_prop` makes during apply) runs against it. The derived-edge deltas
9799    /// it emits are the answer, so rule semantics — predicates, top-k,
9800    /// via-hops, chaining, weights — are the engine's, not a re-implementation.
9801    ///
9802    /// Works on a read-only handle.
9803    ///
9804    /// Returns `Err(KeyNotFound)` for an unknown or tombstoned key and
9805    /// `Err(ViewPropReadOnly)` for a field a view owns — matching
9806    /// [`set_prop`](GraphDb::set_prop)'s validation. A change with no effect
9807    /// (the node already holds `value`, or no rule watches `field`) returns
9808    /// empty lists.
9809    ///
9810    /// ## Cost
9811    ///
9812    /// One clone of the property columns, the topology overlay, the symbol
9813    /// interner, the edge properties and the provenance map, plus one candidate
9814    /// re-index (O(nodes × rules)). That is much cheaper than copying the store
9815    /// directory, but it is not free — this is an interactive "what if", not a
9816    /// hot path.
9817    pub fn what_if_set_prop(&self, key: &str, field: &str, value: Value) -> Result<WhatIf> {
9818        // The engine's provenance, HNSW and IVF state live in the mmap'd base
9819        // until something asks for them. On a store opened cold from a snapshot
9820        // this is the first ask, and without it the clone below starts from an
9821        // empty provenance map: nothing to retract, so `lost` comes back empty.
9822        self.ensure_v8_base_sections_loaded();
9823
9824        let empty = WhatIf {
9825            lost: Vec::new(),
9826            gained: Vec::new(),
9827        };
9828
9829        if let Some(view_name) = self.view_store.view_for_prop(field) {
9830            return Err(GraphError::ViewPropReadOnly {
9831                view_name: view_name.to_string(),
9832            });
9833        }
9834        MutPreview::new(self).check_live_key(key)?;
9835        let id = self
9836            .ids
9837            .get(key)
9838            .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
9839
9840        let rules: Vec<RuleDef> = self.engine.rules().cloned().collect();
9841        if rules.is_empty() {
9842            return Ok(empty);
9843        }
9844
9845        // No rule watches this field → no derivation can change.
9846        if !rules.iter().any(|r| r.watched_fields().contains(field)) {
9847            return Ok(empty);
9848        }
9849
9850        let old_value = build_props_view(&self.props, &self.base)
9851            .get(id, field)
9852            .map(|vr| vr.into_value());
9853        if old_value.as_ref() == Some(&value) {
9854            return Ok(empty);
9855        }
9856
9857        // --- Clone every piece of state the re-derivation writes to. ---
9858        let mut props = self.props.clone();
9859        let mut topo = self.topo.clone();
9860        let mut syms = self.syms.clone();
9861        let mut edge_props = self.edge_props.clone();
9862
9863        let mut tripped: BTreeMap<String, bool> = BTreeMap::new();
9864        let mut fires: BTreeMap<String, u64> = BTreeMap::new();
9865        for r in &rules {
9866            tripped.insert(r.name.clone(), self.engine.is_tripped(&r.name));
9867            fires.insert(r.name.clone(), self.engine.fire_count(&r.name));
9868        }
9869        // `provenance()` decodes retained snapshot bytes on first use; the
9870        // engine clone needs the real map, not an empty one.
9871        let provenance = self.engine.provenance().clone();
9872        let mut engine = core_rules::RuleEngine::from_persist(rules, provenance, tripped, fires);
9873
9874        // Build the candidate indexes from the state BEFORE the change, exactly
9875        // as apply() sees them: `on_node_changed` withdraws the node under its
9876        // old value and refiles it under the new one, so the index must not
9877        // already reflect the change.
9878        engine.reindex_all_load_ivf(
9879            &self.ids,
9880            &syms,
9881            &self.labels,
9882            build_props_view(&self.props, &self.base),
9883            self.engine.export_ivf_state(),
9884        );
9885        engine.load_hnsw_state(self.engine.export_hnsw_state_passthrough());
9886        engine.set_emit_deltas(true);
9887
9888        // --- Apply the hypothetical change and re-derive. ---
9889        props.set(id, field, value);
9890        {
9891            let mut gm = make_graph_mut(
9892                &self.ids,
9893                &mut syms,
9894                &self.labels,
9895                build_props_view(&props, &self.base),
9896                &mut topo,
9897                &self.base,
9898                &mut edge_props,
9899            );
9900            engine.on_node_changed(id, Some((field, old_value)), &mut gm);
9901        }
9902
9903        let mut lost: BTreeSet<EdgeAt> = BTreeSet::new();
9904        let mut gained: BTreeSet<EdgeAt> = BTreeSet::new();
9905        for d in engine.drain_deltas() {
9906            let edge = EdgeAt {
9907                edge_type: d.edge_type,
9908                src_key: d.src_key,
9909                dst_key: d.dst_key,
9910                derived: true,
9911                rule: Some(d.rule),
9912            };
9913            if d.fired {
9914                gained.insert(edge);
9915            } else {
9916                lost.insert(edge);
9917            }
9918        }
9919        // An edge retracted and re-derived within the same re-derivation (top-k
9920        // churn) is not a change the caller would see.
9921        let churn: Vec<EdgeAt> = lost.intersection(&gained).cloned().collect();
9922        for e in churn {
9923            lost.remove(&e);
9924            gained.remove(&e);
9925        }
9926
9927        Ok(WhatIf {
9928            lost: lost.into_iter().collect(),
9929            gained: gained.into_iter().collect(),
9930        })
9931    }
9932
9933    pub fn edge_count(&self) -> u64 {
9934        self.topo_view().edge_count()
9935    }
9936
9937    /// Live/tombstone/edge counts plus per-rule provenance size, trip latch,
9938    /// and fire counter (includes rebuild evaluations). Rules are sorted by name.
9939    pub fn stats(&self) -> Stats {
9940        self.ensure_v8_base_sections_loaded();
9941        let rules: Vec<RuleStats> = self
9942            .engine
9943            .rules()
9944            .map(|r| RuleStats {
9945                name: r.name.clone(),
9946                edges: self
9947                    .engine
9948                    .provenance()
9949                    .get(&r.name)
9950                    .map(|s| s.len() as u64)
9951                    .unwrap_or(0),
9952                tripped: self.engine.is_tripped(&r.name),
9953                fires: self.engine.fire_count(&r.name),
9954                approximate: r.approximate,
9955            })
9956            .collect();
9957        Stats {
9958            nodes_live: self.ids.live_len(),
9959            nodes_tombstoned: self.ids.len() - self.ids.live_len(),
9960            edges: self.topo_view().edge_count(),
9961            rules,
9962            chain_truncations: self.engine.chain_truncations(),
9963        }
9964    }
9965
9966    /// On-disk size of the WAL file in bytes.
9967    ///
9968    /// Reads file metadata without loading WAL contents.  Returns `Err` for
9969    /// in-memory (`SimFs`) databases where no WAL file exists on disk.
9970    pub fn wal_size_bytes(&self) -> std::io::Result<u64> {
9971        let path = self.fs.wal_path().ok_or_else(|| {
9972            std::io::Error::new(
9973                std::io::ErrorKind::Unsupported,
9974                "wal_path not available for this Fs implementation",
9975            )
9976        })?;
9977        Ok(std::fs::metadata(path)?.len())
9978    }
9979
9980    /// Set the slow-query threshold.  Queries whose execution time equals or
9981    /// exceeds `ms` milliseconds are logged.  Pass `0` to disable.
9982    ///
9983    /// Use this setter in tests — the environment variable
9984    /// `MUSHROOMDB_SLOW_QUERY_MS` is process-global and races parallel test
9985    /// threads.
9986    pub fn set_slow_query_threshold_ms(&mut self, ms: u64) {
9987        self.slow_query_threshold_ms = ms;
9988    }
9989
9990    /// Snapshot of the slow-query ring buffer and lifetime counter.
9991    pub fn slow_query_snapshot(&self) -> SlowQuerySnapshot {
9992        let log = self.slow_queries.lock().unwrap_or_else(|e| e.into_inner());
9993        SlowQuerySnapshot {
9994            threshold_ms: self.slow_query_threshold_ms,
9995            count: log.total,
9996            last: log.entries.iter().cloned().collect(),
9997        }
9998    }
9999
10000    /// Instant the database was opened.  Used by consumers (e.g. `/metrics`)
10001    /// to compute uptime.
10002    pub fn started_at(&self) -> std::time::Instant {
10003        self.started_at
10004    }
10005
10006    /// On-disk snapshot format version this binary writes and reads.
10007    pub fn format_version() -> u16 {
10008        core_storage::snapshot::VERSION
10009    }
10010
10011    /// Test-support: total bytes appended (SimFs only usage).
10012    pub fn fs_total_appended(&self) -> usize
10013    where
10014        F: FsIntrospect,
10015    {
10016        self.fs.total_appended()
10017    }
10018
10019    /// Test-support: successful `Fs::sync` calls (SimFs / counting fs).
10020    pub fn fs_sync_count(&self) -> usize
10021    where
10022        F: FsIntrospect,
10023    {
10024        self.fs.sync_count()
10025    }
10026
10027    /// Consume the db, returning its fs (for crash simulation).
10028    pub fn into_fs(self) -> F {
10029        self.fs
10030    }
10031
10032    pub fn snapshot(&mut self) -> Result<()> {
10033        self.snapshot_with(SnapshotOptions::default())
10034    }
10035
10036    /// Snapshot with explicit options.
10037    ///
10038    /// # `keep_wal`
10039    ///
10040    /// When `keep_wal` is `false` (the default, same as [`snapshot`]):
10041    ///   - The WAL is replaced with a minimal baseline containing one
10042    ///     `EnableFulltext` record per active declaration.  All pre-snapshot
10043    ///     history is discarded; `open_at` can only reach post-snapshot commits.
10044    ///
10045    /// When `keep_wal` is `true`:
10046    ///   - The WAL is left intact.  All pre-snapshot commits remain reachable
10047    ///     via `open_at`.  The existing WAL already contains the original
10048    ///     `EnableFulltext` records, so no baseline re-write is needed; the
10049    ///     recovery guards in `apply()` silently skip any duplicate records on
10050    ///     replay.
10051    ///   - Crash window: a crash after the snapshot write but before the next
10052    ///     WAL write leaves the full pre-snapshot WAL intact.  On reopen the
10053    ///     snapshot is loaded and the WAL replayed idempotently over it — safe
10054    ///     because every `apply()` arm is idempotent when replayed over an
10055    ///     already-current snapshot.
10056    pub fn snapshot_with(&mut self, opts: SnapshotOptions) -> Result<()> {
10057        if self.read_only {
10058            return Err(GraphError::ReadOnly);
10059        }
10060        // A snapshot rewrites `wal.bin` through a tmp+rename, so a peer that is
10061        // appending ends up holding a descriptor on an unlinked inode and loses
10062        // commits it believes durable. Snapshotting therefore requires the
10063        // cross-process write lock, exactly as appending does. Unlike the WAL
10064        // append path this does not go through `log_then_apply_with`, so both
10065        // guards are repeated here.
10066        if self.degraded {
10067            return Err(GraphError::Io(std::io::Error::other(
10068                "database degraded after group-commit fsync failure; reopen required",
10069            )));
10070        }
10071        if self.lock_denied {
10072            return Err(GraphError::Busy { holder: None });
10073        }
10074        // Capture whether snapshot.bin already existed BEFORE this snapshot write.
10075        // Used by the archive path's conservative genesis-chain check: if a prior
10076        // snapshot exists but wal.truncated does not, we cannot distinguish a
10077        // legacy store (may have been truncated in an older code version) from a
10078        // new store that only used keep_wal=true.  Conservative: refuse genesis in
10079        // both cases.  Must be sampled here, before the snapshot write below.
10080        let had_prior_snapshot = self.fs.snapshot_path().map(|p| p.exists()).unwrap_or(false);
10081        self.ensure_v8_base_sections_loaded();
10082        // Ensure provenance is decoded before to_persist() clones it.
10083        self.engine.ensure_provenance_loaded_mut();
10084        let (rule_defs_typed, provenance, rule_tripped, rule_fires) = self.engine.to_persist();
10085        let rule_defs = rule_defs_typed
10086            .iter()
10087            .map(|r| bincode::serialize(r).expect("RuleDef serialize cannot fail"))
10088            .collect();
10089        // Collect HNSW state and IVF state.  When indexes are not yet
10090        // populated (clean open, no mutation since open), pass the retained
10091        // raw bytes through directly so that migrate/snapshot does not
10092        // silently discard fitted approximate-rule indexes.
10093        let hnsw_state = self.engine.export_hnsw_state_passthrough();
10094        let ivf_bytes = if !self.engine.indexes_populated() {
10095            // Pass retained IVF bytes through unchanged (no re-encode).
10096            self.engine.retained_ivf_bytes_clone().unwrap_or_default()
10097        } else {
10098            // Indexes live: encode from current state.
10099            let raw_ivf = self.engine.export_ivf_state();
10100            let ivf_state_map: BTreeMap<String, core_storage::snapshot::PerRuleIvfState> = raw_ivf
10101                .into_iter()
10102                .map(|(name, ((sc, sa, sd), (dc, da, dd)))| {
10103                    (
10104                        name,
10105                        core_storage::snapshot::PerRuleIvfState {
10106                            src: core_storage::snapshot::SideIvfState {
10107                                centroids: sc,
10108                                clusters: sa,
10109                                drift: sd,
10110                            },
10111                            dst: core_storage::snapshot::SideIvfState {
10112                                centroids: dc,
10113                                clusters: da,
10114                                drift: dd,
10115                            },
10116                        },
10117                    )
10118                })
10119                .collect();
10120            if ivf_state_map.is_empty() {
10121                Vec::new()
10122            } else {
10123                bincode::serialize(&ivf_state_map).expect("IVF state serialize cannot fail")
10124            }
10125        };
10126        let view_defs: Vec<Vec<u8>> = self
10127            .view_store
10128            .views()
10129            .map(|v| bincode::serialize(v).expect("ViewDef serialize cannot fail"))
10130            .collect();
10131        if self.base.is_some() {
10132            // V8 merge-snapshot path: encode base+overlay into a new V8 snapshot,
10133            // write it atomically, remap it as the new base, then clear the overlay.
10134            let meta = V8Meta {
10135                labels: self.labels.clone(),
10136                edge_props: self.edge_props.clone(),
10137                rule_defs,
10138                provenance,
10139                rule_tripped,
10140                rule_fires,
10141                ivf_bytes,
10142                view_defs,
10143                wal_truncated: !opts.keep_wal,
10144                hnsw: hnsw_state,
10145                last_change: self.last_change.clone(),
10146            };
10147            let mut buf: Vec<u8> = Vec::new();
10148            {
10149                // Clone the Arc so the old base stays alive while we encode.
10150                // The borrow of archived_csr (into old_base's mmap) is released
10151                // at the end of this block, before we replace self.base.
10152                let old_base = self.base.clone().expect("is_some checked above");
10153                let archived_csr = old_base.topology().map_err(|e| GraphError::Corrupt {
10154                    detail: format!("v8 snapshot: topology section: {e:?}"),
10155                })?;
10156                let archived_cols = old_base.columns().map_err(|e| GraphError::Corrupt {
10157                    detail: format!("v8 snapshot: columns section: {e:?}"),
10158                })?;
10159                let archived_edge_props =
10160                    old_base
10161                        .edge_props_section()
10162                        .map_err(|e| GraphError::Corrupt {
10163                            detail: format!("v8 snapshot: edge_props section: {e:?}"),
10164                        })?;
10165                let edge_props_raw =
10166                    old_base
10167                        .edge_props_raw_bytes()
10168                        .map_err(|e| GraphError::Corrupt {
10169                            detail: format!("v8 snapshot: edge_props raw bytes: {e:?}"),
10170                        })?;
10171                let prov_raw =
10172                    old_base
10173                        .provenance_raw_bytes()
10174                        .map_err(|e| GraphError::Corrupt {
10175                            detail: format!("v8 snapshot: provenance raw bytes: {e:?}"),
10176                        })?;
10177                encode_v8(
10178                    Some(archived_csr),
10179                    Some(archived_cols),
10180                    Some((archived_edge_props, edge_props_raw)),
10181                    Some(prov_raw),
10182                    &self.topo,
10183                    &self.props,
10184                    &self.ids,
10185                    &self.syms,
10186                    &meta,
10187                    &mut buf,
10188                )?;
10189            }
10190            self.fs.write_atomic(FileId::Snapshot, &buf)?;
10191            // Remap the freshly-written snapshot as the new base.
10192            // C2: use file mmap on RealFs; fall back to from_bytes on SimFs.
10193            let new_base = if let Some(snap_path) = self.fs.snapshot_path() {
10194                core_storage::v8::MappedBase::map(&snap_path)
10195            } else {
10196                core_storage::v8::MappedBase::from_bytes(buf)
10197            }
10198            .map_err(|e| GraphError::Corrupt {
10199                detail: format!("v8 snapshot: remap new base: {e:?}"),
10200            })?;
10201            self.base = Some(Arc::new(new_base));
10202            // Clear the overlay and prop tombstones — all data is now in the new base.
10203            self.topo = Topology::new();
10204            self.props = core_storage::columns::ColumnStore::new();
10205        } else {
10206            // Legacy path (V5–V7 stores without a V8 base).
10207            //
10208            // Memory-diet path: build V8Meta directly from &self — no SnapshotState
10209            // clone and no encode_v8_from_state intermediate clones.  The big
10210            // structures (self.topo, self.props) are borrowed, not cloned.
10211            // self.edge_props is moved (not cloned) because we immediately clear it
10212            // when we remap the new V8 snapshot as self.base (see below).
10213            //
10214            // Eliminates from peak RSS vs. the old SnapshotState path:
10215            //   • self.topo.clone()      (~topology HashMap footprint)
10216            //   • self.props.clone()     (~column-store footprint)
10217            //   • encode_v8_from_state V8Meta secondary clones (labels, edge_props, …)
10218            let meta = V8Meta {
10219                labels: self.labels.clone(),
10220                wal_truncated: !opts.keep_wal,
10221                // Move edge_props out so the large overlay is freed when meta
10222                // drops at end of this block (self.edge_props is now empty; reads
10223                // after base assignment go through the mmap'd base section).
10224                edge_props: std::mem::take(&mut self.edge_props),
10225                rule_defs,
10226                provenance,
10227                rule_tripped,
10228                rule_fires,
10229                ivf_bytes,
10230                view_defs,
10231                hnsw: hnsw_state,
10232                last_change: self.last_change.clone(),
10233            };
10234            let mut buf = Vec::new();
10235            encode_v8(
10236                None,
10237                None,
10238                None,
10239                None,
10240                &self.topo,
10241                &self.props,
10242                &self.ids,
10243                &self.syms,
10244                &meta,
10245                &mut buf,
10246            )?;
10247            // meta (and the moved edge_props inside it) is no longer needed;
10248            // drop it before the write to keep the peak window narrow.
10249            drop(meta);
10250            self.fs.write_atomic(FileId::Snapshot, &buf)?;
10251            // Remap the freshly-written V8 snapshot as self.base.
10252            // On RealFs: drop the encode buffer before mmap to recover ~1.9 GiB.
10253            // On SimFs (tests): pass buf to from_bytes.
10254            let new_base = if let Some(snap_path) = self.fs.snapshot_path() {
10255                drop(buf);
10256                core_storage::v8::MappedBase::map(&snap_path)
10257            } else {
10258                core_storage::v8::MappedBase::from_bytes(buf)
10259            }
10260            .map_err(|e| GraphError::Corrupt {
10261                detail: format!("v8 snapshot: remap new base (legacy path): {e:?}"),
10262            })?;
10263            self.base = Some(Arc::new(new_base));
10264            // Free the large heap-allocated decoded state — all data is now in the
10265            // mmap'd base.  Mirrors the V8 merge-snapshot path (see above).
10266            // self.edge_props was already moved into meta and is effectively empty.
10267            self.topo = Topology::new();
10268            self.props = core_storage::columns::ColumnStore::new();
10269        }
10270
10271        if opts.archive_wal {
10272            // History-preserving snapshot (Task 4):
10273            //   1. Snapshot already written above (write_atomic → fsynced).
10274            //   2. Rename WAL → wal.<commit_seq>.archive  (atomic, same fs).
10275            //      Crash window B: crash here leaves archive present, WAL
10276            //      absent.  Reopen: snapshot loaded (full state), no WAL
10277            //      replay.  Archive is NOT replayed into live state — it is
10278            //      pre-snapshot by construction.  Safe.
10279            //   3. Optionally write genesis marker (first archive only, no
10280            //      prior WAL truncation).
10281            //   4. Prune old archives (retention), update horizon floor.
10282            //      Pruning invalidates the genesis chain; delete marker.
10283            //   5. Write new minimal baseline WAL (write_atomic).
10284            //      Crash window C: crash here leaves new archive plus no live
10285            //      WAL.  Same as window B — handled above.
10286            //
10287            // Sample existing archives BEFORE the rename so we can detect
10288            // whether this is the first archive.
10289            let existing_archives = self.fs.list_archives()?;
10290            let is_first_archive = existing_archives.is_empty();
10291
10292            // Compute a globally-monotonic archive name: the name equals the
10293            // cumulative end-frame index of the archive in global commit space.
10294            //
10295            // Using `commit_seq` directly is UNSOUND across sessions: on reopen
10296            // commit_seq is seeded from max(last_change), which underestimates
10297            // the WAL depth when trailing commits (e.g. insert_edge) do not
10298            // update last_change.  A session-2 archive could then receive a name
10299            // ≤ the session-1 archive, causing incorrect sort order or collision.
10300            //
10301            // Instead: read and decode the live WAL here (before the rename) to
10302            // get its exact frame count, then add it to the last known global
10303            // end-frame index (the name of the most recent existing archive, or
10304            // wal_horizon_floor if no archives exist).  This is O(WAL size) but
10305            // snapshot is already serialising the full graph state, so the cost
10306            // is dominated.
10307            let live_wal_bytes_for_name = self.fs.read(FileId::Wal)?;
10308            let (live_frames_for_name, _) = decode_all(&live_wal_bytes_for_name);
10309            let archive_n = existing_archives
10310                .last()
10311                .copied()
10312                .unwrap_or(self.wal_horizon_floor)
10313                + live_frames_for_name.len() as u64;
10314            self.fs.archive_wal(archive_n)?;
10315
10316            // Genesis marker: written once when the first archive is taken
10317            // from a store that has never undergone a WAL-truncating snapshot.
10318            // When present, `open_at` may replay archive-resident commits from
10319            // empty state (the archive chain covers from global index 0).
10320            //
10321            // Two conditions must ALL hold:
10322            //   1. This is the first archive (existing_archives was empty).
10323            //   2. No snapshot.bin existed before this operation (had_prior_snapshot=false).
10324            //      A WAL-truncating snapshot (keep_wal=false) always writes snapshot.bin
10325            //      before truncating the WAL, so if any prior truncating snapshot was taken
10326            //      — even in a previous session — snapshot.bin is present and this condition
10327            //      is false.  This subsumes the cross-session truncation case without
10328            //      requiring a separate wal.truncated sidecar file.
10329            //      For legacy stores (snapshot.bin written by an older code version that
10330            //      may have truncated the WAL), the same conservative refusal applies:
10331            //      we cannot prove the chain is complete, so we refuse genesis (cost =
10332            //      no as-of-through-archives; never silent wrong data).
10333            //      On SimFs (snapshot_path() == None) had_prior_snapshot is always false,
10334            //      so SimFs always passes this check.
10335            if is_first_archive && !had_prior_snapshot {
10336                self.fs.write_genesis_marker()?;
10337                self.archive_genesis_chain = true;
10338            }
10339
10340            // Retention pruning: keep newest `keep` archives; delete oldest.
10341            // Pruning is the ONLY deletion site for archives.
10342            //
10343            // Crash-safety ordering (C1 fix):
10344            //   1. Count frames in surplus archives (reads only — no mutation).
10345            //   2. Advance and PERSIST the horizon floor FIRST via write-then-
10346            //      rename (atomic).  A crash after this point leaves orphaned
10347            //      archives on disk, but the floor is correct.  The opening
10348            //      cleanup sweep (`cleanup_orphaned_archives`) removes them on
10349            //      the next open, so the store is always safe to reopen.
10350            //   3. Delete the genesis marker (floor > 0 already blocks open_at
10351            //      via the conjunctive gate; marker cleanup is belt-and-suspenders).
10352            //   4. Delete surplus archives.  A crash between any two deletes
10353            //      leaves the floor committed and orphaned archives cleaned at
10354            //      next open — never a stale floor with a missing archive prefix.
10355            if let Some(keep) = self.wal_archive_retention {
10356                if keep > 0 {
10357                    let archives = self.fs.list_archives()?;
10358                    // archives is sorted ascending (oldest first)
10359                    if archives.len() as u32 > keep {
10360                        let surplus = archives.len() - keep as usize;
10361                        // Step 1: count pruned frames (reads, no mutation).
10362                        let mut pruned_frames = 0u64;
10363                        for &n in &archives[..surplus] {
10364                            let bytes = self.fs.read_archive(n)?;
10365                            let (frames, _) = decode_all(&bytes);
10366                            pruned_frames += frames.len() as u64;
10367                        }
10368                        // Step 2: advance and persist floor FIRST.
10369                        self.wal_horizon_floor += pruned_frames;
10370                        self.fs.write_horizon_floor(self.wal_horizon_floor)?;
10371                        // Step 3: delete genesis marker (floor > 0 already
10372                        // blocks open_at; this is belt-and-suspenders cleanup).
10373                        if pruned_frames > 0 && self.archive_genesis_chain {
10374                            self.fs.delete_genesis_marker()?;
10375                            self.archive_genesis_chain = false;
10376                        }
10377                        // Step 4: delete surplus archives.  Crash here →
10378                        // orphaned archives; cleaned at next open.
10379                        for &n in &archives[..surplus] {
10380                            self.fs.delete_archive(n)?;
10381                        }
10382                    }
10383                }
10384            }
10385
10386            // Write new minimal baseline WAL (mirrors the keep_wal=false path).
10387            let mut baseline_wal: Vec<u8> = Vec::new();
10388            for (label, field) in self.fulltext.enabled_pairs() {
10389                let rec = WalRecord::EnableFulltext {
10390                    label: label.clone(),
10391                    field: field.clone(),
10392                };
10393                baseline_wal.extend_from_slice(&encode_record(&rec));
10394            }
10395            for (label, field) in self.prop_index.enabled_pairs() {
10396                let rec = WalRecord::EnableIndex {
10397                    label: label.clone(),
10398                    field: field.clone(),
10399                };
10400                baseline_wal.extend_from_slice(&encode_record(&rec));
10401            }
10402            self.fs.write_atomic(FileId::Wal, &baseline_wal)?;
10403        } else if opts.keep_wal {
10404            // keep_wal=true: WAL is left untouched.  The existing WAL already
10405            // contains the EnableFulltext records from the original enable calls;
10406            // replay is idempotent (guards in apply() skip already-live entries).
10407            // No baseline re-write is needed or safe here — the full WAL history
10408            // must remain intact for open_at to reach pre-snapshot commits.
10409        } else {
10410            // keep_wal=false (default): truncate by replacing the WAL with a
10411            // minimal baseline of one EnableFulltext record per active pair.
10412            //
10413            // Crash-ordering: write_atomic is atomic.
10414            //   • Crash before snapshot write  → WAL unchanged.  Safe.
10415            //   • Crash after snapshot write but before this WAL write → full
10416            //     pre-snapshot WAL still present; open_with replays idempotently.
10417            //   • Crash after both writes → normal post-snapshot state.
10418            //
10419            // Genesis chain: a WAL-truncating snapshot breaks the archive chain
10420            // for any archives taken AFTER this point (their WAL slices would
10421            // not start at genesis).  Delete any existing genesis marker so that
10422            // open_at refuses archive-resident commits.  Future sessions are
10423            // covered by had_prior_snapshot: snapshot.bin written here persists
10424            // across sessions and prevents a later archiving session from
10425            // incorrectly claiming a complete genesis chain.
10426            if self.archive_genesis_chain {
10427                self.fs.delete_genesis_marker()?;
10428                self.archive_genesis_chain = false;
10429            }
10430            let mut baseline_wal: Vec<u8> = Vec::new();
10431            for (label, field) in self.fulltext.enabled_pairs() {
10432                let rec = WalRecord::EnableFulltext {
10433                    label: label.clone(),
10434                    field: field.clone(),
10435                };
10436                baseline_wal.extend_from_slice(&encode_record(&rec));
10437            }
10438            for (label, field) in self.prop_index.enabled_pairs() {
10439                let rec = WalRecord::EnableIndex {
10440                    label: label.clone(),
10441                    field: field.clone(),
10442                };
10443                baseline_wal.extend_from_slice(&encode_record(&rec));
10444            }
10445            self.fs.write_atomic(FileId::Wal, &baseline_wal)?;
10446        }
10447        // After snapshot the overlay may have changed (V8 merge path clears
10448        // self.topo and self.props). Refresh the MVCC fold so future readers
10449        // see the post-snapshot state rather than stale overlay data.
10450        self.fold_now();
10451        // We wrote the snapshot and (unless keep_wal) replaced the WAL, so both
10452        // markers this handle uses to detect other processes' work must be
10453        // re-taken from disk. Skipping this would make our own snapshot look
10454        // like a peer's on the next staleness check and force a needless
10455        // reload.
10456        self.wal_consumed = self.fs.wal_len().map_err(GraphError::Io)?;
10457        self.snapshot_ident = self.fs.snapshot_ident().map_err(GraphError::Io)?;
10458        Ok(())
10459    }
10460}
10461
10462/// Queued mutation for a [`BatchBuilder`] or [`GraphDb::commit_group`].
10463///
10464/// The `submit_batch` / `commit_group` APIs accept `Vec<BatchOp>` so that
10465/// callers can build a set of mutations without holding `&mut GraphDb` and
10466/// hand them off to the group-committing writer for durable, batched I/O.
10467pub enum BatchOp {
10468    InsertNode {
10469        label: String,
10470        key: String,
10471        props: Vec<(String, Value)>,
10472    },
10473    InsertEdge {
10474        edge_type: String,
10475        src_key: String,
10476        dst_key: String,
10477    },
10478    SetProp {
10479        key: String,
10480        field: String,
10481        value: Value,
10482    },
10483    RemoveProp {
10484        key: String,
10485        field: String,
10486    },
10487    DeleteEdge {
10488        edge_type: String,
10489        src_key: String,
10490        dst_key: String,
10491    },
10492    DeleteNode {
10493        key: String,
10494    },
10495    CreateRule(RuleDef),
10496    DeleteRule {
10497        name: String,
10498    },
10499    /// Rename a node's key. Validated: old must exist, new must not.
10500    RenameNode {
10501        old_key: String,
10502        new_key: String,
10503    },
10504    /// Insert an edge, auto-creating any missing endpoint as a plain node with
10505    /// `placeholder_label` and no props. Rules fire and last-change is updated
10506    /// for each created endpoint (normal InsertNode semantics in the batch frame).
10507    InsertEdgeUpsert {
10508        edge_type: String,
10509        src_key: String,
10510        dst_key: String,
10511        placeholder_label: String,
10512    },
10513}
10514
10515/// Three-way node visibility status used by `check_single_op_authz`.
10516enum NodeAuthzStatus {
10517    /// Node exists in the store and is in the role's read mask.
10518    Visible(String), // carries the node's label
10519    /// Node exists in the store but is NOT in the role's read mask.
10520    Hidden,
10521    /// Node does not exist in the store.
10522    Absent,
10523}
10524
10525/// Overlay of ops already accepted earlier in the same batch. Never written
10526/// back to the database — validation only.
10527#[derive(Default)]
10528struct Overlay {
10529    extra_keys: BTreeSet<String>,
10530    deleted_keys: BTreeSet<String>,
10531    extra_props: BTreeMap<(String, String), Value>,
10532    removed_props: BTreeSet<(String, String)>,
10533    extra_edges: BTreeSet<(String, String, String)>,
10534    deleted_edges: BTreeSet<(String, String, String)>,
10535    extra_rules: BTreeSet<String>,
10536    deleted_rules: BTreeSet<String>,
10537    /// `rule name → (via_edge, edge_type)` for every via-hop rule accepted
10538    /// earlier in this batch. Feeds the rule-chain cycle check, which otherwise
10539    /// sees only the rules already committed to the engine. Keyed by name so a
10540    /// later `DeleteRule` in the same batch drops the arc with the rule.
10541    extra_rule_arcs: BTreeMap<String, (String, String)>,
10542}
10543
10544/// Read-only view of live db state plus a batch overlay. Shared by single-op
10545/// public methods (empty overlay) and `commit_batch`.
10546struct MutPreview<'a, F: Fs> {
10547    db: &'a GraphDb<F>,
10548    overlay: Overlay,
10549}
10550
10551/// Shortest path from `start` to `target` following `arcs` (`from → to`), or
10552/// `None` if `target` is unreachable.
10553///
10554/// Used for rule-chain cycle detection, where an arc is "a rule hops over
10555/// `from` and writes `to`". Breadth-first over BTree-ordered adjacency, so the
10556/// reported path is stable for a given rule set, and iterative so a pathological
10557/// rule graph cannot overflow the stack.
10558fn find_cycle_through(arcs: &[(String, String)], start: &str, target: &str) -> Option<Vec<String>> {
10559    let mut adj: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
10560    for (from, to) in arcs {
10561        adj.entry(from.as_str()).or_default().insert(to.as_str());
10562    }
10563    let mut parent: BTreeMap<&str, &str> = BTreeMap::new();
10564    let mut visited: BTreeSet<&str> = BTreeSet::new();
10565    let mut queue: std::collections::VecDeque<&str> = std::collections::VecDeque::new();
10566    visited.insert(start);
10567    queue.push_back(start);
10568    while let Some(node) = queue.pop_front() {
10569        if node == target {
10570            let mut path = vec![node.to_string()];
10571            let mut cur = node;
10572            while let Some(&p) = parent.get(cur) {
10573                path.push(p.to_string());
10574                cur = p;
10575            }
10576            path.reverse();
10577            return Some(path);
10578        }
10579        for &next in adj.get(node).into_iter().flatten() {
10580            if visited.insert(next) {
10581                parent.insert(next, node);
10582                queue.push_back(next);
10583            }
10584        }
10585    }
10586    None
10587}
10588
10589impl<'a, F: Fs> MutPreview<'a, F> {
10590    fn new(db: &'a GraphDb<F>) -> Self {
10591        Self {
10592            db,
10593            overlay: Overlay::default(),
10594        }
10595    }
10596
10597    fn has_key(&self, key: &str) -> bool {
10598        if self.overlay.extra_keys.contains(key) {
10599            return true;
10600        }
10601        if self.overlay.deleted_keys.contains(key) {
10602            return false;
10603        }
10604        self.db.ids.get(key).is_some()
10605    }
10606
10607    fn has_prop(&self, key: &str, field: &str) -> bool {
10608        if !self.has_key(key) {
10609            return false;
10610        }
10611        let k = (key.to_string(), field.to_string());
10612        if self.overlay.removed_props.contains(&k) {
10613            return false;
10614        }
10615        if self.overlay.extra_props.contains_key(&k) {
10616            return true;
10617        }
10618        // Fresh identity (first insert in this batch, or delete+reinsert):
10619        // ignore props still sitting on the soon-to-be-tombstoned slot.
10620        if self.overlay.extra_keys.contains(key) {
10621            return false;
10622        }
10623        self.db.get_prop(key, field).is_some()
10624    }
10625
10626    fn has_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
10627        let k = (
10628            edge_type.to_string(),
10629            src_key.to_string(),
10630            dst_key.to_string(),
10631        );
10632        if self.overlay.deleted_edges.contains(&k) {
10633            return false;
10634        }
10635        if self.overlay.extra_edges.contains(&k) {
10636            return true;
10637        }
10638        // A key created in this batch (including reinsert) has no db edges.
10639        if self.overlay.extra_keys.contains(src_key) || self.overlay.extra_keys.contains(dst_key) {
10640            return false;
10641        }
10642        if self.overlay.deleted_keys.contains(src_key)
10643            || self.overlay.deleted_keys.contains(dst_key)
10644        {
10645            return false;
10646        }
10647        let Some(src) = self.db.ids.get(src_key) else {
10648            return false;
10649        };
10650        let Some(dst) = self.db.ids.get(dst_key) else {
10651            return false;
10652        };
10653        let Some(sym) = self.db.syms.get(edge_type) else {
10654            return false;
10655        };
10656        self.db
10657            .topo_view()
10658            .neighbors(sym, Direction::Out, src)
10659            .binary_search(&dst)
10660            .is_ok()
10661    }
10662
10663    fn has_rule(&self, name: &str) -> bool {
10664        if self.overlay.extra_rules.contains(name) {
10665            return true;
10666        }
10667        if self.overlay.deleted_rules.contains(name) {
10668            return false;
10669        }
10670        self.db.engine.rules().any(|r| r.name == name)
10671    }
10672
10673    fn is_rule_owned(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
10674        if self.overlay.extra_keys.contains(src_key) || self.overlay.extra_keys.contains(dst_key) {
10675            return false;
10676        }
10677        if self.overlay.deleted_keys.contains(src_key)
10678            || self.overlay.deleted_keys.contains(dst_key)
10679        {
10680            return false;
10681        }
10682        let Some(src) = self.db.ids.get(src_key) else {
10683            return false;
10684        };
10685        let Some(dst) = self.db.ids.get(dst_key) else {
10686            return false;
10687        };
10688        let Some(et) = self.db.syms.get(edge_type) else {
10689            return false;
10690        };
10691        // extra_rules is deliberately not consulted: a CreateRule earlier in
10692        // this batch has not fired, so it contributes no provenance. That is
10693        // the documented rule-window gap (see GraphDb::batch).
10694        if self.overlay.deleted_rules.is_empty() {
10695            return self.db.engine.is_owned(et, src, dst);
10696        }
10697        for (rule, triples) in self.db.engine.provenance() {
10698            if self.overlay.deleted_rules.contains(rule) {
10699                continue;
10700            }
10701            if triples.contains(&(et, src, dst)) {
10702                return true;
10703            }
10704        }
10705        false
10706    }
10707
10708    fn check_insert_node(&self, key: &str) -> Result<()> {
10709        if self.has_key(key) {
10710            Err(GraphError::DuplicateKey { key: key.into() })
10711        } else {
10712            Ok(())
10713        }
10714    }
10715
10716    fn check_live_key(&self, key: &str) -> Result<()> {
10717        if self.has_key(key) {
10718            Ok(())
10719        } else {
10720            Err(GraphError::KeyNotFound { key: key.into() })
10721        }
10722    }
10723
10724    fn prepare_insert_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
10725        for k in [src_key, dst_key] {
10726            if !self.has_key(k) {
10727                return Err(GraphError::KeyNotFound { key: k.into() });
10728            }
10729        }
10730        if self.is_rule_owned(edge_type, src_key, dst_key) {
10731            return Err(GraphError::RuleOwned {
10732                detail: format!("edge {edge_type} {src_key}→{dst_key} is rule-owned"),
10733            });
10734        }
10735        Ok(!self.has_edge(edge_type, src_key, dst_key))
10736    }
10737
10738    fn prepare_remove_prop(&self, key: &str, field: &str) -> Result<bool> {
10739        self.check_live_key(key)?;
10740        Ok(self.has_prop(key, field))
10741    }
10742
10743    fn prepare_delete_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
10744        for k in [src_key, dst_key] {
10745            if !self.has_key(k) {
10746                return Err(GraphError::KeyNotFound { key: k.into() });
10747            }
10748        }
10749        // Provenance-owned OR a live rule would derive this pair. User-first
10750        // edges that a later rule matches are not in `owned`, but deleting
10751        // them would leave a hole `rebuild_rule` immediately fills.
10752        if self.is_rule_owned(edge_type, src_key, dst_key) {
10753            return Err(GraphError::RuleOwned {
10754                detail: format!(
10755                    "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
10756                     delete or change the owning rule"
10757                ),
10758            });
10759        }
10760        if self.would_derive(edge_type, src_key, dst_key) {
10761            return Err(GraphError::RuleOwned {
10762                detail: format!(
10763                    "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
10764                     delete or change the owning rule, or a live rule would re-derive it"
10765                ),
10766            });
10767        }
10768        Ok(self.has_edge(edge_type, src_key, dst_key))
10769    }
10770
10771    /// True if any live rule (minus overlay-deleted names) would derive
10772    /// `(edge_type, src, dst)` from current overlay-visible props/labels.
10773    /// CreateRule names in `extra_rules` are ignored — same documented
10774    /// same-batch rule-window as [`Self::is_rule_owned`].
10775    fn would_derive(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
10776        if src_key == dst_key {
10777            return false;
10778        }
10779        let Some(src_label) = self.label_of(src_key) else {
10780            return false;
10781        };
10782        let Some(dst_label) = self.label_of(dst_key) else {
10783            return false;
10784        };
10785        for rule in self.db.engine.rules() {
10786            if self.overlay.deleted_rules.contains(&rule.name) {
10787                continue;
10788            }
10789            if rule.edge_type != edge_type {
10790                continue;
10791            }
10792            if rule.src_label != src_label || rule.dst_label != dst_label {
10793                continue;
10794            }
10795            let src_props = |f: &str| self.prop_value(src_key, f);
10796            let dst_props = |f: &str| self.prop_value(dst_key, f);
10797            let src_view = NodeView {
10798                key: src_key,
10799                props: &src_props,
10800            };
10801            let dst_view = NodeView {
10802                key: dst_key,
10803                props: &dst_props,
10804            };
10805            if evaluate(&rule.predicate, &src_view, &dst_view).is_some() {
10806                return true;
10807            }
10808        }
10809        false
10810    }
10811
10812    fn label_of(&self, key: &str) -> Option<String> {
10813        if self.overlay.deleted_keys.contains(key) {
10814            return None;
10815        }
10816        // Fresh identities created in this batch have no stored label in the
10817        // overlay; they cannot be provenance-owned yet either.
10818        let id = self.db.ids.get(key)?;
10819        let sym = self.db.labels.get(id as usize).copied()?;
10820        if sym == u32::MAX {
10821            return None;
10822        }
10823        self.db.syms.resolve(sym).map(str::to_string)
10824    }
10825
10826    fn prop_value(&self, key: &str, field: &str) -> Option<Value> {
10827        if !self.has_key(key) {
10828            return None;
10829        }
10830        let k = (key.to_string(), field.to_string());
10831        if self.overlay.removed_props.contains(&k) {
10832            return None;
10833        }
10834        if let Some(v) = self.overlay.extra_props.get(&k) {
10835            return Some(v.clone());
10836        }
10837        if self.overlay.extra_keys.contains(key) {
10838            return None;
10839        }
10840        self.db.get_prop(key, field)
10841    }
10842
10843    fn check_create_rule(&self, def: &RuleDef) -> Result<()> {
10844        def.validate()
10845            .map_err(|e| GraphError::RuleInvalid { detail: e })?;
10846        if self.has_rule(&def.name) {
10847            return Err(GraphError::RuleInvalid {
10848                detail: format!("rule {:?} already exists", def.name),
10849            });
10850        }
10851        // Rule-chain cycle rejection. Derived edges feed via-hop rules, so a
10852        // rule set forms a graph whose arcs are "hops over `via_edge`, writes
10853        // `edge_type`". A cycle in that graph is a rule set that would re-fire
10854        // itself forever; the engine's depth cap would silently truncate it
10855        // instead, leaving an arbitrary partial result. Reject it here, the one
10856        // place that sees the whole rule set.
10857        //
10858        // Rules accepted earlier in the same batch count too: the overlay
10859        // carries their arcs, so a cycle cannot be assembled one op at a time.
10860        if let Some(via) = def.via_edge.as_deref() {
10861            if via == def.edge_type {
10862                return Err(GraphError::RuleInvalid {
10863                    detail: format!("rule chain cycle: {} -> {}", via, def.edge_type),
10864                });
10865            }
10866            let mut arcs: Vec<(String, String)> = self
10867                .db
10868                .engine
10869                .rules()
10870                .filter(|r| !self.overlay.deleted_rules.contains(&r.name))
10871                .filter_map(|r| r.via_edge.clone().map(|v| (v, r.edge_type.clone())))
10872                .collect();
10873            arcs.extend(self.overlay.extra_rule_arcs.values().cloned());
10874            arcs.push((via.to_string(), def.edge_type.clone()));
10875            if let Some(path) = find_cycle_through(&arcs, &def.edge_type, via) {
10876                return Err(GraphError::RuleInvalid {
10877                    detail: format!("rule chain cycle: {} -> {}", via, path.join(" -> ")),
10878                });
10879            }
10880        }
10881        Ok(())
10882    }
10883
10884    fn check_delete_rule(&self, name: &str) -> Result<()> {
10885        if self.has_rule(name) {
10886            Ok(())
10887        } else {
10888            Err(GraphError::RuleNotFound { name: name.into() })
10889        }
10890    }
10891
10892    fn note_insert_node(&mut self, key: &str, props: &[(String, Value)]) {
10893        self.overlay.deleted_keys.remove(key);
10894        self.overlay.extra_keys.insert(key.to_string());
10895        self.overlay.extra_props.retain(|(k, _), _| k != key);
10896        self.overlay.removed_props.retain(|(k, _)| k != key);
10897        for (field, value) in props {
10898            self.overlay
10899                .extra_props
10900                .insert((key.to_string(), field.clone()), value.clone());
10901        }
10902    }
10903
10904    fn note_insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) {
10905        let k = (
10906            edge_type.to_string(),
10907            src_key.to_string(),
10908            dst_key.to_string(),
10909        );
10910        self.overlay.deleted_edges.remove(&k);
10911        self.overlay.extra_edges.insert(k);
10912    }
10913
10914    fn note_set_prop(&mut self, key: &str, field: &str, value: &Value) {
10915        let k = (key.to_string(), field.to_string());
10916        self.overlay.removed_props.remove(&k);
10917        self.overlay.extra_props.insert(k, value.clone());
10918    }
10919
10920    fn note_remove_prop(&mut self, key: &str, field: &str) {
10921        let k = (key.to_string(), field.to_string());
10922        self.overlay.extra_props.remove(&k);
10923        self.overlay.removed_props.insert(k);
10924    }
10925
10926    fn note_delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) {
10927        let k = (
10928            edge_type.to_string(),
10929            src_key.to_string(),
10930            dst_key.to_string(),
10931        );
10932        self.overlay.extra_edges.remove(&k);
10933        self.overlay.deleted_edges.insert(k);
10934    }
10935
10936    fn note_delete_node(&mut self, key: &str) {
10937        self.overlay.extra_keys.remove(key);
10938        self.overlay.deleted_keys.insert(key.to_string());
10939        self.overlay.extra_props.retain(|(k, _), _| k != key);
10940        self.overlay.removed_props.retain(|(k, _)| k != key);
10941        self.overlay
10942            .extra_edges
10943            .retain(|(_, s, d)| s != key && d != key);
10944        self.overlay
10945            .deleted_edges
10946            .retain(|(_, s, d)| s != key && d != key);
10947    }
10948
10949    fn note_create_rule(&mut self, def: &RuleDef) {
10950        self.overlay.deleted_rules.remove(&def.name);
10951        self.overlay.extra_rules.insert(def.name.clone());
10952        // Rules accepted earlier in this batch are not in the engine yet, so
10953        // the cycle check would not see their arcs. Keep the arc, not just the
10954        // name, so a batch cannot smuggle in a cycle one op at a time.
10955        if let Some(via) = def.via_edge.clone() {
10956            self.overlay
10957                .extra_rule_arcs
10958                .insert(def.name.clone(), (via, def.edge_type.clone()));
10959        }
10960    }
10961
10962    fn check_rename_node(&self, old: &str, new: &str) -> Result<()> {
10963        if !self.has_key(old) {
10964            return Err(GraphError::KeyNotFound { key: old.into() });
10965        }
10966        if self.has_key(new) {
10967            return Err(GraphError::DuplicateKey { key: new.into() });
10968        }
10969        Ok(())
10970    }
10971
10972    fn note_rename_node(&mut self, old: &str, new: &str) {
10973        // Mark old as deleted so subsequent batch ops cannot reference it.
10974        self.overlay.extra_keys.remove(old);
10975        self.overlay.deleted_keys.insert(old.to_string());
10976        // Mark new as extra so subsequent batch ops can reference it.
10977        self.overlay.deleted_keys.remove(new);
10978        self.overlay.extra_keys.insert(new.to_string());
10979        // Migrate any overlay props from old key to new key.
10980        let new_str = new.to_string();
10981        let transferred: Vec<((String, String), Value)> = self
10982            .overlay
10983            .extra_props
10984            .iter()
10985            .filter(|((k, _), _)| k.as_str() == old)
10986            .map(|((_, f), v)| ((new_str.clone(), f.clone()), v.clone()))
10987            .collect();
10988        self.overlay
10989            .extra_props
10990            .retain(|(k, _), _| k.as_str() != old);
10991        for (k, v) in transferred {
10992            self.overlay.extra_props.insert(k, v);
10993        }
10994        // Migrate removed_props.
10995        let transferred_removed: Vec<(String, String)> = self
10996            .overlay
10997            .removed_props
10998            .iter()
10999            .filter(|(k, _)| k.as_str() == old)
11000            .map(|(_, f)| (new_str.clone(), f.clone()))
11001            .collect();
11002        self.overlay
11003            .removed_props
11004            .retain(|(k, _)| k.as_str() != old);
11005        for k in transferred_removed {
11006            self.overlay.removed_props.insert(k);
11007        }
11008    }
11009
11010    fn note_delete_rule(&mut self, name: &str) {
11011        self.overlay.extra_rules.remove(name);
11012        // Drop its chain arc too: a rule created and then deleted in the same
11013        // batch must not make a later, legal rule look like a cycle.
11014        self.overlay.extra_rule_arcs.remove(name);
11015        self.overlay.deleted_rules.insert(name.to_string());
11016        // Treat the deleted rule's current provenance as gone so a later
11017        // delete_edge of those triples is a no-op (matches sequential).
11018        if let Some(triples) = self.db.engine.provenance().get(name) {
11019            for &(et, s, d) in triples {
11020                let Some(etype) = self.db.syms.resolve(et) else {
11021                    continue;
11022                };
11023                let Some(src) = self.db.ids.key_of(s) else {
11024                    continue;
11025                };
11026                let Some(dst) = self.db.ids.key_of(d) else {
11027                    continue;
11028                };
11029                let k = (etype.to_string(), src.to_string(), dst.to_string());
11030                self.overlay.extra_edges.remove(&k);
11031                self.overlay.deleted_edges.insert(k);
11032            }
11033        }
11034    }
11035}
11036
11037/// Collects mutations and commits them as one WAL `Batch` frame.
11038///
11039/// Holds `&mut GraphDb` for its lifetime. Queue with the same method names
11040/// as [`GraphDb`]; call [`commit`](Self::commit) to validate, log, and apply.
11041/// See [`GraphDb::batch`] for validation and atomicity rules.
11042pub struct BatchBuilder<'a, F: Fs> {
11043    db: &'a mut GraphDb<F>,
11044    ops: Vec<BatchOp>,
11045}
11046
11047impl<'a, F: Fs> BatchBuilder<'a, F> {
11048    pub fn insert_node(
11049        &mut self,
11050        label: &str,
11051        key: &str,
11052        props: Vec<(String, Value)>,
11053    ) -> &mut Self {
11054        self.ops.push(BatchOp::InsertNode {
11055            label: label.into(),
11056            key: key.into(),
11057            props,
11058        });
11059        self
11060    }
11061
11062    pub fn insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> &mut Self {
11063        self.ops.push(BatchOp::InsertEdge {
11064            edge_type: edge_type.into(),
11065            src_key: src_key.into(),
11066            dst_key: dst_key.into(),
11067        });
11068        self
11069    }
11070
11071    pub fn set_prop(&mut self, key: &str, field: &str, value: Value) -> &mut Self {
11072        self.ops.push(BatchOp::SetProp {
11073            key: key.into(),
11074            field: field.into(),
11075            value,
11076        });
11077        self
11078    }
11079
11080    pub fn remove_prop(&mut self, key: &str, field: &str) -> &mut Self {
11081        self.ops.push(BatchOp::RemoveProp {
11082            key: key.into(),
11083            field: field.into(),
11084        });
11085        self
11086    }
11087
11088    pub fn delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> &mut Self {
11089        self.ops.push(BatchOp::DeleteEdge {
11090            edge_type: edge_type.into(),
11091            src_key: src_key.into(),
11092            dst_key: dst_key.into(),
11093        });
11094        self
11095    }
11096
11097    pub fn delete_node(&mut self, key: &str) -> &mut Self {
11098        self.ops.push(BatchOp::DeleteNode { key: key.into() });
11099        self
11100    }
11101
11102    pub fn create_rule(&mut self, def: RuleDef) -> &mut Self {
11103        self.ops.push(BatchOp::CreateRule(def));
11104        self
11105    }
11106
11107    pub fn delete_rule(&mut self, name: &str) -> &mut Self {
11108        self.ops.push(BatchOp::DeleteRule { name: name.into() });
11109        self
11110    }
11111
11112    /// Queue a node-rename in this batch.
11113    ///
11114    /// Validation (old exists, new not taken) runs at commit time.
11115    pub fn rename_node(&mut self, old_key: &str, new_key: &str) -> &mut Self {
11116        self.ops.push(BatchOp::RenameNode {
11117            old_key: old_key.into(),
11118            new_key: new_key.into(),
11119        });
11120        self
11121    }
11122
11123    /// Queue an edge insert with endpoint auto-creation.
11124    ///
11125    /// Any missing endpoint is created as a plain node `{key, label:
11126    /// placeholder_label, no props}` inside this batch frame. Rules fire and
11127    /// last-change is updated for each auto-created node.
11128    pub fn insert_edge_upsert(
11129        &mut self,
11130        edge_type: &str,
11131        src_key: &str,
11132        dst_key: &str,
11133        placeholder_label: &str,
11134    ) -> &mut Self {
11135        self.ops.push(BatchOp::InsertEdgeUpsert {
11136            edge_type: edge_type.into(),
11137            src_key: src_key.into(),
11138            dst_key: dst_key.into(),
11139            placeholder_label: placeholder_label.into(),
11140        });
11141        self
11142    }
11143
11144    /// Validate every queued op, then log one `Batch` frame and apply.
11145    /// Empty / all-noop batches return `Ok(())` without writing the WAL.
11146    /// A second `commit()` after a successful one is an empty-batch no-op
11147    /// (queued ops were taken).
11148    /// Takes `&mut self` so it chains after the queue methods (`b.insert_node(..).commit()`)
11149    /// and also works as `let mut b = db.batch(); b.insert_node(..); b.commit()`.
11150    ///
11151    /// **Rule-window limitation:** batch validation cannot see edges that a
11152    /// rule created earlier in the *same* batch will derive at apply time, so
11153    /// a `delete_edge` / `insert_edge` in that window is silently no-oped
11154    /// where sequential calls would return `Err(RuleOwned)`. State integrity
11155    /// is unaffected (idempotent apply, provenance intact). Create rules in
11156    /// their own batch, or sequentially, when later ops may touch derived
11157    /// edges.
11158    /// Validate every queued op and commit atomically.
11159    ///
11160    /// Returns `(nodes_inserted, edges_inserted)` — the counts of node and edge
11161    /// WAL records actually written (duplicate edges are silent no-ops and are
11162    /// NOT counted). Both are 0 when the batch is empty or all-noop.
11163    pub fn commit(&mut self) -> Result<(usize, usize)> {
11164        let ops = std::mem::take(&mut self.ops);
11165        self.db.commit_batch(ops)
11166    }
11167
11168    /// Same as [`commit`](Self::commit) but tail the inner events with
11169    /// [`MutationEvent::Ingested`] instead of [`MutationEvent::BatchApplied`].
11170    pub(crate) fn commit_ingest(&mut self, label: &str, inserted: usize) -> Result<(usize, usize)> {
11171        let ops = std::mem::take(&mut self.ops);
11172        self.db
11173            .commit_logged_batch(ops, Some((label.to_string(), inserted)), None)
11174    }
11175}
11176
11177pub struct NodeRef<'a, F: Fs> {
11178    db: &'a GraphDb<F>,
11179    id: u32,
11180}
11181
11182impl<'a, F: Fs> NodeRef<'a, F> {
11183    pub fn key(&self) -> &str {
11184        self.db.ids.key_of(self.id).expect("dense ids")
11185    }
11186
11187    pub fn label(&self) -> &str {
11188        let sym = self
11189            .db
11190            .labels
11191            .get(self.id as usize)
11192            .copied()
11193            .filter(|&s| s != u32::MAX)
11194            .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
11195        self.db.syms.resolve(sym).expect("interned label symbol")
11196    }
11197
11198    pub fn prop(&self, field: &str) -> Option<Value> {
11199        self.db
11200            .props_view()
11201            .get(self.id, field)
11202            .map(|vr| vr.into_value())
11203    }
11204
11205    /// All stored fields for this node, sorted by field name.
11206    ///
11207    /// Reads from the full base+overlay view so that props stored only in the
11208    /// V8 snapshot base (i.e. before any post-snapshot WAL writes) are visible.
11209    pub fn props(&self) -> BTreeMap<String, Value> {
11210        let mut out = BTreeMap::new();
11211        let pv = self.db.props_view();
11212        for field in pv.field_names() {
11213            if let Some(vr) = pv.get(self.id, &field) {
11214                out.insert(field, vr.into_value());
11215            }
11216        }
11217        out
11218    }
11219
11220    /// depth-N BFS as a ResultSet: columns ["key","label","depth"], BFS order.
11221    pub fn neighborhood(&self, depth: u32, edge_types: Option<&[&str]>, dir: Dir) -> ResultSet {
11222        let view = self.db.view();
11223        let resolved: Option<Vec<u32>> = edge_types.map(|names| {
11224            names
11225                .iter()
11226                .filter_map(|name| view.syms.get(name))
11227                .collect()
11228        });
11229        let nb = neighborhood(&view, self.id, depth, resolved.as_deref(), dir);
11230        let mut rs = ResultSet::new(vec!["key".into(), "label".into(), "depth".into()]);
11231        for (nid, d) in nb.nodes {
11232            let key = view.key_of(nid);
11233            let label = view
11234                .label_of(nid)
11235                .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
11236            rs.push_row(vec![
11237                Some(Value::Str(key.to_string())),
11238                Some(Value::Str(label.to_string())),
11239                Some(Value::Int(d as i64)),
11240            ]);
11241        }
11242        rs
11243    }
11244
11245    /// 1-hop, Both directions: edge-type name → sorted unique neighbor keys.
11246    pub fn grouped_by_edge_type(&self) -> BTreeMap<String, Vec<String>> {
11247        let view = self.db.view();
11248        let mut groups: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
11249        for e in expand(&view, self.id, None, Dir::Both) {
11250            // Skip edges with unknown etypes (only possible from corrupt large
11251            // TOPOLOGY section; function returns BTreeMap not Result).
11252            let Some(etype) = view.syms.resolve(e.etype) else {
11253                continue;
11254            };
11255            let etype = etype.to_string();
11256            let nbr = if e.src == self.id { e.dst } else { e.src };
11257            groups
11258                .entry(etype)
11259                .or_default()
11260                .insert(view.key_of(nbr).to_string());
11261        }
11262        groups
11263            .into_iter()
11264            .map(|(k, v)| (k, v.into_iter().collect()))
11265            .collect()
11266    }
11267}
11268
11269#[cfg(test)]
11270mod tests {
11271    use super::*;
11272    use core_rules::Predicate;
11273
11274    fn tmp_dir(name: &str) -> std::path::PathBuf {
11275        let d =
11276            std::env::temp_dir().join(format!("graphdb-db-unit-{}-{}", name, std::process::id()));
11277        let _ = std::fs::remove_dir_all(&d);
11278        d
11279    }
11280
11281    fn fk_rule() -> RuleDef {
11282        RuleDef {
11283            name: "works_at".into(),
11284            src_label: "Person".into(),
11285            dst_label: "Org".into(),
11286            predicate: Predicate::KeyMatch {
11287                field: "org_id".into(),
11288            },
11289            edge_type: "WORKS_AT".into(),
11290            weight_prop: None,
11291            max_edges: None,
11292            approximate: false,
11293            via_label: None,
11294            via_edge: None,
11295            via_dir: None,
11296        }
11297    }
11298
11299    /// Regression guard for the no-views delta-copy fast path.
11300    ///
11301    /// When no views are defined, `pending_deltas_since().to_vec()` must never
11302    /// be called — even during a large CreateRule backfill. The DELTA_COPY_COUNT
11303    /// thread-local is incremented inside every `if !view_store.is_empty()` block;
11304    /// a count of 0 after the entire sequence proves the guard fires correctly.
11305    #[test]
11306    fn no_delta_copy_when_no_views() {
11307        DELTA_COPY_COUNT.with(|c| c.set(0));
11308        let dir = tmp_dir("no-delta-copy");
11309        {
11310            let mut db = GraphDb::open(&dir).unwrap();
11311            // Insert 50 Org + 50 Person nodes with FK links.
11312            for i in 0..50u32 {
11313                db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
11314            }
11315            for i in 0..50u32 {
11316                db.insert_node(
11317                    "Person",
11318                    &format!("p{i}"),
11319                    vec![("org_id".into(), Value::Str(format!("o{i}")))],
11320                )
11321                .unwrap();
11322            }
11323            // CreateRule backfill should NOT invoke to_vec() when no views are defined.
11324            db.create_rule(fk_rule()).unwrap();
11325
11326            // Counter must stay 0 — no views, no copies.
11327            let copies = DELTA_COPY_COUNT.with(|c| c.get());
11328            assert_eq!(
11329                copies, 0,
11330                "pending_deltas_since().to_vec() called despite no views"
11331            );
11332
11333            // Derived edges must still be correct (the guard skips only the
11334            // empty delta propagation loop, not the rule application itself).
11335            let nbrs = db.neighbors("p0", "WORKS_AT", Direction::Out).unwrap();
11336            assert_eq!(
11337                nbrs,
11338                vec!["o0"],
11339                "rule must derive edges even with no views"
11340            );
11341        }
11342        let _ = std::fs::remove_dir_all(&dir);
11343    }
11344
11345    /// Gating regression: subscribe AFTER a backfill must see no stale events.
11346    /// subscribe BEFORE a backfill must see every edge-fire event.
11347    #[test]
11348    fn subscribe_after_backfill_no_stale_events() {
11349        let dir = tmp_dir("sub-after-backfill");
11350        {
11351            let mut db = GraphDb::open(&dir).unwrap();
11352            for i in 0..10u32 {
11353                db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
11354                db.insert_node(
11355                    "Person",
11356                    &format!("p{i}"),
11357                    vec![("org_id".into(), Value::Str(format!("o{i}")))],
11358                )
11359                .unwrap();
11360            }
11361            // Create rule BEFORE subscribing — emit_deltas is false during backfill.
11362            db.create_rule(fk_rule()).unwrap();
11363
11364            // Subscribe AFTER the backfill — queue must be empty (no stale events).
11365            let sub = db.subscribe_all_rules().unwrap();
11366            // No events should have queued for the prior backfill.
11367            assert!(
11368                sub.try_recv().is_none(),
11369                "subscribe after backfill must see no stale events"
11370            );
11371
11372            // Inserting a new node now should fire an event (emit_deltas is now true).
11373            db.insert_node("Org", "o_new", vec![]).unwrap();
11374            db.insert_node(
11375                "Person",
11376                "p_new",
11377                vec![("org_id".into(), Value::Str("o_new".into()))],
11378            )
11379            .unwrap();
11380            let ev = sub.recv_timeout(std::time::Duration::from_millis(200));
11381            assert!(
11382                ev.is_some(),
11383                "edge-fire event must arrive after subscribe (emit_deltas=true)"
11384            );
11385        }
11386        let _ = std::fs::remove_dir_all(&dir);
11387    }
11388
11389    /// Gating regression: subscribe BEFORE a backfill → events flow.
11390    #[test]
11391    fn subscribe_before_backfill_events_flow() {
11392        let dir = tmp_dir("sub-before-backfill");
11393        {
11394            let mut db = GraphDb::open(&dir).unwrap();
11395            // Subscribe FIRST — emit_deltas becomes true.
11396            let sub = db.subscribe_all_rules().unwrap();
11397
11398            for i in 0..5u32 {
11399                db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
11400                db.insert_node(
11401                    "Person",
11402                    &format!("p{i}"),
11403                    vec![("org_id".into(), Value::Str(format!("o{i}")))],
11404                )
11405                .unwrap();
11406            }
11407            // Backfill fires with emit_deltas=true → events queued.
11408            db.create_rule(fk_rule()).unwrap();
11409
11410            // Should receive at least one edge-fired event from the backfill.
11411            let mut received = 0usize;
11412            while sub.try_recv().is_some() {
11413                received += 1;
11414            }
11415            assert!(
11416                received > 0,
11417                "subscribe before backfill must receive edge-fire events (got 0)"
11418            );
11419        }
11420        let _ = std::fs::remove_dir_all(&dir);
11421    }
11422
11423    /// Companion: when a view IS defined, the delta path fires and view values update.
11424    #[test]
11425    fn delta_copy_fires_when_view_exists() {
11426        use core_rules::ViewSource;
11427        DELTA_COPY_COUNT.with(|c| c.set(0));
11428        let dir = tmp_dir("delta-copy-with-view");
11429        {
11430            let mut db = GraphDb::open(&dir).unwrap();
11431            db.insert_node("Org", "o1", vec![]).unwrap();
11432            db.insert_node(
11433                "Person",
11434                "p1",
11435                vec![("org_id".into(), Value::Str("o1".into()))],
11436            )
11437            .unwrap();
11438            // Declare a Degree view so is_empty() returns false.
11439            db.create_view(ViewDef {
11440                name: "degree_out".into(),
11441                label: "Person".into(),
11442                view_prop: "degree_out".into(),
11443                source: ViewSource::Degree {
11444                    edge_type: "WORKS_AT".into(),
11445                    direction: Direction::Out,
11446                },
11447            })
11448            .unwrap();
11449            db.create_rule(fk_rule()).unwrap();
11450
11451            // At least one delta copy should have happened (CreateRule backfill).
11452            let copies = DELTA_COPY_COUNT.with(|c| c.get());
11453            assert!(
11454                copies > 0,
11455                "expected delta copy to fire when a view is defined"
11456            );
11457
11458            // View value should be computed: p1 has one WORKS_AT out-edge.
11459            let info = db.node_info("p1").unwrap();
11460            let degree = info.props.get("degree_out");
11461            assert!(
11462                degree.is_some(),
11463                "view prop should be written to node props"
11464            );
11465        }
11466        let _ = std::fs::remove_dir_all(&dir);
11467    }
11468
11469    /// Regression: `open_at_with` must call `rebuild_all` after WAL replay so
11470    /// derived-edge-driven view values reflect the as-of state rather than just
11471    /// the initial backfill written at `CreateView` time.
11472    ///
11473    /// Base WAL frames (indices 0..=5 before history markers):
11474    ///   0: insert Org "o1"
11475    ///   1: create_view "employee_count" (Degree / WORKS_AT / In) on Org
11476    ///   2: create_rule fk_rule (WORKS_AT, Person→Org via org_id)
11477    ///   3: insert Person "p1" → rule fires WORKS_AT p1→o1 (degree = 1)  ← mid
11478    ///   4: insert Person "p2" → rule fires WORKS_AT p2→o1 (degree = 2)
11479    ///   5: insert Person "p3" → rule fires WORKS_AT p3→o1 (degree = 3)  ← latest
11480    ///
11481    /// Each rule-fire also appends a DerivedEdgeAdded history-marker frame (state
11482    /// no-op), so the total commit count is higher than the base frame count.
11483    /// The "latest" open_at commit is computed dynamically via `wal_commit_count_at`.
11484    ///
11485    /// Without `rebuild_all`, the as-of instance's "emp" view stays at the
11486    /// initial backfill value (0) instead of reflecting the replayed derived edges.
11487    #[test]
11488    fn open_at_derived_edge_view_values_correct() {
11489        use core_rules::ViewSource;
11490        let dir = tmp_dir("open-at-view-rebuild");
11491        {
11492            let mut db = GraphDb::open(&dir).unwrap();
11493            // frame 0
11494            db.insert_node("Org", "o1", vec![]).unwrap();
11495            // frame 1: create view — initial backfill sees 0 derived edges (none fired yet)
11496            db.create_view(ViewDef {
11497                name: "employee_count".into(),
11498                label: "Org".into(),
11499                view_prop: "emp".into(),
11500                source: ViewSource::Degree {
11501                    edge_type: "WORKS_AT".into(),
11502                    direction: Direction::In,
11503                },
11504            })
11505            .unwrap();
11506            // frame 2: create rule — no Persons yet; backfill is a no-op
11507            db.create_rule(fk_rule()).unwrap();
11508            // frame 3: p1 — rule fires WORKS_AT p1→o1; degree = 1
11509            db.insert_node(
11510                "Person",
11511                "p1",
11512                vec![("org_id".into(), Value::Str("o1".into()))],
11513            )
11514            .unwrap();
11515            // frame 4: p2 — degree = 2
11516            db.insert_node(
11517                "Person",
11518                "p2",
11519                vec![("org_id".into(), Value::Str("o1".into()))],
11520            )
11521            .unwrap();
11522            // frame 5: p3 — degree = 3
11523            db.insert_node(
11524                "Person",
11525                "p3",
11526                vec![("org_id".into(), Value::Str("o1".into()))],
11527            )
11528            .unwrap();
11529            // Sanity: normal open sees degree = 3.
11530            assert_eq!(
11531                db.get_view_prop("o1", "emp"),
11532                Some(Value::Int(3)),
11533                "normal db must show degree 3 after 3 derived edges"
11534            );
11535        } // WAL flushed
11536
11537        // Re-open normally to get the authoritative reference value.
11538        let normal_db = GraphDb::open(&dir).unwrap();
11539        let normal_emp = normal_db.get_view_prop("o1", "emp");
11540        assert_eq!(
11541            normal_emp,
11542            Some(Value::Int(3)),
11543            "re-opened normal db must show degree 3"
11544        );
11545
11546        // Latest as-of (last WAL commit): must match the normal open.
11547        // History-marker frames are appended after each rule-fire, so the total
11548        // commit count is computed dynamically rather than hardcoded.
11549        let total = crate::wal_commit_count_at(&dir).unwrap();
11550        let aof_latest = GraphDb::open_at(&dir, total - 1).unwrap();
11551        assert_eq!(
11552            aof_latest.get_view_prop("o1", "emp"),
11553            normal_emp,
11554            "open_at latest: derived-edge view must equal normal open (rebuild_all required)"
11555        );
11556
11557        // Mid-history as-of (commit 3 = p1 insert Batch frame): only p1; degree = 1.
11558        // The DerivedEdgeAdded marker for p1 is at frame 4 (state no-op on replay),
11559        // so replaying 0..=3 correctly re-derives only the p1→o1 edge.
11560        let aof_mid = GraphDb::open_at(&dir, 3).unwrap();
11561        assert_eq!(
11562            aof_mid.get_view_prop("o1", "emp"),
11563            Some(Value::Int(1)),
11564            "open_at mid-history: only p1 exists at frame 3, degree must be 1"
11565        );
11566
11567        let _ = std::fs::remove_dir_all(&dir);
11568    }
11569
11570    /// Pin: subscribe_* on an as-of instance must return Err(ReadOnly) —
11571    /// as-of instances never commit, so distribute_events never runs and any
11572    /// subscription would wait forever.
11573    #[test]
11574    fn subscribe_on_as_of_returns_read_only_error() {
11575        let dir = tmp_dir("sub-as-of-read-only");
11576        {
11577            let mut db = GraphDb::open(&dir).unwrap();
11578            db.insert_node("Org", "o1", vec![]).unwrap();
11579            db.create_rule(fk_rule()).unwrap();
11580        }
11581        let mut aof = GraphDb::open_at(&dir, 0).unwrap();
11582
11583        assert!(
11584            matches!(
11585                aof.subscribe_all_rules(),
11586                Err(core_storage::GraphError::ReadOnly)
11587            ),
11588            "subscribe_all_rules on as-of must return ReadOnly"
11589        );
11590        assert!(
11591            matches!(
11592                aof.subscribe_writes(),
11593                Err(core_storage::GraphError::ReadOnly)
11594            ),
11595            "subscribe_writes on as-of must return ReadOnly"
11596        );
11597        assert!(
11598            matches!(
11599                aof.subscribe_rule("works_at"),
11600                Err(core_storage::GraphError::ReadOnly)
11601            ),
11602            "subscribe_rule on as-of must return ReadOnly"
11603        );
11604        let _ = std::fs::remove_dir_all(&dir);
11605    }
11606
11607    /// Regression: a failed dense WAL rewrite must not leave speculative
11608    /// interns in `syms`. If it does, the next successful mutation logs an
11609    /// `Intern` record with an inflated id; replay (which never saw the
11610    /// orphans) assigns a smaller id and the WAL becomes unreplayable.
11611    #[test]
11612    fn dense_rewrite_error_rolls_back_speculative_interns() {
11613        let dir = tmp_dir("dense-rewrite-rollback");
11614        {
11615            let mut db = GraphDb::open(&dir).unwrap();
11616            db.insert_node("Person", "a", vec![]).unwrap();
11617
11618            // Bypass MutPreview validation to hit the rewrite's own error path
11619            // (same shape as an id-exhaustion failure mid-rewrite). The
11620            // InsertEdge arm interns the edge type before it resolves keys.
11621            let err = db.rewrite_wal_dense(vec![WalRecord::InsertEdge {
11622                edge_type: "ORPHAN_TYPE".into(),
11623                src_key: "missing".into(),
11624                dst_key: "a".into(),
11625            }]);
11626            assert!(err.is_err(), "rewrite of a missing src key must fail");
11627            assert_eq!(
11628                db.syms.get("ORPHAN_TYPE"),
11629                None,
11630                "failed rewrite must roll back speculative interns"
11631            );
11632
11633            // A later successful mutation must produce a replayable WAL.
11634            db.set_prop("a", "later_field", Value::Int(2)).unwrap();
11635        }
11636        let db = GraphDb::open(&dir).expect("WAL must replay after failed rewrite");
11637        assert_eq!(db.get_prop("a", "later_field"), Some(Value::Int(2)));
11638        let _ = std::fs::remove_dir_all(&dir);
11639    }
11640}