Skip to main content

core_api/
db.rs

1use crate::ingest::{IngestOptions, IngestReport};
2use crate::roles::{RoleDef, RolesFile};
3use crate::subscription::{
4    event_matches, DbEvent, SubEntry, SubFilter, SubInner, Subscription, DEFAULT_SUB_CAPACITY,
5};
6use core_query::cypher::ast::ArithOp;
7use core_query::cypher::{
8    execute, is_subscribable, is_write_tokens, lex, parse, parse_write, plan, MatchDeleteNodeStmt,
9    NodePat, Operand, Params, Pattern, PlanOp, Query, RetItem, RetVal, WriteStatement,
10};
11use core_query::{eval_filter, expand, neighborhood, Dir, Filter, GraphView, ResultSet};
12use core_rules::{
13    decode_rule_def, evaluate, EngineEdgeDelta, GraphMut, NodeView, Predicate, RuleDef, RuleEngine,
14    ViewDef, ViewStore,
15};
16use core_storage::fs::{FileId, Fs, FsIntrospect, RealFs};
17use core_storage::fulltext::FulltextIndex;
18use core_storage::v8::encode::{
19    archived_hnsw_to_owned, archived_rules_meta_to_owned, archived_to_idmap, archived_to_interner,
20    archived_views_to_owned, decode_last_change_bytes, decode_meta, encode_v8, V8Meta,
21};
22use core_storage::v8::seam::TopologyView;
23use core_storage::wal::{decode_all, encode_record, WalRecord};
24use core_storage::EdgePropsView;
25use core_storage::{
26    ColumnStore, Direction, EdgeProps, GraphError, IdMap, Interner, Result, Topology, Value,
27};
28use serde::{Deserialize, Serialize};
29use std::collections::{BTreeMap, BTreeSet, HashMap};
30use std::sync::Arc;
31
32/// Print a timing checkpoint when MUSHROOMDB_TRACE_OPEN is set.
33/// Zero-cost when the env var is absent (the var check is O(1) after first call).
34macro_rules! trace_open {
35    ($phase:literal, $t:expr) => {
36        if std::env::var("MUSHROOMDB_TRACE_OPEN").is_ok() {
37            eprintln!(
38                "[MUSHROOMDB_TRACE_OPEN] {:40} {:>9.3?}",
39                $phase,
40                $t.elapsed()
41            );
42        }
43    };
44}
45
46/// Print a migration phase checkpoint when MUSHROOMDB_TRACE_MIGRATE is set.
47/// Zero-cost when the env var is absent (the var check is O(1) after first call).
48macro_rules! trace_migrate {
49    ($phase:literal, $t:expr) => {
50        if std::env::var("MUSHROOMDB_TRACE_MIGRATE").is_ok() {
51            eprintln!(
52                "[MUSHROOMDB_TRACE_MIGRATE] {:40} {:>9.3?}",
53                $phase,
54                $t.elapsed()
55            );
56        }
57    };
58}
59
60// Test-only: counts how many times `pending_deltas_since().to_vec()` actually
61// executes (i.e., at least one view is defined). Used to verify the fast-path
62// guard skips the allocation when `view_store.is_empty()`.
63#[cfg(test)]
64thread_local! {
65    static DELTA_COPY_COUNT: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
66}
67
68/// Internal state for a single `subscribe_query` subscription.
69///
70/// On every commit, `distribute_events` re-executes `ops` against the current
71/// graph state, diffs the result against `prev_rows`, and pushes
72/// `DbEvent::QueryRowAdded` / `QueryRowRemoved` events to `inner`.
73///
74/// **Full re-run per commit; use LIMIT to bound execution cost.**
75/// (Differential evaluation is roadmap / Phase 5.)
76pub(crate) struct QuerySubEntry {
77    /// Compiled plan for the subscribed Cypher query.
78    ops: Vec<PlanOp>,
79    /// Column names from the first execution (fixed for the subscription lifetime).
80    columns: Vec<String>,
81    /// Serialized (JSON) row key → row data, representing the result set at
82    /// the end of the last commit. Used to diff against the new result.
83    prev_row_map: std::collections::HashMap<String, Vec<Option<Value>>>,
84    /// Weak pointer to the subscriber queue; dead Weak → subscription dropped.
85    inner: std::sync::Weak<SubInner>,
86}
87
88/// A post-commit mutation notification.
89///
90/// Emitted from `log_then_apply` after the WAL append, fsync, and
91/// in-memory `apply` all succeed. Never emitted for rejected operations
92/// (validation errors, [`GraphError::RuleOwned`], duplicate keys, no-op
93/// deletes/removes). Event payloads carry user keys and rule names, never
94/// internal ids.
95///
96/// **Replay:** [`GraphDb::open`] / [`GraphDb::open_with`] replay the WAL via
97/// `apply` only. Emission lives exclusively in `log_then_apply`, so
98/// recovery is silent even if a sink were installed (it cannot be: the
99/// sink is in-memory and set after open).
100///
101/// **Ordering:** a `Batch` WAL frame emits one event per inner record, then
102/// [`MutationEvent::BatchApplied`]. An ingest commit emits those same inner
103/// events, then [`MutationEvent::Ingested`] (not `BatchApplied`). An empty
104/// or all-noop batch writes no WAL and emits nothing (including no summary).
105///
106/// **Derived edges:** rule-created or retracted edges are not individually
107/// evented — they are recoverable from the triggering mutation plus the live
108/// rule set. Only the triggering record is emitted.
109///
110/// **Wire form:** externally tagged snake_case JSON
111/// (`{"node_inserted":{"label":"A","key":"k"}}`).
112#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
113#[serde(rename_all = "snake_case")]
114pub enum MutationEvent {
115    NodeInserted {
116        label: String,
117        key: String,
118    },
119    PropSet {
120        key: String,
121        field: String,
122    },
123    PropRemoved {
124        key: String,
125        field: String,
126    },
127    EdgeInserted {
128        edge_type: String,
129        src: String,
130        dst: String,
131    },
132    EdgeDeleted {
133        edge_type: String,
134        src: String,
135        dst: String,
136    },
137    NodeDeleted {
138        key: String,
139    },
140    RuleCreated {
141        name: String,
142    },
143    RuleDeleted {
144        name: String,
145    },
146    RuleRebuilt {
147        name: String,
148    },
149    BatchApplied {
150        ops: usize,
151    },
152    Ingested {
153        label: String,
154        inserted: usize,
155    },
156}
157
158fn event_from_record(rec: &WalRecord, intern: &Interner, ids: &IdMap) -> Option<MutationEvent> {
159    match rec {
160        WalRecord::InsertNode { label, key, .. } => Some(MutationEvent::NodeInserted {
161            label: label.clone(),
162            key: key.clone(),
163        }),
164        WalRecord::InsertNodeId { label, key, .. } => Some(MutationEvent::NodeInserted {
165            label: intern.resolve(*label)?.to_string(),
166            key: key.clone(),
167        }),
168        WalRecord::SetProp { key, field, .. } => Some(MutationEvent::PropSet {
169            key: key.clone(),
170            field: field.clone(),
171        }),
172        WalRecord::SetPropId { id, field, .. } => Some(MutationEvent::PropSet {
173            key: ids.key_of(*id)?.to_string(),
174            field: intern.resolve(*field)?.to_string(),
175        }),
176        WalRecord::RemoveProp { key, field } => Some(MutationEvent::PropRemoved {
177            key: key.clone(),
178            field: field.clone(),
179        }),
180        WalRecord::InsertEdge {
181            edge_type,
182            src_key,
183            dst_key,
184        } => Some(MutationEvent::EdgeInserted {
185            edge_type: edge_type.clone(),
186            src: src_key.clone(),
187            dst: dst_key.clone(),
188        }),
189        WalRecord::InsertEdgeId { etype, src, dst } => Some(MutationEvent::EdgeInserted {
190            edge_type: intern.resolve(*etype)?.to_string(),
191            src: ids.key_of(*src)?.to_string(),
192            dst: ids.key_of(*dst)?.to_string(),
193        }),
194        WalRecord::DeleteEdge {
195            edge_type,
196            src_key,
197            dst_key,
198        } => Some(MutationEvent::EdgeDeleted {
199            edge_type: edge_type.clone(),
200            src: src_key.clone(),
201            dst: dst_key.clone(),
202        }),
203        WalRecord::DeleteNode { key } => Some(MutationEvent::NodeDeleted { key: key.clone() }),
204        WalRecord::CreateRule { def_bytes } => {
205            let def: RuleDef = decode_rule_def(def_bytes).ok()?;
206            Some(MutationEvent::RuleCreated { name: def.name })
207        }
208        WalRecord::DeleteRule { name } => Some(MutationEvent::RuleDeleted { name: name.clone() }),
209        WalRecord::RebuildRule { name } => Some(MutationEvent::RuleRebuilt { name: name.clone() }),
210        WalRecord::Batch(_)
211        | WalRecord::CreateView { .. }
212        | WalRecord::DeleteView { .. }
213        | WalRecord::EnableFulltext { .. }
214        | WalRecord::DisableFulltext { .. }
215        | WalRecord::Intern { .. }
216        // History markers are no-ops for mutation events — they carry no new
217        // state and rules re-derive deterministically on replay.
218        | WalRecord::DerivedEdgeAdded { .. }
219        | WalRecord::DerivedEdgeRetracted { .. }
220        // RenameNode carries no node/edge count change; no special event.
221        | WalRecord::RenameNode { .. } => None,
222    }
223}
224
225/// Database-wide counters plus per-rule budget/fire stats.
226#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
227pub struct Stats {
228    pub nodes_live: usize,
229    pub nodes_tombstoned: usize,
230    pub edges: u64,
231    pub rules: Vec<RuleStats>,
232}
233
234/// One rule's provenance size, trip latch, and fire counter.
235///
236/// `tripped` is a one-way latch: once set, the engine adds no new edges for
237/// that rule until [`GraphDb::rebuild_rule`] (and only if the full desired
238/// set then fits). `fires` counts `on_node_changed` evaluations plus
239/// backfill/rebuild participant ticks (rebuild counts even when it is a
240/// provenance no-op).
241#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
242pub struct RuleStats {
243    pub name: String,
244    pub edges: u64,
245    pub tripped: bool,
246    pub fires: u64,
247    /// Whether this rule uses the approximate IVF-Flat candidate path.
248    pub approximate: bool,
249}
250
251/// Wire summary of a [`Predicate`]. JSON only — `Explanation` is never
252/// bincode-persisted (WAL/snapshots store `RuleDef` bytes, not this type).
253#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
254pub struct PredicateSummary {
255    pub kind: String,
256    pub fields: Vec<String>,
257    pub min: Option<f64>,
258    pub tolerance: Option<f64>,
259    pub km: Option<f64>,
260    pub parts: Option<Vec<PredicateSummary>>,
261    /// True when the owning rule has `approximate=true` (IVF-Flat candidate path).
262    /// Always false for predicates reported without rule context (sub-predicates in `parts`).
263    #[serde(default)]
264    pub approximate: bool,
265}
266
267impl From<&Predicate> for PredicateSummary {
268    fn from(p: &Predicate) -> Self {
269        match p {
270            Predicate::KeyMatch { field } => PredicateSummary {
271                kind: "key_match".into(),
272                fields: vec![field.clone()],
273                min: None,
274                tolerance: None,
275                km: None,
276                parts: None,
277                approximate: false,
278            },
279            Predicate::FieldEqual { field } => PredicateSummary {
280                kind: "field_equal".into(),
281                fields: vec![field.clone()],
282                min: None,
283                tolerance: None,
284                km: None,
285                parts: None,
286                approximate: false,
287            },
288            Predicate::Overlap { field, min } => PredicateSummary {
289                kind: "overlap".into(),
290                fields: vec![field.clone()],
291                min: Some(*min),
292                tolerance: None,
293                km: None,
294                parts: None,
295                approximate: false,
296            },
297            Predicate::NumericWithin { field, tolerance } => PredicateSummary {
298                kind: "numeric_within".into(),
299                fields: vec![field.clone()],
300                min: None,
301                tolerance: Some(*tolerance),
302                km: None,
303                parts: None,
304                approximate: false,
305            },
306            Predicate::GeoRadius { field, km } => PredicateSummary {
307                kind: "geo_radius".into(),
308                fields: vec![field.clone()],
309                min: None,
310                tolerance: None,
311                km: Some(*km),
312                parts: None,
313                approximate: false,
314            },
315            Predicate::VectorSimilar { field, min } => PredicateSummary {
316                kind: "vector_similar".into(),
317                fields: vec![field.clone()],
318                min: Some(*min),
319                tolerance: None,
320                km: None,
321                parts: None,
322                approximate: false,
323            },
324            Predicate::All(inner) => {
325                let parts: Vec<PredicateSummary> = inner.iter().map(Self::from).collect();
326                let mut fields = Vec::new();
327                for part in &parts {
328                    for f in &part.fields {
329                        if !fields.contains(f) {
330                            fields.push(f.clone());
331                        }
332                    }
333                }
334                PredicateSummary {
335                    kind: "all".into(),
336                    fields,
337                    min: None,
338                    tolerance: None,
339                    km: None,
340                    parts: Some(parts),
341                    approximate: false,
342                }
343            }
344            Predicate::Any(inner) => {
345                let parts: Vec<PredicateSummary> = inner.iter().map(Self::from).collect();
346                let mut fields = Vec::new();
347                for part in &parts {
348                    for f in &part.fields {
349                        if !fields.contains(f) {
350                            fields.push(f.clone());
351                        }
352                    }
353                }
354                PredicateSummary {
355                    kind: "any".into(),
356                    fields,
357                    min: None,
358                    tolerance: None,
359                    km: None,
360                    parts: Some(parts),
361                    approximate: false,
362                }
363            }
364        }
365    }
366}
367
368/// Snapshot of a live node's key, label, and columnar properties.
369///
370/// `props` is a [`BTreeMap`] so field order is deterministic (sorted by name)
371/// regardless of insert order or the columnar store's `HashMap` iteration.
372///
373/// Deliberately does not derive `Serialize`: `Value`'s serde form is
374/// internally tagged. Wire JSON is built by `value_to_json` in the server.
375#[derive(Debug, Clone, PartialEq)]
376pub struct NodeInfo {
377    pub key: String,
378    pub label: String,
379    pub props: BTreeMap<String, Value>,
380}
381
382/// Counts returned by [`GraphDb::delete_node`].
383#[derive(Debug, Clone, PartialEq, Eq, Default)]
384pub struct DeleteReport {
385    /// Number of manual (user-inserted) edges removed.
386    pub manual_edges: u64,
387    /// Number of derived (rule-owned) edges retracted.
388    pub derived_edges: u64,
389}
390
391/// One directed edge incident on a node, with provenance membership.
392///
393/// `derived` is true iff `(edge_type, src, dst)` is in the rule engine's
394/// Plan-8 `by_node` provenance index.
395#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
396pub struct EdgeInfo {
397    pub edge_type: String,
398    pub src_key: String,
399    pub dst_key: String,
400    pub derived: bool,
401}
402
403/// An edge with mask-aware endpoint visibility.
404///
405/// Returned by [`GraphDb::node_edges_masked`] in [`crate::mask::MaskMode::Stub`]
406/// mode — hidden endpoints carry `*_restricted: true`.
407#[derive(Debug, Clone, PartialEq, Eq)]
408pub struct MaskedEdge {
409    pub edge_type: String,
410    pub src_key: String,
411    /// `true` when `src_key` is in the DB but hidden from the mask.
412    pub src_restricted: bool,
413    pub dst_key: String,
414    /// `true` when `dst_key` is in the DB but hidden from the mask.
415    pub dst_restricted: bool,
416    pub derived: bool,
417}
418
419/// Result of a mask-aware node lookup via [`GraphDb::node_info_masked`].
420///
421/// `None` from that method means the key does not exist (→ 404).
422/// `Some(Restricted)` is only produced when `mask.mode() == MaskMode::Stub`.
423#[derive(Debug, PartialEq)]
424pub enum MaskedNodeResult {
425    Visible(NodeInfo),
426    /// Node exists in the DB but is hidden from this mask.
427    Restricted,
428}
429
430/// One rule-owned edge between two nodes, with the rule name, edge type,
431/// direction (src_key → dst_key), and weight if the rule stores one.
432#[derive(Debug, Clone, PartialEq, Serialize)]
433pub struct Explanation {
434    pub rule: String,
435    pub edge_type: String,
436    pub src_key: String,
437    pub dst_key: String,
438    pub weight: Option<f64>,
439    pub predicate: PredicateSummary,
440}
441
442/// Report returned by [`GraphDb::backup_to`].
443#[derive(Debug, Clone)]
444pub struct BackupReport {
445    /// Filenames copied into the destination directory (sorted ascending).
446    pub files: Vec<String>,
447    /// Total bytes written across all copied files.
448    pub bytes: u64,
449    /// `true` when the destination opened cleanly and passed post-copy checks.
450    ///
451    /// For stores that have a `snapshot.bin` this means: all V8 section CRCs
452    /// matched **and** the destination opened without error.
453    ///
454    /// For WAL-only stores (no `snapshot.bin`) there is no snapshot to
455    /// CRC-check; `verified` is `true` when the destination opened and
456    /// replayed the WAL without error (record-level checksums in the WAL
457    /// provide the integrity signal, not section CRCs).
458    pub verified: bool,
459}
460
461/// One directed edge in export form, with optional rule attribution for derived edges.
462///
463/// Returned by [`GraphDb::all_edges_for_export`].
464#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
465pub struct ExportEdge {
466    pub edge_type: String,
467    pub src: String,
468    pub dst: String,
469    pub derived: bool,
470    /// Rule name that created this edge, if derived. `None` for manual edges.
471    pub rule: Option<String>,
472}
473
474/// Construct the standard write-query result set (columns: created, properties_set, deleted).
475fn write_result_set() -> ResultSet {
476    ResultSet::new(vec![
477        "created".into(),
478        "properties_set".into(),
479        "deleted".into(),
480    ])
481}
482
483fn resolve_merge_set_value(op: &Operand, params: &BTreeMap<String, Value>) -> Result<Value> {
484    match op {
485        Operand::Lit(v) => Ok(v.clone()),
486        Operand::Param(name) => params
487            .get(name)
488            .cloned()
489            .ok_or_else(|| GraphError::QueryError {
490                detail: format!("missing parameter `{name}`"),
491            }),
492        _ => Err(GraphError::QueryError {
493            detail: "ON CREATE/ON MATCH SET value must be a literal or $parameter".into(),
494        }),
495    }
496}
497
498fn operand_node_vars(op: &Operand, out: &mut Vec<String>) {
499    match op {
500        Operand::Prop { var, .. } | Operand::Var(var) => {
501            if !out.contains(var) {
502                out.push(var.clone());
503            }
504        }
505        Operand::FuncCall { args, .. } => {
506            for arg in args {
507                operand_node_vars(arg, out);
508            }
509        }
510        Operand::BinArith { left, right, .. } => {
511            operand_node_vars(left, out);
512            operand_node_vars(right, out);
513        }
514        Operand::Lit(_) | Operand::Param(_) => {}
515    }
516}
517
518fn ret_node_vars(items: &[RetItem]) -> Vec<String> {
519    let mut out = Vec::new();
520    for item in items {
521        match &item.value {
522            RetVal::Var(v) | RetVal::Prop { var: v, .. } => {
523                if !out.contains(v) {
524                    out.push(v.clone());
525                }
526            }
527            RetVal::FuncCall { args, .. } => {
528                for arg in args {
529                    operand_node_vars(arg, &mut out);
530                }
531            }
532            RetVal::ScalarExpr(op) => operand_node_vars(op, &mut out),
533            RetVal::Agg { .. } => {}
534        }
535    }
536    out
537}
538
539fn add_var(out: &mut Vec<String>, v: &str) {
540    if !out.iter().any(|x| x == v) {
541        out.push(v.to_string());
542    }
543}
544
545fn pattern_node_vars(pats: &[Pattern]) -> Vec<String> {
546    let mut out = Vec::new();
547    for p in pats {
548        if let Some(v) = &p.start.var {
549            add_var(&mut out, v);
550        }
551        for (_, dest) in &p.chain {
552            if let Some(v) = &dest.var {
553                add_var(&mut out, v);
554            }
555        }
556    }
557    out
558}
559
560fn pattern_rel_vars(pats: &[Pattern]) -> Vec<String> {
561    let mut out = Vec::new();
562    for p in pats {
563        for (rel, _) in &p.chain {
564            if rel.hops.is_none() {
565                if let Some(v) = &rel.var {
566                    add_var(&mut out, v);
567                }
568            }
569        }
570    }
571    out
572}
573
574fn rel_type_alias(var: &str) -> String {
575    format!("__rt_{var}")
576}
577
578fn ret_column_name(item: &RetItem) -> String {
579    if let Some(alias) = &item.alias {
580        return alias.clone();
581    }
582    match &item.value {
583        RetVal::Var(v) => v.clone(),
584        RetVal::Prop { var, field } => format!("{var}.{field}"),
585        RetVal::FuncCall { name, args } => {
586            let arg_strs: Vec<String> = args
587                .iter()
588                .map(|a| match a {
589                    Operand::Var(v) => v.clone(),
590                    Operand::Prop { var, field } => format!("{var}.{field}"),
591                    Operand::Lit(_) => "<lit>".to_string(),
592                    Operand::Param(p) => format!("${p}"),
593                    Operand::FuncCall { name: n, .. } => format!("{n}(...)"),
594                    Operand::BinArith { .. } => "<arith>".to_string(),
595                })
596                .collect();
597            format!("{name}({})", arg_strs.join(", "))
598        }
599        RetVal::ScalarExpr(_) => "<expr>".to_string(),
600        RetVal::Agg { .. } => "<agg>".to_string(),
601    }
602}
603
604fn eval_set_return_operand<F: Fs>(
605    db: &GraphDb<F>,
606    match_rs: &ResultSet,
607    row: usize,
608    rel_vars: &[String],
609    op: &Operand,
610    params: &BTreeMap<String, Value>,
611) -> Result<Option<Value>> {
612    match op {
613        Operand::Lit(v) => Ok(Some(v.clone())),
614        Operand::Param(name) => params.get(name).cloned().ok_or_else(|| GraphError::QueryError {
615            detail: format!("missing parameter `{name}`"),
616        }).map(Some),
617        Operand::Var(name) if rel_vars.iter().any(|r| r == name) => Err(GraphError::QueryError {
618            detail: format!(
619                "cannot return relationship variable '{name}' bare; return its properties ({name}.field) instead"
620            ),
621        }),
622        Operand::Var(name) => Ok(match_rs.get(row, name).cloned()),
623        Operand::Prop { var, field } => {
624            if rel_vars.iter().any(|r| r == var) {
625                return Ok(None);
626            }
627            let Some(Value::Str(key)) = match_rs.get(row, var) else {
628                return Ok(None);
629            };
630            Ok(db.get_prop(key, field))
631        }
632        Operand::FuncCall { name, args } => {
633            eval_set_return_func(db, match_rs, row, rel_vars, name, args, params)
634        }
635        Operand::BinArith { op, left, right } => {
636            let lv = eval_set_return_operand(db, match_rs, row, rel_vars, left, params)?;
637            let rv = eval_set_return_operand(db, match_rs, row, rel_vars, right, params)?;
638            eval_set_return_arith(op, lv, rv)
639        }
640    }
641}
642
643fn eval_set_return_arith(
644    op: &ArithOp,
645    lv: Option<Value>,
646    rv: Option<Value>,
647) -> Result<Option<Value>> {
648    match (lv, rv) {
649        (None, _) | (_, None) => Ok(None),
650        (Some(Value::Int(a)), Some(Value::Int(b))) => {
651            let result = match op {
652                ArithOp::Sub => a.saturating_sub(b),
653                ArithOp::Mul => a.saturating_mul(b),
654                ArithOp::Add => a.saturating_add(b),
655                ArithOp::Div => {
656                    if b == 0 {
657                        return Err(GraphError::QueryError {
658                            detail: "division by zero".into(),
659                        });
660                    }
661                    a.checked_div(b).unwrap_or(i64::MAX)
662                }
663            };
664            Ok(Some(Value::Int(result)))
665        }
666        (Some(lv), Some(rv)) => {
667            let a = match &lv {
668                Value::Float(f) => *f,
669                Value::Int(i) => *i as f64,
670                _ => {
671                    return Err(GraphError::QueryError {
672                        detail: format!("arithmetic operand must be numeric, got {lv:?}"),
673                    })
674                }
675            };
676            let b = match &rv {
677                Value::Float(f) => *f,
678                Value::Int(i) => *i as f64,
679                _ => {
680                    return Err(GraphError::QueryError {
681                        detail: format!("arithmetic operand must be numeric, got {rv:?}"),
682                    })
683                }
684            };
685            let result = match op {
686                ArithOp::Sub => a - b,
687                ArithOp::Mul => a * b,
688                ArithOp::Add => a + b,
689                ArithOp::Div => {
690                    if b == 0.0 {
691                        return Err(GraphError::QueryError {
692                            detail: "division by zero".into(),
693                        });
694                    }
695                    a / b
696                }
697            };
698            Ok(Some(Value::Float(result)))
699        }
700    }
701}
702
703fn eval_set_return_func<F: Fs>(
704    db: &GraphDb<F>,
705    match_rs: &ResultSet,
706    row: usize,
707    rel_vars: &[String],
708    name: &str,
709    args: &[Operand],
710    params: &BTreeMap<String, Value>,
711) -> Result<Option<Value>> {
712    let norm = name.to_ascii_lowercase();
713    if norm == "type" {
714        if args.len() != 1 {
715            return Err(GraphError::QueryError {
716                detail: format!("type() requires exactly 1 argument, got {}", args.len()),
717            });
718        }
719        let Operand::Var(rel) = &args[0] else {
720            return Err(GraphError::QueryError {
721                detail: "type() argument must be a relationship variable (e.g. type(r))".into(),
722            });
723        };
724        return Ok(match_rs.get(row, &rel_type_alias(rel)).cloned());
725    }
726    let mut vals = Vec::with_capacity(args.len());
727    for arg in args {
728        vals.push(eval_set_return_operand(
729            db, match_rs, row, rel_vars, arg, params,
730        )?);
731    }
732    match norm.as_str() {
733        "tolower" => {
734            if vals.len() != 1 {
735                return Err(GraphError::QueryError {
736                    detail: format!("toLower() requires exactly 1 argument, got {}", vals.len()),
737                });
738            }
739            Ok(vals[0].clone().map(|val| match val {
740                Value::Str(s) => Value::Str(s.to_ascii_lowercase()),
741                other => other,
742            }))
743        }
744        "toupper" => {
745            if vals.len() != 1 {
746                return Err(GraphError::QueryError {
747                    detail: format!("toUpper() requires exactly 1 argument, got {}", vals.len()),
748                });
749            }
750            Ok(vals[0].clone().map(|val| match val {
751                Value::Str(s) => Value::Str(s.to_ascii_uppercase()),
752                other => other,
753            }))
754        }
755        "size" => match vals.first().cloned().flatten() {
756            None => Ok(None),
757            Some(Value::Str(s)) => Ok(Some(Value::Int(s.len() as i64))),
758            Some(Value::List(items)) => Ok(Some(Value::Int(items.len() as i64))),
759            Some(_) => Ok(None),
760        },
761        "coalesce" => Ok(vals.into_iter().flatten().next()),
762        "abs" => match vals.first().cloned().flatten() {
763            None => Ok(None),
764            Some(Value::Int(n)) => Ok(Some(Value::Int(n.saturating_abs()))),
765            Some(Value::Float(f)) => Ok(Some(Value::Float(f.abs()))),
766            Some(_) => Ok(None),
767        },
768        "round" => match vals.first().cloned().flatten() {
769            None => Ok(None),
770            Some(Value::Float(f)) => Ok(Some(Value::Float(f.round()))),
771            Some(Value::Int(n)) => Ok(Some(Value::Int(n))),
772            Some(_) => Ok(None),
773        },
774        _ => Err(GraphError::QueryError {
775            detail: format!(
776                "unknown function `{name}`; supported: toLower, toUpper, size, coalesce, type, abs, round, textMatches"
777            ),
778        }),
779    }
780}
781
782fn eval_set_return_item<F: Fs>(
783    db: &GraphDb<F>,
784    match_rs: &ResultSet,
785    row: usize,
786    rel_vars: &[String],
787    item: &RetItem,
788    params: &BTreeMap<String, Value>,
789) -> Result<Option<Value>> {
790    match &item.value {
791        RetVal::Var(v) => eval_set_return_operand(
792            db,
793            match_rs,
794            row,
795            rel_vars,
796            &Operand::Var(v.clone()),
797            params,
798        ),
799        RetVal::Prop { var, field } => eval_set_return_operand(
800            db,
801            match_rs,
802            row,
803            rel_vars,
804            &Operand::Prop {
805                var: var.clone(),
806                field: field.clone(),
807            },
808            params,
809        ),
810        RetVal::FuncCall { name, args } => {
811            eval_set_return_func(db, match_rs, row, rel_vars, name, args, params)
812        }
813        RetVal::ScalarExpr(op) => eval_set_return_operand(db, match_rs, row, rel_vars, op, params),
814        RetVal::Agg { .. } => Err(GraphError::QueryError {
815            detail: "aggregates are not supported in MATCH … SET … RETURN".into(),
816        }),
817    }
818}
819
820/// Project user RETURN from original MATCH rows after SET. No rematch.
821fn project_set_return_rows<F: Fs>(
822    db: &GraphDb<F>,
823    rel_vars: &[String],
824    match_rs: &ResultSet,
825    returns: &[RetItem],
826    params: &BTreeMap<String, Value>,
827) -> Result<ResultSet> {
828    let columns: Vec<String> = returns.iter().map(ret_column_name).collect();
829    let mut out = ResultSet::new(columns);
830    for row in 0..match_rs.len() {
831        let mut cells = Vec::with_capacity(returns.len());
832        for item in returns {
833            cells.push(eval_set_return_item(
834                db, match_rs, row, rel_vars, item, params,
835            )?);
836        }
837        out.push_row(cells);
838    }
839    Ok(out)
840}
841
842/// Single construction point for a `GraphMut` view over the split-borrowed graph fields.
843/// Callers use `std::mem::take` on the engine before calling this, then restore it after.
844/// Extract a `Vec<f64>` from a `Value::List` whose items are all numeric.
845/// Returns `None` for non-list values or lists with non-numeric elements.
846fn value_as_float_list(v: &Value) -> Option<Vec<f64>> {
847    match v {
848        Value::List(items) => items
849            .iter()
850            .map(|item| match item {
851                Value::Float(f) => Some(*f),
852                Value::Int(i) => Some(*i as f64),
853                _ => None,
854            })
855            .collect(),
856        _ => None,
857    }
858}
859
860fn make_graph_mut<'a>(
861    ids: &'a IdMap,
862    syms: &'a mut Interner,
863    labels: &'a [u32],
864    props: core_storage::v8::seam::ColumnsView<'a>,
865    topo: &'a mut Topology,
866    edge_props: &'a mut EdgeProps,
867) -> GraphMut<'a> {
868    GraphMut {
869        ids,
870        syms,
871        labels,
872        props,
873        topo,
874        edge_props,
875    }
876}
877
878/// Build a `ColumnsView` from the disjoint `props` overlay and optional V8 base.
879///
880/// Takes explicit field references rather than `&self` so the caller can hold
881/// simultaneous mutable borrows of other fields (e.g. `syms`, `topo`).
882fn build_props_view<'a>(
883    props: &'a ColumnStore,
884    base: &'a Option<std::sync::Arc<core_storage::v8::MappedBase>>,
885) -> core_storage::v8::seam::ColumnsView<'a> {
886    match base {
887        None => core_storage::v8::seam::ColumnsView::owned(props),
888        Some(b) => {
889            let archived = b
890                .columns()
891                .expect("base columns section bounds validated at open");
892            core_storage::v8::seam::ColumnsView::with_base(props, archived)
893        }
894    }
895}
896
897fn build_topo_view<'a>(
898    overlay: &'a Topology,
899    base: &'a Option<std::sync::Arc<core_storage::v8::MappedBase>>,
900) -> core_storage::v8::seam::TopologyView<'a> {
901    match base {
902        None => core_storage::v8::seam::TopologyView::owned(overlay),
903        Some(b) => {
904            let archived_csr = b
905                .topology()
906                .expect("base topology section bounds validated at open");
907            core_storage::v8::seam::TopologyView::with_base(overlay, archived_csr)
908        }
909    }
910}
911
912/// When [`GraphDb`] calls `Fs::sync` after a WAL append.
913///
914/// Default is [`Strict`](FsyncPolicy::Strict): every `log_then_apply_with`
915/// fsyncs (single `insert_node` / `set_prop`). Ingest and `write_batch`
916/// emit one `WalRecord::Batch` and fsync once at that frame (Batched).
917/// [`Relaxed`](FsyncPolicy::Relaxed) skips WAL sync; [`GraphDb::snapshot`]
918/// is still durable via `write_atomic`. Crash-recovery DST stays Strict.
919#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
920pub enum FsyncPolicy {
921    /// Every WAL commit calls `fs.sync` (today's behavior).
922    #[default]
923    Strict,
924    /// Sync only at a `Batch` frame end. Single-op path stays Strict unless
925    /// this policy is set on the database.
926    Batched,
927    /// Never call `fs.sync`. [`GraphDb::snapshot`] still syncs via `write_atomic`.
928    Relaxed,
929}
930
931/// A precondition for a compare-and-set batch write.
932///
933/// All preconditions in a [`GraphDb::write_batch_cas`] or
934/// [`crate::SharedDb::submit_batch_cas`] call are checked atomically before
935/// any operation in the batch is applied.  If any precondition fails, the
936/// entire batch is rejected with [`GraphError::CasConflict`] and no WAL frame
937/// is written.
938///
939/// # Touch definition
940///
941/// A node's last-change commit (`last_changed`) is updated when any of the
942/// following state-changing WAL records touch it:
943///
944/// - `InsertNode` / `InsertNodeId` — the newly-inserted node.
945/// - `SetProp` / `SetPropId` / `RemoveProp` — the property-bearing node.
946/// - `InsertEdge` / `InsertEdgeId` / `DeleteEdge` — **both** src and dst
947///   endpoints (an edge change touches both sides).
948/// - `DeleteNode` — the node is tombstoned; `last_changed` returns `None`
949///   for deleted keys so the pre-deletion entry is never observed.
950///
951/// History markers (`DerivedEdgeAdded` / `DerivedEdgeRetracted`) are
952/// state no-ops.  The underlying mutation that triggered rule firing already
953/// updated the relevant nodes' last-change entries.  Rule-management records
954/// (`CreateRule`, `DeleteRule`, `RebuildRule`) and view/full-text declarations
955/// do not touch any node's last-change.
956#[derive(Debug, Clone, PartialEq, Eq)]
957pub enum Precondition {
958    /// The node's last-change commit must equal `expected`.
959    ///
960    /// Fails with [`GraphError::CasConflict`] when:
961    /// - The node does not exist (`last_changed` returns `None`), or
962    /// - The recorded commit seq does not match `expected`.
963    NodeUnchangedSince { key: String, expected: u64 },
964    /// The node must not exist (not inserted, or already deleted).
965    ///
966    /// Fails with [`GraphError::CasConflict`] (expected=`u64::MAX`,
967    /// actual=`last_changed(key).unwrap_or(0)`) when the node is live.
968    NodeAbsent { key: String },
969}
970
971pub struct GraphDb<F: Fs> {
972    fs: F,
973    ids: IdMap,
974    syms: Interner,
975    topo: Topology,
976    props: ColumnStore,
977    labels: Vec<u32>, // node id -> label symbol
978    edge_props: EdgeProps,
979    engine: RuleEngine,
980    view_store: ViewStore,
981    /// Incremental inverted index for full-text-lite search.
982    /// Rebuild-on-open: populated from WAL replay + rebuild_all at open end.
983    fulltext: FulltextIndex,
984    event_sink: Option<Box<dyn Fn(MutationEvent) + Send + Sync>>,
985    /// WAL fsync cadence. Default [`FsyncPolicy::Strict`].
986    fsync: FsyncPolicy,
987    /// Monotonically increasing per-commit counter.  A single `log_then_apply_with`
988    /// call increments this once; all events emitted from that call share the same
989    /// `commit_seq` value.
990    commit_seq: u64,
991    /// RBAC role definitions loaded from `roles.json` at open.
992    ///
993    /// `Some(roles)` — loaded successfully (may be empty when no roles are defined).
994    /// `None` — `roles.json` was present but corrupt; `mask_for_role` returns
995    /// `Err` for any request (fail-loud, never silently grant empty visibility).
996    roles: Option<Vec<RoleDef>>,
997    /// Live subscriptions.  Entries with a dead `Weak` are pruned on the next
998    /// distribute_events call.
999    subscriptions: Vec<SubEntry>,
1000    /// Live query subscriptions. Re-executed on every commit when non-empty.
1001    /// Dead `Weak` entries are pruned inside `distribute_events`.
1002    query_subscriptions: Vec<QuerySubEntry>,
1003    /// Queue capacity for new subscriptions created by this db.  Default is
1004    /// [`DEFAULT_SUB_CAPACITY`]; can be overridden via [`set_sub_capacity`]
1005    /// to test Lagged behaviour with small queues.
1006    sub_capacity: usize,
1007    /// True for as-of instances opened via [`GraphDb::open_at`].
1008    /// Every mutation method and `snapshot()` returns [`GraphError::ReadOnly`]
1009    /// when this flag is set.
1010    read_only: bool,
1011    /// Total WAL commit count at the time [`open_at`] was called.
1012    /// 0 for normal (non-as-of) instances.
1013    total_wal_commits: u64,
1014    /// Immutable mmap-backed base snapshot (V8).  When `Some`, `self.topo` is
1015    /// the WAL-replay overlay (empty at open time, populated by apply()) and
1016    /// reads go through a merged `TopologyView`.  `self.props` is always
1017    /// fully materialized (base + WAL replay) for HNSW/IVF and view compat.
1018    base: Option<Arc<core_storage::v8::MappedBase>>,
1019    // ── MVCC epoch reader state ───────────────────────────────────────────────
1020    /// Most-recent full overlay clone.  Initialized at end of `open_with` /
1021    /// `open_at_with`; refreshed every `FOLD_EVERY_K` commits.
1022    /// `None` only between struct creation and the first fold.
1023    fold_overlay: Option<Arc<crate::reader::FrozenOverlay>>,
1024    /// Per-commit deltas accumulated since the last fold.
1025    delta_tail: Vec<Arc<crate::reader::CommitDelta>>,
1026    /// How many commits have occurred since the last fold.
1027    commits_since_fold: usize,
1028    /// When true, `log_then_apply_with` buffers event notifications instead of
1029    /// firing them immediately.  Used by the group-commit drain thread to defer
1030    /// events until after the group fsync (R2: durability before notification).
1031    /// Cleared to false once the drain thread flushes or discards the buffer.
1032    defer_events: bool,
1033    /// Buffered events accumulated while `defer_events` is true.
1034    deferred_events: Vec<DeferredEvent>,
1035    /// Set to true by the group-commit drain thread when a group fsync fails
1036    /// after WAL truncation.  All subsequent mutation attempts return an IO
1037    /// error until the database is reopened.
1038    degraded: bool,
1039    /// Set to `true` after `ensure_v8_base_sections_loaded` has read provenance,
1040    /// HNSW, and IVF sections from the mmap base into the engine's retained
1041    /// fields.  `false` on all opens until first use; always `true` for non-V8
1042    /// opens (base is None, fast-path sets flag immediately).
1043    v8_sections_loaded: std::sync::atomic::AtomicBool,
1044    /// Serializes the one-time section population in `ensure_v8_base_sections_loaded`.
1045    v8_sections_mutex: std::sync::Mutex<()>,
1046    /// Per-node last-change commit sequence.  `last_change[node_id] = seq` means
1047    /// the node was last modified by commit `seq`.
1048    ///
1049    /// Loaded from V8 section 11 at open; updated on every state-changing commit
1050    /// and WAL replay frame.  V5-V7 stores start with an empty map; pre-WAL-horizon
1051    /// nodes return `None` from `last_changed` until they are next mutated.
1052    ///
1053    /// See [`Precondition`] for the full touch definition.
1054    last_change: HashMap<u32, u64>,
1055    /// WAL archive retention policy set by [`set_wal_archive_retention`].
1056    /// `None` = unlimited (keep all archives); `Some(N)` = keep N newest archives,
1057    /// pruning older ones at snapshot time.  0 is treated as unlimited.
1058    wal_archive_retention: Option<u32>,
1059    /// Global frame index of the first commit that is still reachable through
1060    /// surviving archives.  Persisted to `wal.floor` sidecar when pruning occurs.
1061    /// Default 0 = all history reachable.
1062    wal_horizon_floor: u64,
1063    /// True when the surviving archive chain forms a continuous WAL history
1064    /// starting from the store's first commit (the genesis chain).
1065    ///
1066    /// `open_at` may replay archive-resident commits from empty state only when
1067    /// this flag is true AND `wal_horizon_floor == 0`.  Cleared whenever:
1068    ///   - a WAL-truncating snapshot (`keep_wal=false`) is taken after archives
1069    ///     already exist (breaks the chain for subsequent archives), or
1070    ///   - any archive is pruned (floor advances past zero).
1071    ///
1072    /// Persisted via the `wal.genesis` marker file; loaded from it at open.
1073    archive_genesis_chain: bool,
1074}
1075
1076/// One group of deferred event notifications, held until the group fsync
1077/// completes.  Replayed by [`GraphDb::flush_deferred_events`].
1078struct DeferredEvent {
1079    rec: core_storage::WalRecord,
1080    engine_deltas: Vec<EngineEdgeDelta>,
1081    seq: u64,
1082    ingest: Option<(String, usize)>,
1083}
1084
1085/// Options for [`GraphDb::open_with_options`].
1086#[derive(Clone, Copy, Debug)]
1087pub struct OpenOptions {
1088    /// Rewrite an old-format snapshot to the current VERSION after a
1089    /// successful load (default `true`). The old snapshot is kept as
1090    /// `snapshot.bin.bak` until the next clean open at the current version,
1091    /// at which point the `.bak` is deleted.
1092    ///
1093    /// Set to `false` to open a store without touching any on-disk files
1094    /// (useful for read-only inspection of a store at an older format).
1095    pub auto_migrate: bool,
1096}
1097
1098impl Default for OpenOptions {
1099    fn default() -> Self {
1100        Self { auto_migrate: true }
1101    }
1102}
1103
1104/// Write `bytes` to `snapshot.bin.bak` atomically with full fsync.
1105///
1106/// Uses [`RealFs::write_atomic`] which applies `F_FULLFSYNC` on macOS and
1107/// `sync_all` on other platforms, then renames the `.tmp` file into place and
1108/// syncs the directory entry. This is the only correct path for writing the
1109/// `.bak` — plain `std::fs::write + sync_all` misses both `F_FULLFSYNC` and
1110/// the directory sync.
1111pub fn write_snapshot_bak(dir: &std::path::Path, bytes: &[u8]) -> crate::Result<()> {
1112    use core_storage::fs::{FileId, Fs as _};
1113    RealFs::new(dir)
1114        .map_err(core_storage::GraphError::Io)?
1115        .write_atomic(FileId::SnapshotBak, bytes)
1116        .map_err(core_storage::GraphError::Io)
1117}
1118
1119/// Return the on-disk snapshot format version without decoding the full snapshot.
1120///
1121/// Reads only the 6-byte header (magic + version LE). Returns `None` when no
1122/// snapshot file exists (WAL-only store). Returns an error if the header is
1123/// malformed.
1124pub fn snapshot_version_at(dir: &std::path::Path) -> crate::Result<Option<u16>> {
1125    use std::io::Read as _;
1126    let path = dir.join("snapshot.bin");
1127    let mut header = [0u8; 6];
1128    let n = match std::fs::File::open(&path) {
1129        Ok(mut f) => f.read(&mut header).map_err(core_storage::GraphError::Io)?,
1130        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1131        Err(e) => return Err(core_storage::GraphError::Io(e)),
1132    };
1133    core_storage::snapshot::peek_version(&header[..n])
1134}
1135
1136/// Options for [`GraphDb::snapshot_with`].
1137#[derive(Debug, Clone, Default)]
1138pub struct SnapshotOptions {
1139    /// When `true`, the WAL is preserved after the snapshot write.
1140    /// Pre-snapshot commits remain reachable via [`GraphDb::open_at`].
1141    /// When `false` (the default), the WAL is truncated to a minimal
1142    /// baseline so cold-start replay stays fast.
1143    pub keep_wal: bool,
1144    /// When `true`, the current WAL is renamed to `wal.<commit_seq>.archive`
1145    /// before a fresh WAL baseline is written (history-preserving snapshot).
1146    ///
1147    /// This is the feature opt-in: `false` (the default) leaves the existing
1148    /// truncation / keep-wal behaviour byte-identical.  `archive_wal` takes
1149    /// precedence over `keep_wal` when both are set.
1150    ///
1151    /// Archives can be scanned by [`GraphDb::node_history`],
1152    /// [`GraphDb::edge_history`], [`GraphDb::was_linked`], and
1153    /// [`GraphDb::open_at`], extending the reachable history horizon across
1154    /// snapshot boundaries.
1155    pub archive_wal: bool,
1156}
1157
1158impl GraphDb<RealFs> {
1159    /// Open the database at `dir` with default options.
1160    ///
1161    /// Equivalent to `open_with_options(dir, OpenOptions::default())`.
1162    /// Old-format snapshots (V5, V6) are automatically migrated to the
1163    /// current version on a successful load (see [`OpenOptions::auto_migrate`]).
1164    pub fn open(dir: &std::path::Path) -> Result<Self> {
1165        Self::open_with_options(dir, OpenOptions::default())
1166    }
1167
1168    /// Open the database at `dir` with explicit options.
1169    ///
1170    /// When `opts.auto_migrate` is `true` (the default) and the on-disk
1171    /// snapshot is an older format version, this function:
1172    ///   1. Copies the current `snapshot.bin` to `snapshot.bin.bak` (atomic
1173    ///      + fsynced) before any modification.
1174    ///   2. Rewrites `snapshot.bin` at the current format version via
1175    ///      [`GraphDb::snapshot_with`] with `keep_wal: true` (WAL preserved).
1176    ///
1177    /// If migration fails the error is returned and the original files are
1178    /// intact (the `.bak` was written before the new snapshot was attempted).
1179    ///
1180    /// A clean open that finds the snapshot already at the current version
1181    /// deletes any leftover `.bak` file.
1182    ///
1183    /// WAL-only stores (no snapshot) are never auto-migrated on open.
1184    pub fn open_with_options(dir: &std::path::Path, opts: OpenOptions) -> Result<Self> {
1185        // Header-only peek — 6 bytes, no full decode.
1186        let snap_version = snapshot_version_at(dir)?;
1187
1188        // Full load: decode snapshot + replay WAL + rebuild indexes.
1189        let mut db = Self::open_with(RealFs::new(dir)?)?;
1190
1191        if opts.auto_migrate {
1192            match snap_version {
1193                Some(ver) if ver < core_storage::snapshot::VERSION => {
1194                    let _tm = std::time::Instant::now();
1195                    // Copy the original snapshot to .bak at OS level — no in-memory
1196                    // buffer required for a 2+ GiB file.
1197                    //
1198                    // Crash-safety: snapshot.bin remains intact (write_atomic inside
1199                    // snapshot_with uses a .tmp+rename) until the V8 write succeeds.
1200                    // A torn .bak on crash is acceptable because the original
1201                    // snapshot.bin is the authoritative source until after the rename.
1202                    std::fs::copy(dir.join("snapshot.bin"), dir.join("snapshot.bin.bak"))
1203                        .map_err(core_storage::GraphError::Io)?;
1204                    trace_migrate!("bak copy done", _tm);
1205                    // Rewrite snapshot at current version; keep WAL intact.
1206                    db.snapshot_with(SnapshotOptions {
1207                        keep_wal: true,
1208                        ..SnapshotOptions::default()
1209                    })?;
1210                    trace_migrate!("snapshot_with done", _tm);
1211                }
1212                Some(_) => {
1213                    // Already current version: remove any leftover .bak.
1214                    let bak = dir.join("snapshot.bin.bak");
1215                    if bak.exists() {
1216                        std::fs::remove_file(&bak).map_err(core_storage::GraphError::Io)?;
1217                    }
1218                }
1219                None => {
1220                    // WAL-only store — nothing to migrate on open.
1221                }
1222            }
1223        }
1224
1225        Ok(db)
1226    }
1227
1228    /// Open a read-only view of the database as it existed after `commit`.
1229    ///
1230    /// Commit indices are 0-based over the current WAL: commit 0 is the state
1231    /// after the first WAL frame, commit N-1 is the state after the N-th (most
1232    /// recent) frame.  Call [`GraphDb::open`] to read the full current state.
1233    ///
1234    /// **Replay base.** [`GraphDb::snapshot`] truncates the WAL when it runs,
1235    /// so as-of can only reach commits recorded in the current WAL (those
1236    /// written after the most recent snapshot, or all commits if no snapshot
1237    /// was ever taken).  Commit 0 in `open_at` always refers to the first
1238    /// frame in the WAL that exists on disk, not the first ever write to the
1239    /// database.  When the on-disk snapshot recorded that it truncated the
1240    /// WAL (V7, default `keep_wal: false`), it is loaded as the base state
1241    /// before frame replay, so the as-of view includes all pre-snapshot data.
1242    /// Snapshots written with `keep_wal: true` (and legacy V5/V6 snapshots)
1243    /// are ignored and replay is WAL-only, as before.
1244    ///
1245    /// **Read-only.** Every mutation method and `snapshot()` on the returned
1246    /// instance returns [`GraphError::ReadOnly`].  Queries, `explain()`, and
1247    /// `stats()` work normally.
1248    ///
1249    /// # Errors
1250    /// - [`GraphError::CommitOutOfRange`] if `commit >= wal_commit_count` (including
1251    ///   when the WAL is empty after a snapshot).
1252    pub fn open_at(dir: &std::path::Path, commit: u64) -> Result<Self> {
1253        Self::open_at_with(RealFs::new(dir)?, commit)
1254    }
1255}
1256
1257impl<F: Fs> GraphDb<F> {
1258    pub fn open_with(fs: F) -> Result<Self> {
1259        let mut db = Self {
1260            fs,
1261            ids: IdMap::new(),
1262            syms: Interner::new(),
1263            topo: Topology::new(),
1264            props: ColumnStore::new(),
1265            labels: Vec::new(),
1266            edge_props: EdgeProps::new(),
1267            engine: RuleEngine::new(),
1268            view_store: ViewStore::new(),
1269            fulltext: FulltextIndex::new(),
1270            event_sink: None,
1271            fsync: FsyncPolicy::Strict,
1272            commit_seq: 0,
1273            roles: Some(vec![]),
1274            subscriptions: Vec::new(),
1275            query_subscriptions: Vec::new(),
1276            sub_capacity: DEFAULT_SUB_CAPACITY,
1277            read_only: false,
1278            total_wal_commits: 0,
1279            base: None,
1280            fold_overlay: None,
1281            delta_tail: Vec::new(),
1282            commits_since_fold: 0,
1283            defer_events: false,
1284            deferred_events: Vec::new(),
1285            degraded: false,
1286            v8_sections_loaded: std::sync::atomic::AtomicBool::new(false),
1287            v8_sections_mutex: std::sync::Mutex::new(()),
1288            last_change: HashMap::new(),
1289            wal_archive_retention: None,
1290            wal_horizon_floor: 0,
1291            archive_genesis_chain: false,
1292        };
1293        db.wal_horizon_floor = db.fs.read_horizon_floor()?;
1294        db.archive_genesis_chain = db.fs.has_genesis_marker();
1295        // Opening cleanup: remove orphaned archives — archives whose frames all
1296        // fall below the horizon floor.  Orphans arise when a crash interrupted
1297        // the retention-prune sequence after the floor was written but before
1298        // all surplus archives were deleted.  Safe to delete: floor already
1299        // accounts for their frames.
1300        db.cleanup_orphaned_archives()?;
1301        let _t0 = std::time::Instant::now();
1302        // Peek 6 bytes to determine snapshot version without reading the full
1303        // file. For RealFs this is a true partial read (O(1)); for SimFs the
1304        // default impl reads all bytes and truncates (still correct).
1305        let snap_header = db.fs.read_prefix(FileId::Snapshot, 6)?;
1306        let is_v8 = snap_header.len() >= 6
1307            && &snap_header[0..4] == b"GDB1"
1308            && u16::from_le_bytes([snap_header[4], snap_header[5]])
1309                == core_storage::snapshot::VERSION_8;
1310        if is_v8 {
1311            // V8: map the file zero-copy (RealFs) or read full bytes (SimFs).
1312            // No 2.4GB heap Vec is allocated on RealFs.
1313            let mapped = Arc::new(
1314                if let Some(snap_path) = db.fs.snapshot_path() {
1315                    core_storage::v8::MappedBase::map(&snap_path)
1316                } else {
1317                    let snap_bytes = db.fs.read(FileId::Snapshot)?;
1318                    core_storage::v8::MappedBase::from_bytes(snap_bytes)
1319                }
1320                .map_err(|e| GraphError::Corrupt {
1321                    detail: format!("v8: mmap open: {e:?}"),
1322                })?,
1323            );
1324            db.restore_v8_base(Arc::clone(&mapped))?;
1325            trace_open!("restore_v8_base", _t0);
1326            db.base = Some(mapped);
1327            trace_open!("base assigned", _t0);
1328        } else if !snap_header.is_empty() {
1329            // Legacy V5-V7: full read required for decode.
1330            let snap_bytes = db.fs.read(FileId::Snapshot)?;
1331            if let Some(state) = core_storage::snapshot::decode(&snap_bytes)? {
1332                db.restore_snapshot_state(state)?;
1333            }
1334        }
1335        // else: snap_header is empty = no snapshot file, fresh store.
1336        //
1337        // Seed commit_seq from the highest seq persisted in last_change so that
1338        // WAL-replay frames (which start at commit_seq+1) always exceed any seq
1339        // already stored in the snapshot.  Without this, a db with one snapshot
1340        // commit would save last_change["a"]=1, then on reopen the first WAL
1341        // frame would replay at seq=1 again — colliding and making WAL-tail
1342        // mutations indistinguishable from the snapshot baseline.
1343        //
1344        // Safety invariant (seq-recycling):
1345        //   Recycled seqs (those below the seeded baseline) were NEVER stored in
1346        //   last_change because they belonged to a previous db lifetime — a new
1347        //   db starts at commit_seq=0 with an empty last_change.  Therefore no
1348        //   CAS precondition can carry a recycled seq as its `expected` value
1349        //   and accidentally match a live node's last_change entry.
1350        //
1351        // `expected:0` on a deleted-then-reinserted node:
1352        //   After deletion, last_changed() returns None; callers that call
1353        //   last_changed() and then use NodeUnchangedSince get None.unwrap_or(0)
1354        //   = 0.  The reinserted node gets seq > 0, so a subsequent CAS with
1355        //   expected=0 correctly conflicts.  The only way to observe actual=0 in
1356        //   a CasConflict would be a caller that invented expected=0 without ever
1357        //   calling last_changed() — unreachable via the documented API contract.
1358        if let Some(&max_seq) = db.last_change.values().max() {
1359            db.commit_seq = db.commit_seq.max(max_seq);
1360        }
1361        let bytes = db.fs.read(FileId::Wal)?;
1362        let (records, valid_len) = decode_all(&bytes);
1363        if valid_len < bytes.len() {
1364            db.fs.write_atomic(FileId::Wal, &bytes[..valid_len])?;
1365        }
1366        // WAL-present path: build indexes eagerly BEFORE replay so that the
1367        // first replayed record does not trigger the lazy-init guard (which
1368        // would call reindex_all_load_ivf on an empty graph, defeating the
1369        // point of restoring IVF/HNSW blobs from the snapshot).
1370        if !records.is_empty() {
1371            db.ensure_v8_base_sections_loaded();
1372            trace_open!("lazy sections loaded (WAL path)", _t0);
1373            db.engine.consume_retained_state_eager(
1374                &db.ids,
1375                &db.syms,
1376                &db.labels,
1377                build_props_view(&db.props, &db.base),
1378            );
1379        }
1380        for rec in records {
1381            db.apply(&rec)?;
1382            // Drain per-frame to keep pending_deltas O(1) during replay (I-2).
1383            // No subscriber exists yet; discard is correct.
1384            let _ = db.engine.drain_deltas();
1385            // Track commit_seq during replay so last_change entries are
1386            // consistent with the seqs assigned by log_then_apply_with on
1387            // subsequent live commits.  After N replayed frames, commit_seq=N;
1388            // live commits begin at N+1.
1389            db.commit_seq += 1;
1390            let replay_seq = db.commit_seq;
1391            db.update_last_change_from_rec(&rec, replay_seq);
1392        }
1393        // Enforce I-2: if the per-frame drain above is ever removed or skipped,
1394        // this assert catches the regression in debug builds immediately.
1395        debug_assert_eq!(
1396            db.engine.pending_delta_count(),
1397            0,
1398            "pending_deltas non-empty after replay — \
1399             per-frame drain must run inside the loop to keep memory O(1)"
1400        );
1401        // T2 note: the per-frame drain IS the suppression seam for replay.
1402        // Any future as-of replay path (Plan-15 T2) must drain here to feed
1403        // replaying subscribers; the mechanism is already in place.
1404        let _ = db.engine.drain_deltas(); // belt-and-braces no-op after loop drain
1405        trace_open!("wal replay done", _t0);
1406        // Rebuild view values after WAL replay only when there is no V8 base.
1407        // With a V8 base, view values are correct in the snapshot and are updated
1408        // incrementally during WAL replay (on_edge_changed / on_prop_changed).
1409        // A full rebuild would read overlay-only props (empty after restore_v8_base)
1410        // and overwrite correct base values with wrong results (e.g. NeighborAgg
1411        // Sum reads no "score" in overlay → writes 0.0, shadowing the correct
1412        // base value).
1413        if db.base.is_none() {
1414            let topo_view = TopologyView::owned(&db.topo);
1415            db.view_store
1416                .rebuild_all(&mut db.props, &topo_view, &db.ids, &db.syms, &db.labels);
1417        }
1418        // Rebuild full-text index after WAL replay.  Corrects drift from
1419        // per-record incremental apply during replay.
1420        db.fulltext.rebuild_all(
1421            &db.ids,
1422            &db.labels,
1423            &db.syms,
1424            build_props_view(&db.props, &db.base),
1425        );
1426        // Load roles sidecar. Missing file = no roles (Some(vec![])).
1427        // Corrupt/unparseable = poisoned (None); mask_for_role will fail-loud.
1428        db.roles = Self::load_roles_from_fs(&db.fs)?;
1429        // Capture the initial MVCC fold so reader() is ready immediately.
1430        db.fold_now();
1431        trace_open!("open_with complete", _t0);
1432        Ok(db)
1433    }
1434
1435    /// As-of replay for [`GraphDb::open_at`]: snapshot base (only when the
1436    /// snapshot truncated the WAL) plus the first `commit + 1` WAL frames;
1437    /// see [`GraphDb::open_at`] for the semantics.  The per-frame drain
1438    /// mirrors `open_with` exactly so pending_delta_count is 0 on exit.
1439    /// Restore all persisted state from a decoded snapshot. Shared by
1440    /// `open_with` and (when the snapshot truncated the WAL) `open_at_with`.
1441    fn restore_snapshot_state(
1442        &mut self,
1443        state: core_storage::snapshot::SnapshotState,
1444    ) -> Result<()> {
1445        self.ids = state.ids;
1446        self.syms = state.syms;
1447        self.topo = state.topo;
1448        self.props = state.props;
1449        self.labels = state.labels;
1450        self.edge_props = state.edge_props;
1451        // Cross-section label integrity for V5/V7 snapshots: same invariants as
1452        // restore_v8_base.  A crafted bincode snapshot with a short `labels` vec,
1453        // out-of-range sym ids, or a sentinel label on a live node would otherwise
1454        // open successfully and panic later in `NodeRef::label()` or
1455        // `neighborhood_masked()`.  Catching it here turns those into typed
1456        // `GraphError::Corrupt` at open time.
1457        {
1458            let ids_len = self.ids.len();
1459            if self.labels.len() != ids_len {
1460                return Err(GraphError::Corrupt {
1461                    detail: format!(
1462                        "snapshot: labels vec has {} entries but id table has {} total slots",
1463                        self.labels.len(),
1464                        ids_len,
1465                    ),
1466                });
1467            }
1468            let syms_len = self.syms.len() as u32;
1469            for (i, &sym) in self.labels.iter().enumerate() {
1470                let is_tombstoned = self.ids.is_tombstoned(i as u32);
1471                if sym == u32::MAX {
1472                    if !is_tombstoned {
1473                        return Err(GraphError::Corrupt {
1474                            detail: format!(
1475                                "snapshot: live node at id slot {i} has sentinel label (u32::MAX)"
1476                            ),
1477                        });
1478                    }
1479                } else if sym >= syms_len {
1480                    return Err(GraphError::Corrupt {
1481                        detail: format!(
1482                            "snapshot: label at id slot {i} references sym {sym} \
1483                             which is out of interner range ({syms_len})"
1484                        ),
1485                    });
1486                }
1487            }
1488        }
1489        let defs: Vec<RuleDef> = state
1490            .rule_defs
1491            .iter()
1492            .map(|b| {
1493                decode_rule_def(b).map_err(|e| GraphError::Corrupt {
1494                    detail: format!("snapshot rule_def deserialize: {e}"),
1495                })
1496            })
1497            .collect::<Result<Vec<_>>>()?;
1498        self.engine =
1499            RuleEngine::from_persist(defs, state.provenance, state.rule_tripped, state.rule_fires);
1500        // Candidate indexes are rebuilt lazily on the first mutation (see
1501        // RuleEngine::on_node_changed).  HNSW blobs and IVF centroids from the
1502        // snapshot are retained without deserializing so that:
1503        //   - clean-open (empty WAL): indexes stay empty; blobs load on first
1504        //     ANN query via ensure_hnsw_loaded, or on first mutation via the
1505        //     lazy-init guard which calls reindex_all_load_ivf + load_hnsw_state.
1506        //   - WAL-present: open_with calls consume_retained_state_eager before
1507        //     replay so HNSW/IVF are live before any record fires the hooks.
1508        let ivf_bytes = if state.ivf_state.is_empty() {
1509            Vec::new()
1510        } else {
1511            bincode::serialize(&state.ivf_state).expect("IVF state serialize cannot fail")
1512        };
1513        // Store blobs without eagerly deserializing them.
1514        self.engine
1515            .store_snapshot_state(state.hnsw_state, ivf_bytes);
1516        // Restore view defs from snapshot (V5).
1517        // The ColumnStore already contains view values from the snapshot;
1518        // use restore_view (no collision check, no backfill) so the store
1519        // is aware of the definitions.  rebuild_all runs after WAL replay.
1520        for def_bytes in &state.view_defs {
1521            let def: ViewDef =
1522                bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
1523                    detail: format!("snapshot view_def deserialize: {e}"),
1524                })?;
1525            self.view_store
1526                .restore_view(def)
1527                .map_err(|e| GraphError::Corrupt {
1528                    detail: format!("snapshot view restore: {e}"),
1529                })?;
1530        }
1531        Ok(())
1532    }
1533
1534    /// Restore all persisted state from a V8 `MappedBase` snapshot, **except**
1535    /// topology (`self.topo` stays empty and serves as the WAL-replay overlay).
1536    ///
1537    /// `self.props` IS fully materialised from the base so that HNSW/IVF blob
1538    /// deserialization and view rebuild have access to all column data.
1539    fn restore_v8_base(&mut self, mapped: Arc<core_storage::v8::MappedBase>) -> Result<()> {
1540        self.ids = archived_to_idmap(mapped.ids().map_err(|e| GraphError::Corrupt {
1541            detail: format!("v8: ids section: {e:?}"),
1542        })?);
1543        self.syms = archived_to_interner(mapped.syms().map_err(|e| GraphError::Corrupt {
1544            detail: format!("v8: syms section: {e:?}"),
1545        })?);
1546
1547        // C1: self.props is left as an empty overlay. Column reads go through
1548        // props_view() (ColumnsView::with_base), which consults the archived base
1549        // section zero-copy. This avoids the O(columns) heap copy at every open.
1550
1551        // self.topo deliberately left as Topology::new() — overlay path.
1552
1553        let meta = decode_meta(mapped.meta_bytes().map_err(|e| GraphError::Corrupt {
1554            detail: format!("v8: meta section: {e:?}"),
1555        })?)
1556        .map_err(|e| GraphError::Corrupt {
1557            detail: format!("v8: meta decode: {e:?}"),
1558        })?;
1559        self.labels = meta.labels;
1560        // Cross-section label integrity: labels must cover every id slot (live
1561        // and tombstoned), every non-sentinel sym must be within the interner's
1562        // bound, and no live (non-tombstoned) node may carry the u32::MAX
1563        // sentinel label.  Without this check, a crafted snapshot where the META
1564        // section (small, CRC-validated) holds a short `labels` vec, out-of-range
1565        // sym ids, or a sentinel label on a live node, would open successfully
1566        // and then panic in `NodeRef::label()`, `neighborhood_masked()`, and
1567        // related read paths.  Catching the inconsistency here converts those
1568        // panics into typed `GraphError::Corrupt` at open time.
1569        {
1570            let ids_len = self.ids.len();
1571            if self.labels.len() != ids_len {
1572                return Err(GraphError::Corrupt {
1573                    detail: format!(
1574                        "v8: labels section has {} entries but id table has {} total slots",
1575                        self.labels.len(),
1576                        ids_len,
1577                    ),
1578                });
1579            }
1580            let syms_len = self.syms.len() as u32;
1581            for (i, &sym) in self.labels.iter().enumerate() {
1582                let is_tombstoned = self.ids.is_tombstoned(i as u32);
1583                if sym == u32::MAX {
1584                    // Sentinel is only valid for tombstoned slots.
1585                    if !is_tombstoned {
1586                        return Err(GraphError::Corrupt {
1587                            detail: format!(
1588                                "v8: live node at id slot {i} has sentinel label (u32::MAX)"
1589                            ),
1590                        });
1591                    }
1592                } else if sym >= syms_len {
1593                    return Err(GraphError::Corrupt {
1594                        detail: format!(
1595                            "v8: label at id slot {i} references sym {sym} \
1596                             which is out of interner range ({syms_len})"
1597                        ),
1598                    });
1599                }
1600            }
1601        }
1602        // C3: self.edge_props stays as an empty overlay.  Reads go through
1603        // edge_props_view() which consults the mmap'd base section zero-copy
1604        // via EdgePropsView::with_base.  No heap decode at open time.
1605
1606        // Restore rule engine.
1607        let (rule_def_bytes, rule_tripped, rule_fires) =
1608            archived_rules_meta_to_owned(mapped.rules_meta_section().map_err(|e| {
1609                GraphError::Corrupt {
1610                    detail: format!("v8: rules_meta section: {e:?}"),
1611                }
1612            })?);
1613        let defs: Vec<RuleDef> = rule_def_bytes
1614            .iter()
1615            .map(|b| {
1616                decode_rule_def(b).map_err(|e| GraphError::Corrupt {
1617                    detail: format!("v8: rule_def deserialize: {e}"),
1618                })
1619            })
1620            .collect::<Result<Vec<_>>>()?;
1621        self.engine = RuleEngine::from_persist(defs, BTreeMap::new(), rule_tripped, rule_fires);
1622        // C4+C5: provenance, HNSW, and IVF sections are NOT read here.
1623        // `ensure_v8_base_sections_loaded` reads them on first use from
1624        // `self.base` (set by the caller immediately after this returns).
1625        // A clean open touches only: header + IDS + SYMS + META + RULES_META.
1626
1627        // Restore view definitions.
1628        let view_defs =
1629            archived_views_to_owned(mapped.views_section().map_err(|e| GraphError::Corrupt {
1630                detail: format!("v8: views section: {e:?}"),
1631            })?);
1632        for def_bytes in &view_defs {
1633            let def: ViewDef =
1634                bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
1635                    detail: format!("v8: view_def deserialize: {e}"),
1636                })?;
1637            self.view_store
1638                .restore_view(def)
1639                .map_err(|e| GraphError::Corrupt {
1640                    detail: format!("v8: view restore: {e}"),
1641                })?;
1642        }
1643        // Load the last-change map from section 11 (small section; load eagerly).
1644        // Pre-Task-3 snapshots lack this section; `last_change_bytes` returns &[]
1645        // in that case and `decode_last_change_bytes` returns an empty map.
1646        let last_change_raw = mapped
1647            .last_change_bytes()
1648            .map_err(|e| GraphError::Corrupt {
1649                detail: format!("v8: last_change section: {e:?}"),
1650            })?;
1651        self.last_change = decode_last_change_bytes(last_change_raw);
1652
1653        // Validate that all deferred sections (provenance, HNSW, IVF) fit within
1654        // the file.  Pure bounds check — no bytes read, no page faults triggered.
1655        // Catches truncated snapshots at open time before the lazy deferred reads.
1656        mapped.validate_section_bounds().map_err(|e| match e {
1657            GraphError::Corrupt { detail } => GraphError::Corrupt {
1658                detail: format!("v8: section bounds: {detail}"),
1659            },
1660            other => other,
1661        })?;
1662        Ok(())
1663    }
1664
1665    /// Read provenance, HNSW, and IVF sections from the mmap base into the
1666    /// engine's retained fields on first call.  Subsequent calls are a no-op
1667    /// (AtomicBool fast-path).
1668    ///
1669    /// Must be called before any code path that reads or mutates engine
1670    /// provenance, HNSW, or IVF state:
1671    /// - WAL replay (before `consume_retained_state_eager`)
1672    /// - First mutation (`log_then_apply_with`)
1673    /// - Read-only paths (`stats`, `explain`, `node_edges`)
1674    /// - Snapshot (`snapshot_with`)
1675    ///
1676    /// No-op for fresh stores and V5-V7 opens (`self.base` is `None`).
1677    fn ensure_v8_base_sections_loaded(&self) {
1678        use std::sync::atomic::Ordering;
1679        if self.v8_sections_loaded.load(Ordering::Acquire) {
1680            return;
1681        }
1682        let _guard = self
1683            .v8_sections_mutex
1684            .lock()
1685            .expect("v8 sections mutex poisoned");
1686        if self.v8_sections_loaded.load(Ordering::Acquire) {
1687            return; // another caller populated while we waited
1688        }
1689        let _t = std::time::Instant::now();
1690        if let Some(base) = &self.base {
1691            // Provenance: raw rkyv bytes; CRC validated inside section_bytes.
1692            // Bounds are already validated at open time (restore_v8_base →
1693            // validate_section_bounds) — unreachable post-validate_section_bounds;
1694            // unwrap_or_default is a safety belt against impossible errors.
1695            let prov_bytes = base
1696                .provenance_raw_bytes()
1697                .map(|b| b.to_vec())
1698                .unwrap_or_default();
1699            self.engine.store_provenance_bytes(prov_bytes);
1700            // HNSW: decode rkyv blobs into owned map.
1701            let hnsw_state = base
1702                .hnsw_section()
1703                .map(archived_hnsw_to_owned)
1704                .unwrap_or_default();
1705            // IVF: raw bincode bytes; deserialized on first mutation/query.
1706            let ivf_bytes = base.ivf_bytes().map(|b| b.to_vec()).unwrap_or_default();
1707            self.engine.store_snapshot_state(hnsw_state, ivf_bytes);
1708        }
1709        self.v8_sections_loaded.store(true, Ordering::Release);
1710        if std::env::var("MUSHROOMDB_TRACE_OPEN").is_ok() {
1711            eprintln!(
1712                "[MUSHROOMDB_TRACE_OPEN] ensure_v8_base_sections_loaded: {:>9.3?}",
1713                _t.elapsed()
1714            );
1715        }
1716    }
1717
1718    /// Return a `TopologyView` that merges the mmap'd base (when present) with
1719    /// the in-memory WAL overlay.  Used by all read paths in db.rs that need
1720    /// the full merged topology without going through `self.view()`.
1721    fn topo_view(&self) -> TopologyView<'_> {
1722        match self.base {
1723            None => TopologyView::owned(&self.topo),
1724            Some(ref base) => {
1725                // SAFETY: base lives as long as self; section bounds validated at open.
1726                // topology() uses access_unchecked; all field reads are bounds-checked in seam.rs.
1727                let archived = base
1728                    .topology()
1729                    .expect("base topology section bounds validated at open");
1730                TopologyView::with_base(&self.topo, archived)
1731            }
1732        }
1733    }
1734
1735    /// Return a `ColumnsView` that merges the mmap'd base columns (when a V8
1736    /// snapshot is open) with the in-memory WAL overlay.  Reads consult the
1737    /// overlay first, then fall through to the archived base section zero-copy.
1738    fn props_view(&self) -> core_storage::v8::seam::ColumnsView<'_> {
1739        match self.base {
1740            None => core_storage::v8::seam::ColumnsView::owned(&self.props),
1741            Some(ref base) => {
1742                // columns() uses access_unchecked; field reads are bounds-checked in seam.rs.
1743                let archived = base
1744                    .columns()
1745                    .expect("base columns section bounds validated at open");
1746                core_storage::v8::seam::ColumnsView::with_base(&self.props, archived)
1747            }
1748        }
1749    }
1750
1751    /// Return an `EdgePropsView` that merges the mmap'd base edge-props section
1752    /// (when a V8 snapshot is open) with the in-memory WAL overlay.
1753    ///
1754    /// Reads consult the overlay first (for post-snapshot mutations), then fall
1755    /// through to the archived base section zero-copy.  Tombstones in the
1756    /// overlay mask deleted-from-base entries.
1757    fn edge_props_view(&self) -> EdgePropsView<'_> {
1758        match self.base {
1759            None => EdgePropsView::owned(&self.edge_props),
1760            Some(ref base) => {
1761                // edge_props_section() uses access_unchecked; field reads bounds-checked in seam.rs.
1762                let archived = base
1763                    .edge_props_section()
1764                    .expect("base edge_props section bounds validated at open");
1765                EdgePropsView::with_base(&self.edge_props, archived)
1766            }
1767        }
1768    }
1769
1770    fn open_at_with(fs: F, commit: u64) -> Result<Self> {
1771        let mut db = Self {
1772            fs,
1773            ids: IdMap::new(),
1774            syms: Interner::new(),
1775            topo: Topology::new(),
1776            props: ColumnStore::new(),
1777            labels: Vec::new(),
1778            edge_props: EdgeProps::new(),
1779            engine: RuleEngine::new(),
1780            view_store: ViewStore::new(),
1781            fulltext: FulltextIndex::new(),
1782            event_sink: None,
1783            fsync: FsyncPolicy::Strict,
1784            commit_seq: 0,
1785            roles: Some(vec![]),
1786            subscriptions: Vec::new(),
1787            query_subscriptions: Vec::new(),
1788            sub_capacity: DEFAULT_SUB_CAPACITY,
1789            read_only: false, // set to true after replay
1790            total_wal_commits: 0,
1791            base: None,
1792            fold_overlay: None,
1793            delta_tail: Vec::new(),
1794            commits_since_fold: 0,
1795            defer_events: false,
1796            deferred_events: Vec::new(),
1797            degraded: false,
1798            v8_sections_loaded: std::sync::atomic::AtomicBool::new(false),
1799            v8_sections_mutex: std::sync::Mutex::new(()),
1800            last_change: HashMap::new(),
1801            wal_archive_retention: None,
1802            wal_horizon_floor: 0,
1803            archive_genesis_chain: false,
1804        };
1805        db.wal_horizon_floor = db.fs.read_horizon_floor()?;
1806        db.archive_genesis_chain = db.fs.has_genesis_marker();
1807        // Same orphaned-archive cleanup as open_with: floor was written first
1808        // during pruning, so a crash may have left stale archives below floor.
1809        db.cleanup_orphaned_archives()?;
1810        // Collect archive frames (oldest-first) and live WAL frames.
1811        // Archives represent pre-snapshot history; the snapshot captures the
1812        // cumulative state at the time of archiving.  Crash-window guarantee:
1813        //   A: crash before rename → WAL intact, no archive. Reopen: normal.
1814        //   B: crash after rename, before new WAL → archive present, WAL
1815        //      absent. Reopen: snapshot loaded (full state), no WAL replay.
1816        //   C: crash after new baseline WAL written → normal post-archive.
1817        let archive_ns = db.fs.list_archives()?;
1818        let mut archive_frames_all: Vec<WalRecord> = Vec::new();
1819        for n in &archive_ns {
1820            let arc_bytes = db.fs.read_archive(*n)?;
1821            let (arc_frames, _) = decode_all(&arc_bytes);
1822            archive_frames_all.extend(arc_frames);
1823        }
1824        let total_archive_frames = archive_frames_all.len() as u64;
1825
1826        let live_bytes = db.fs.read(FileId::Wal)?;
1827        let (live_records, _valid_len) = decode_all(&live_bytes);
1828        let total_surviving = total_archive_frames + live_records.len() as u64;
1829        // Global total including any pruned history below the horizon floor.
1830        let total = db.wal_horizon_floor + total_surviving;
1831
1832        // Horizon and range check.
1833        if commit < db.wal_horizon_floor {
1834            return Err(GraphError::CommitOutOfRange { commit, total });
1835        }
1836        if commit >= total {
1837            return Err(GraphError::CommitOutOfRange { commit, total });
1838        }
1839
1840        // Local index into surviving frames (0 = first frame of oldest archive).
1841        let local = commit - db.wal_horizon_floor;
1842
1843        if local < total_archive_frames {
1844            // Target commit is in an archive.  Correct replay from empty state
1845            // is only possible when the archive chain is an uninterrupted
1846            // genesis chain (first archive taken from a fresh store, no prior
1847            // WAL truncation) and no archives have been pruned (floor == 0).
1848            //
1849            // If either condition is violated the prefix needed to reconstruct
1850            // the requested state is gone; refuse rather than return wrong data.
1851            if db.wal_horizon_floor > 0 || !db.archive_genesis_chain {
1852                return Err(GraphError::CommitOutOfRange { commit, total });
1853            }
1854            // Replay all archive frames up to and including the target commit
1855            // from an empty database state.  Archives must be replayed in order
1856            // so that dense-id intern tables are built up correctly.
1857            for rec in archive_frames_all.into_iter().take((local + 1) as usize) {
1858                db.apply(&rec)?;
1859                let _ = db.engine.drain_deltas();
1860            }
1861        } else {
1862            // Target commit is in the live WAL: load snapshot as base, then
1863            // replay the needed live WAL prefix.
1864            //
1865            // Base state: a truncating snapshot (wal_truncated=true) compacts
1866            // all pre-truncation / pre-archive commits.  Dense-id records in
1867            // the live WAL reference ids/interns that the snapshot provides.
1868            // Peek 6 bytes (same pattern as open_with).
1869            let snap_header = db.fs.read_prefix(FileId::Snapshot, 6)?;
1870            let is_v8 = snap_header.len() >= 6
1871                && &snap_header[0..4] == b"GDB1"
1872                && u16::from_le_bytes([snap_header[4], snap_header[5]])
1873                    == core_storage::snapshot::VERSION_8;
1874            if is_v8 {
1875                let state = if let Some(snap_path) = db.fs.snapshot_path() {
1876                    let mapped = core_storage::v8::MappedBase::map(&snap_path).map_err(|e| {
1877                        GraphError::Corrupt {
1878                            detail: format!("v8: open_at mmap: {e:?}"),
1879                        }
1880                    })?;
1881                    core_storage::snapshot::decode_v8_from_mapped(&mapped)?
1882                } else {
1883                    let snap_bytes = db.fs.read(FileId::Snapshot)?;
1884                    core_storage::snapshot::decode(&snap_bytes)?
1885                };
1886                if let Some(state) = state {
1887                    if state.wal_truncated {
1888                        db.restore_snapshot_state(state)?;
1889                    }
1890                }
1891            } else if !snap_header.is_empty() {
1892                let snap_bytes = db.fs.read(FileId::Snapshot)?;
1893                if let Some(state) = core_storage::snapshot::decode(&snap_bytes)? {
1894                    if state.wal_truncated {
1895                        db.restore_snapshot_state(state)?;
1896                    }
1897                }
1898            }
1899            // else: snap_header empty = no snapshot file.
1900            let live_local = local - total_archive_frames;
1901            for rec in live_records.into_iter().take((live_local + 1) as usize) {
1902                db.apply(&rec)?;
1903                let _ = db.engine.drain_deltas();
1904            }
1905        }
1906        // Pin: pending_delta_count must be 0 after as-of replay, mirroring T1's
1907        // post-loop assert in open_with.
1908        debug_assert_eq!(
1909            db.engine.pending_delta_count(),
1910            0,
1911            "pending_deltas non-empty after open_at replay — \
1912             per-frame drain must run inside the loop to keep memory O(1)"
1913        );
1914        let _ = db.engine.drain_deltas(); // belt-and-braces no-op
1915                                          // Rebuild view values after WAL replay so derived-edge-driven views
1916                                          // reflect the as-of state.  open_at always uses the legacy path (no V8
1917                                          // base), so topo_view is always owned.
1918        {
1919            let topo_view = TopologyView::owned(&db.topo);
1920            db.view_store
1921                .rebuild_all(&mut db.props, &topo_view, &db.ids, &db.syms, &db.labels);
1922        }
1923        // Rebuild full-text index for as-of view (mirrors open_with pattern).
1924        db.fulltext.rebuild_all(
1925            &db.ids,
1926            &db.labels,
1927            &db.syms,
1928            build_props_view(&db.props, &db.base),
1929        );
1930        // Load roles sidecar (current roles, not point-in-time).
1931        db.roles = Self::load_roles_from_fs(&db.fs)?;
1932        db.read_only = true;
1933        db.total_wal_commits = total;
1934        // Capture initial fold so reader() is immediately usable.
1935        db.fold_now();
1936        Ok(db)
1937    }
1938
1939    /// Whether this instance is a read-only as-of view.
1940    pub fn is_read_only(&self) -> bool {
1941        self.read_only
1942    }
1943
1944    // ── MVCC epoch reader ─────────────────────────────────────────────────────
1945
1946    /// Clone the current overlay state into a new `FrozenOverlay` and reset
1947    /// the delta tail. Called automatically every `FOLD_EVERY_K` commits and at
1948    /// the end of `open_with` / `open_at_with` to prime the reader.
1949    fn fold_now(&mut self) {
1950        let frozen = crate::reader::FrozenOverlay {
1951            ids: self.ids.clone(),
1952            syms: self.syms.clone(),
1953            topo: self.topo.clone(),
1954            props: self.props.clone(),
1955            labels: self.labels.clone(),
1956            edge_props: self.edge_props.clone(),
1957            roles: self.roles.clone(),
1958            fulltext: self.fulltext.clone(),
1959        };
1960        self.fold_overlay = Some(Arc::new(frozen));
1961        self.delta_tail.clear();
1962        self.commits_since_fold = 0;
1963    }
1964
1965    /// Capture a lock-free reader snapshot of the current db state.
1966    ///
1967    /// The read lock is held only for the duration of this call (to clone a
1968    /// handful of `Arc` handles). Subsequent query operations run without any
1969    /// lock.
1970    pub fn reader(&self) -> crate::reader::ReaderSnapshot {
1971        crate::reader::ReaderSnapshot::new(
1972            self.fold_overlay
1973                .clone()
1974                .expect("fold_overlay is always Some after open_with; call reader() after open"),
1975            self.base.clone(),
1976            self.delta_tail.clone(),
1977        )
1978    }
1979
1980    /// Total number of WAL commits at the time [`open_at`] was called.
1981    /// Returns 0 for normal (non-as-of) instances.
1982    pub fn total_wal_commits(&self) -> u64 {
1983        self.total_wal_commits
1984    }
1985
1986    /// Apply a record to in-memory state. Used by both live writes and replay,
1987    /// so replay is definitionally identical to the original execution.
1988    fn apply(&mut self, rec: &WalRecord) -> Result<()> {
1989        match rec {
1990            WalRecord::InsertNode { label, key, props } => {
1991                let id = self.ids.try_insert(key)?;
1992                let sym = self.syms.intern(label);
1993                if self.labels.len() <= id as usize {
1994                    // gap slots are sentinels, never valid label symbols
1995                    self.labels.resize(id as usize + 1, u32::MAX);
1996                }
1997                self.labels[id as usize] = sym;
1998                for (field, value) in props {
1999                    self.props.set(id, field, value.clone());
2000                }
2001                // Initialize view values for the new node before the engine runs so
2002                // delta-based increments start from a known zero baseline.
2003                self.view_store
2004                    .init_node_views(id, &mut self.props, &self.syms, &self.labels);
2005                // Fire rules for the newly inserted node.
2006                let cursor = self.engine.pending_delta_count();
2007                let mut eng = std::mem::take(&mut self.engine);
2008                {
2009                    let mut gm = make_graph_mut(
2010                        &self.ids,
2011                        &mut self.syms,
2012                        &self.labels,
2013                        build_props_view(&self.props, &self.base),
2014                        &mut self.topo,
2015                        &mut self.edge_props,
2016                    );
2017                    eng.on_node_changed(id, None, &mut gm);
2018                }
2019                self.engine = eng;
2020                // Process derived-edge deltas for view maintenance.
2021                // Fast path: skip the O(delta_count) allocation when no views exist.
2022                if !self.view_store.is_empty() {
2023                    #[cfg(test)]
2024                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2025                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2026                    for d in &new_deltas {
2027                        self.view_store.on_edge_changed(
2028                            d.etype_sym,
2029                            d.src_id,
2030                            d.dst_id,
2031                            d.fired,
2032                            &mut self.props,
2033                            &build_topo_view(&self.topo, &self.base),
2034                            &self.ids,
2035                            &self.syms,
2036                            &self.labels,
2037                            self.base.as_ref().map(|b| {
2038                                b.columns()
2039                                    .expect("base columns section bounds validated at open")
2040                            }),
2041                        );
2042                    }
2043                }
2044                // Full-text index maintenance: index enabled fields for this label.
2045                if self.fulltext.has_label(label) {
2046                    for (field, value) in props {
2047                        if self.fulltext.is_enabled(label, field) {
2048                            self.fulltext.add_tokens(id, field, value);
2049                        }
2050                    }
2051                }
2052            }
2053            WalRecord::InsertEdge {
2054                edge_type,
2055                src_key,
2056                dst_key,
2057            } => {
2058                let src = self.ids.get(src_key).ok_or_else(|| GraphError::Corrupt {
2059                    detail: format!("wal replay references unknown key {src_key}"),
2060                })?;
2061                let dst = self.ids.get(dst_key).ok_or_else(|| GraphError::Corrupt {
2062                    detail: format!("wal replay references unknown key {dst_key}"),
2063                })?;
2064                let etype = self.syms.intern(edge_type);
2065                // Skip if the edge is already visible in the merged base+overlay
2066                // view.  This keeps WAL replay idempotent when the WAL contains
2067                // pre-snapshot records that are already encoded in a V8 base
2068                // (keep_wal=true opens and crash-before-truncation scenarios).
2069                if self.base.is_some()
2070                    && self
2071                        .topo_view()
2072                        .neighbors(etype, Direction::Out, src)
2073                        .contains(&dst)
2074                {
2075                    return Ok(());
2076                }
2077                self.topo.add_edge(etype, src, dst);
2078                // View maintenance for manual edge insert.
2079                self.view_store.on_edge_changed(
2080                    etype,
2081                    src,
2082                    dst,
2083                    true,
2084                    &mut self.props,
2085                    &build_topo_view(&self.topo, &self.base),
2086                    &self.ids,
2087                    &self.syms,
2088                    &self.labels,
2089                    self.base.as_ref().map(|b| {
2090                        b.columns()
2091                            .expect("base columns section bounds validated at open")
2092                    }),
2093                );
2094                // Rule engine: via-hop rules must update when user edges change.
2095                let cursor = self.engine.pending_delta_count();
2096                let mut eng = std::mem::take(&mut self.engine);
2097                {
2098                    let mut gm = make_graph_mut(
2099                        &self.ids,
2100                        &mut self.syms,
2101                        &self.labels,
2102                        build_props_view(&self.props, &self.base),
2103                        &mut self.topo,
2104                        &mut self.edge_props,
2105                    );
2106                    eng.on_edge_changed(edge_type, src, dst, &mut gm);
2107                }
2108                self.engine = eng;
2109                if !self.view_store.is_empty() {
2110                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2111                    for d in &new_deltas {
2112                        self.view_store.on_edge_changed(
2113                            d.etype_sym,
2114                            d.src_id,
2115                            d.dst_id,
2116                            d.fired,
2117                            &mut self.props,
2118                            &build_topo_view(&self.topo, &self.base),
2119                            &self.ids,
2120                            &self.syms,
2121                            &self.labels,
2122                            self.base.as_ref().map(|b| {
2123                                b.columns()
2124                                    .expect("base columns section bounds validated at open")
2125                            }),
2126                        );
2127                    }
2128                }
2129            }
2130            WalRecord::SetProp { key, field, value } => {
2131                let id = self.ids.get(key).ok_or_else(|| GraphError::Corrupt {
2132                    detail: format!("wal replay references unknown key {key}"),
2133                })?;
2134                let old_value = build_props_view(&self.props, &self.base)
2135                    .get(id, field)
2136                    .map(|vr| vr.into_value());
2137                self.props.set(id, field, value.clone());
2138                // Fire rules for the changed field.
2139                let cursor = self.engine.pending_delta_count();
2140                let mut eng = std::mem::take(&mut self.engine);
2141                {
2142                    let mut gm = make_graph_mut(
2143                        &self.ids,
2144                        &mut self.syms,
2145                        &self.labels,
2146                        build_props_view(&self.props, &self.base),
2147                        &mut self.topo,
2148                        &mut self.edge_props,
2149                    );
2150                    eng.on_node_changed(id, Some((field, old_value)), &mut gm);
2151                }
2152                self.engine = eng;
2153                // Derived-edge deltas → view updates.
2154                if !self.view_store.is_empty() {
2155                    #[cfg(test)]
2156                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2157                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2158                    for d in &new_deltas {
2159                        self.view_store.on_edge_changed(
2160                            d.etype_sym,
2161                            d.src_id,
2162                            d.dst_id,
2163                            d.fired,
2164                            &mut self.props,
2165                            &build_topo_view(&self.topo, &self.base),
2166                            &self.ids,
2167                            &self.syms,
2168                            &self.labels,
2169                            self.base.as_ref().map(|b| {
2170                                b.columns()
2171                                    .expect("base columns section bounds validated at open")
2172                            }),
2173                        );
2174                    }
2175                }
2176                // Neighbor-aggregate views that read `field` must also update.
2177                self.view_store.on_prop_changed(
2178                    id,
2179                    field,
2180                    &mut self.props,
2181                    &build_topo_view(&self.topo, &self.base),
2182                    &self.ids,
2183                    &self.syms,
2184                    &self.labels,
2185                    self.base.as_ref().map(|b| {
2186                        b.columns()
2187                            .expect("base columns section bounds validated at open")
2188                    }),
2189                );
2190                // Full-text index maintenance: update tokens for this field if indexed.
2191                if self.fulltext.field_indexed(field) {
2192                    let label_opt = self.labels.get(id as usize).and_then(|&sym| {
2193                        if sym == u32::MAX {
2194                            None
2195                        } else {
2196                            self.syms.resolve(sym)
2197                        }
2198                    });
2199                    if let Some(label) = label_opt {
2200                        if self.fulltext.is_enabled(label, field) {
2201                            self.fulltext.remove_node_field(id, field);
2202                            self.fulltext.add_tokens(id, field, value);
2203                        }
2204                    }
2205                }
2206            }
2207            WalRecord::Intern { id, text } => {
2208                if let Some(existing) = self.syms.get(text) {
2209                    if existing != *id {
2210                        return Err(GraphError::Corrupt {
2211                            detail: format!(
2212                                "wal intern mismatch for {text:?}: have {existing}, record {id}"
2213                            ),
2214                        });
2215                    }
2216                } else {
2217                    let got = self.syms.intern(text);
2218                    if got != *id {
2219                        return Err(GraphError::Corrupt {
2220                            detail: format!(
2221                                "wal intern assigned {got} for {text:?}, record wanted {id}"
2222                            ),
2223                        });
2224                    }
2225                }
2226            }
2227            WalRecord::InsertNodeId { label, key, props } => {
2228                let id = self.ids.try_insert(key)?;
2229                if self.labels.len() <= id as usize {
2230                    self.labels.resize(id as usize + 1, u32::MAX);
2231                }
2232                self.labels[id as usize] = *label;
2233                let label_str = self
2234                    .syms
2235                    .resolve(*label)
2236                    .ok_or_else(|| GraphError::Corrupt {
2237                        detail: format!("wal InsertNodeId unknown label intern {label}"),
2238                    })?
2239                    .to_string();
2240                for (field_sym, value) in props {
2241                    let field =
2242                        self.syms
2243                            .resolve(*field_sym)
2244                            .ok_or_else(|| GraphError::Corrupt {
2245                                detail: format!(
2246                                    "wal InsertNodeId unknown field intern {field_sym}"
2247                                ),
2248                            })?;
2249                    self.props.set(id, field, value.clone());
2250                }
2251                self.view_store
2252                    .init_node_views(id, &mut self.props, &self.syms, &self.labels);
2253                let cursor = self.engine.pending_delta_count();
2254                let mut eng = std::mem::take(&mut self.engine);
2255                {
2256                    let mut gm = make_graph_mut(
2257                        &self.ids,
2258                        &mut self.syms,
2259                        &self.labels,
2260                        build_props_view(&self.props, &self.base),
2261                        &mut self.topo,
2262                        &mut self.edge_props,
2263                    );
2264                    eng.on_node_changed(id, None, &mut gm);
2265                }
2266                self.engine = eng;
2267                if !self.view_store.is_empty() {
2268                    #[cfg(test)]
2269                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2270                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2271                    for d in &new_deltas {
2272                        self.view_store.on_edge_changed(
2273                            d.etype_sym,
2274                            d.src_id,
2275                            d.dst_id,
2276                            d.fired,
2277                            &mut self.props,
2278                            &build_topo_view(&self.topo, &self.base),
2279                            &self.ids,
2280                            &self.syms,
2281                            &self.labels,
2282                            self.base.as_ref().map(|b| {
2283                                b.columns()
2284                                    .expect("base columns section bounds validated at open")
2285                            }),
2286                        );
2287                    }
2288                }
2289                if self.fulltext.has_label(&label_str) {
2290                    for (field_sym, value) in props {
2291                        let Some(field) = self.syms.resolve(*field_sym) else {
2292                            continue;
2293                        };
2294                        if self.fulltext.is_enabled(&label_str, field) {
2295                            self.fulltext.add_tokens(id, field, value);
2296                        }
2297                    }
2298                }
2299            }
2300            WalRecord::InsertEdgeId { etype, src, dst } => {
2301                // Replay-over-snapshot: dense ids in the pre-snapshot WAL may
2302                // already be tombstoned. Skip rather than attaching edges to
2303                // dead ids (DeleteNode keys the live re-insert, not the old id).
2304                if self.ids.is_tombstoned(*src)
2305                    || self.ids.is_tombstoned(*dst)
2306                    || self.ids.key_of(*src).is_none()
2307                    || self.ids.key_of(*dst).is_none()
2308                {
2309                    return Ok(());
2310                }
2311                // Skip if already visible in the merged view (same idempotency
2312                // guard as InsertEdge above: prevents double-counting when
2313                // pre-snapshot WAL records are replayed over a V8 base).
2314                if self.base.is_some()
2315                    && self
2316                        .topo_view()
2317                        .neighbors(*etype, Direction::Out, *src)
2318                        .contains(dst)
2319                {
2320                    return Ok(());
2321                }
2322                self.topo.add_edge(*etype, *src, *dst);
2323                self.view_store.on_edge_changed(
2324                    *etype,
2325                    *src,
2326                    *dst,
2327                    true,
2328                    &mut self.props,
2329                    &build_topo_view(&self.topo, &self.base),
2330                    &self.ids,
2331                    &self.syms,
2332                    &self.labels,
2333                    self.base.as_ref().map(|b| {
2334                        b.columns()
2335                            .expect("base columns section bounds validated at open")
2336                    }),
2337                );
2338                // Rule engine: via-hop rules fire when user via-edges are inserted.
2339                // Resolve etype back to string so on_edge_changed can match rules by name.
2340                if let Some(etype_str) = self.syms.resolve(*etype).map(|s| s.to_string()) {
2341                    let cursor = self.engine.pending_delta_count();
2342                    let mut eng = std::mem::take(&mut self.engine);
2343                    {
2344                        let mut gm = make_graph_mut(
2345                            &self.ids,
2346                            &mut self.syms,
2347                            &self.labels,
2348                            build_props_view(&self.props, &self.base),
2349                            &mut self.topo,
2350                            &mut self.edge_props,
2351                        );
2352                        eng.on_edge_changed(&etype_str, *src, *dst, &mut gm);
2353                    }
2354                    self.engine = eng;
2355                    if !self.view_store.is_empty() {
2356                        let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2357                        for d in &new_deltas {
2358                            self.view_store.on_edge_changed(
2359                                d.etype_sym,
2360                                d.src_id,
2361                                d.dst_id,
2362                                d.fired,
2363                                &mut self.props,
2364                                &build_topo_view(&self.topo, &self.base),
2365                                &self.ids,
2366                                &self.syms,
2367                                &self.labels,
2368                                self.base.as_ref().map(|b| {
2369                                    b.columns()
2370                                        .expect("base columns section bounds validated at open")
2371                                }),
2372                            );
2373                        }
2374                    }
2375                }
2376            }
2377            WalRecord::SetPropId { id, field, value } => {
2378                if self.ids.is_tombstoned(*id) || self.ids.key_of(*id).is_none() {
2379                    return Ok(());
2380                }
2381                let field_str = self
2382                    .syms
2383                    .resolve(*field)
2384                    .ok_or_else(|| GraphError::Corrupt {
2385                        detail: format!("wal SetPropId unknown field intern {field}"),
2386                    })?
2387                    .to_string();
2388                let old_value = build_props_view(&self.props, &self.base)
2389                    .get(*id, &field_str)
2390                    .map(|vr| vr.into_value());
2391                self.props.set(*id, &field_str, value.clone());
2392                let cursor = self.engine.pending_delta_count();
2393                let mut eng = std::mem::take(&mut self.engine);
2394                {
2395                    let mut gm = make_graph_mut(
2396                        &self.ids,
2397                        &mut self.syms,
2398                        &self.labels,
2399                        build_props_view(&self.props, &self.base),
2400                        &mut self.topo,
2401                        &mut self.edge_props,
2402                    );
2403                    eng.on_node_changed(*id, Some((field_str.as_str(), old_value)), &mut gm);
2404                }
2405                self.engine = eng;
2406                if !self.view_store.is_empty() {
2407                    #[cfg(test)]
2408                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2409                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2410                    for d in &new_deltas {
2411                        self.view_store.on_edge_changed(
2412                            d.etype_sym,
2413                            d.src_id,
2414                            d.dst_id,
2415                            d.fired,
2416                            &mut self.props,
2417                            &build_topo_view(&self.topo, &self.base),
2418                            &self.ids,
2419                            &self.syms,
2420                            &self.labels,
2421                            self.base.as_ref().map(|b| {
2422                                b.columns()
2423                                    .expect("base columns section bounds validated at open")
2424                            }),
2425                        );
2426                    }
2427                }
2428                self.view_store.on_prop_changed(
2429                    *id,
2430                    &field_str,
2431                    &mut self.props,
2432                    &build_topo_view(&self.topo, &self.base),
2433                    &self.ids,
2434                    &self.syms,
2435                    &self.labels,
2436                    self.base.as_ref().map(|b| {
2437                        b.columns()
2438                            .expect("base columns section bounds validated at open")
2439                    }),
2440                );
2441                if self.fulltext.field_indexed(&field_str) {
2442                    let label_opt = self.labels.get(*id as usize).and_then(|&sym| {
2443                        if sym == u32::MAX {
2444                            None
2445                        } else {
2446                            self.syms.resolve(sym)
2447                        }
2448                    });
2449                    if let Some(label) = label_opt {
2450                        if self.fulltext.is_enabled(label, &field_str) {
2451                            self.fulltext.remove_node_field(*id, &field_str);
2452                            self.fulltext.add_tokens(*id, &field_str, value);
2453                        }
2454                    }
2455                }
2456            }
2457            WalRecord::CreateRule { def_bytes } => {
2458                let def: RuleDef = decode_rule_def(def_bytes).map_err(|e| GraphError::Corrupt {
2459                    detail: format!("CreateRule def_bytes deserialize failed: {e}"),
2460                })?;
2461                // Replay-over-snapshot idempotency: the rule was captured in the snapshot
2462                // so the engine already has it; silently skip to avoid a spurious
2463                // RuleInvalid error in the crash window between snapshot write and WAL
2464                // truncation.
2465                if self.engine.rules().any(|r| r.name == def.name) {
2466                    return Ok(());
2467                }
2468                let cursor = self.engine.pending_delta_count();
2469                let mut eng = std::mem::take(&mut self.engine);
2470                let result = {
2471                    let mut gm = make_graph_mut(
2472                        &self.ids,
2473                        &mut self.syms,
2474                        &self.labels,
2475                        build_props_view(&self.props, &self.base),
2476                        &mut self.topo,
2477                        &mut self.edge_props,
2478                    );
2479                    eng.create_rule(def, &mut gm)
2480                };
2481                self.engine = eng;
2482                result.map_err(|e| GraphError::RuleInvalid { detail: e })?;
2483                // Derived-edge fires from backfill → view updates.
2484                // Fast path: skip O(edge_count) allocation when no views exist.
2485                if !self.view_store.is_empty() {
2486                    #[cfg(test)]
2487                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2488                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2489                    for d in &new_deltas {
2490                        self.view_store.on_edge_changed(
2491                            d.etype_sym,
2492                            d.src_id,
2493                            d.dst_id,
2494                            d.fired,
2495                            &mut self.props,
2496                            &build_topo_view(&self.topo, &self.base),
2497                            &self.ids,
2498                            &self.syms,
2499                            &self.labels,
2500                            self.base.as_ref().map(|b| {
2501                                b.columns()
2502                                    .expect("base columns section bounds validated at open")
2503                            }),
2504                        );
2505                    }
2506                }
2507            }
2508            WalRecord::DeleteRule { name } => {
2509                // Replay-over-snapshot idempotency: the snapshot already captured the
2510                // post-delete state so the rule is absent; silently skip to avoid a
2511                // spurious RuleNotFound error in the crash window between snapshot write
2512                // and WAL truncation.
2513                if !self.engine.rules().any(|r| r.name == *name) {
2514                    return Ok(());
2515                }
2516                let cursor = self.engine.pending_delta_count();
2517                let mut eng = std::mem::take(&mut self.engine);
2518                let result = {
2519                    let mut gm = make_graph_mut(
2520                        &self.ids,
2521                        &mut self.syms,
2522                        &self.labels,
2523                        build_props_view(&self.props, &self.base),
2524                        &mut self.topo,
2525                        &mut self.edge_props,
2526                    );
2527                    eng.delete_rule(name, &mut gm)
2528                };
2529                self.engine = eng;
2530                result.map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
2531                // Derived-edge retractions → view updates.
2532                if !self.view_store.is_empty() {
2533                    #[cfg(test)]
2534                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2535                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2536                    for d in &new_deltas {
2537                        self.view_store.on_edge_changed(
2538                            d.etype_sym,
2539                            d.src_id,
2540                            d.dst_id,
2541                            d.fired,
2542                            &mut self.props,
2543                            &build_topo_view(&self.topo, &self.base),
2544                            &self.ids,
2545                            &self.syms,
2546                            &self.labels,
2547                            self.base.as_ref().map(|b| {
2548                                b.columns()
2549                                    .expect("base columns section bounds validated at open")
2550                            }),
2551                        );
2552                    }
2553                }
2554            }
2555            WalRecord::RemoveProp { key, field } => {
2556                // Recovery-safe: unknown key or already-absent field is a
2557                // clean no-op. Crash-window replay over a snapshot that
2558                // already applied this record must not Err.
2559                let Some(id) = self.ids.get(key) else {
2560                    return Ok(());
2561                };
2562                // Read old value through the seam for rule retraction.
2563                let old = build_props_view(&self.props, &self.base)
2564                    .get(id, field)
2565                    .map(|vr| vr.into_value());
2566                self.props.remove(id, field);
2567                // If the base still supplies the value after the overlay removal,
2568                // record a tombstone so ColumnsView::get does not resurrect it.
2569                // This covers both the base-only case AND the both-resident case:
2570                //   base-only (in_overlay=false): old prop was only in base, remove
2571                //     is a no-op on overlay, base still visible → tombstone needed.
2572                //   both-resident (in_overlay=true): overlay had v2, base has v1;
2573                //     removing overlay uncovers v1 → tombstone needed.
2574                // Idempotent on double-replay: second pass sees the tombstone →
2575                // get() returns None → condition is false → no duplicate tombstone.
2576                if build_props_view(&self.props, &self.base)
2577                    .get(id, field)
2578                    .is_some()
2579                {
2580                    self.props.record_prop_tombstone(id, field);
2581                }
2582                let cursor = self.engine.pending_delta_count();
2583                let mut eng = std::mem::take(&mut self.engine);
2584                {
2585                    let mut gm = make_graph_mut(
2586                        &self.ids,
2587                        &mut self.syms,
2588                        &self.labels,
2589                        build_props_view(&self.props, &self.base),
2590                        &mut self.topo,
2591                        &mut self.edge_props,
2592                    );
2593                    eng.on_node_changed(id, Some((field, old)), &mut gm);
2594                }
2595                self.engine = eng;
2596                // Derived-edge deltas → view updates.
2597                if !self.view_store.is_empty() {
2598                    #[cfg(test)]
2599                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2600                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2601                    for d in &new_deltas {
2602                        self.view_store.on_edge_changed(
2603                            d.etype_sym,
2604                            d.src_id,
2605                            d.dst_id,
2606                            d.fired,
2607                            &mut self.props,
2608                            &build_topo_view(&self.topo, &self.base),
2609                            &self.ids,
2610                            &self.syms,
2611                            &self.labels,
2612                            self.base.as_ref().map(|b| {
2613                                b.columns()
2614                                    .expect("base columns section bounds validated at open")
2615                            }),
2616                        );
2617                    }
2618                }
2619                // Neighbor-aggregate views that read `field` must also update.
2620                self.view_store.on_prop_changed(
2621                    id,
2622                    field,
2623                    &mut self.props,
2624                    &build_topo_view(&self.topo, &self.base),
2625                    &self.ids,
2626                    &self.syms,
2627                    &self.labels,
2628                    self.base.as_ref().map(|b| {
2629                        b.columns()
2630                            .expect("base columns section bounds validated at open")
2631                    }),
2632                );
2633                // Full-text index maintenance: remove tokens for this field.
2634                if self.fulltext.field_indexed(field) {
2635                    self.fulltext.remove_node_field(id, field);
2636                }
2637            }
2638            WalRecord::DeleteEdge {
2639                edge_type,
2640                src_key,
2641                dst_key,
2642            } => {
2643                // Recovery-safe: unknown keys, unknown etype, or already-
2644                // absent edge is a clean no-op (remove_edge returns false).
2645                let Some(src) = self.ids.get(src_key) else {
2646                    return Ok(());
2647                };
2648                let Some(dst) = self.ids.get(dst_key) else {
2649                    return Ok(());
2650                };
2651                let Some(etype) = self.syms.get(edge_type) else {
2652                    return Ok(());
2653                };
2654                // I3: phantom-tombstone guard.  When a V8 base is present, a
2655                // DeleteEdge WAL record for an edge that was already absorbed into
2656                // the new base (i.e. neither in overlay nor in base) must be skipped.
2657                // Without this guard, remove_edge records a tombstone for an edge
2658                // that no longer exists, incorrectly understating edge_count.
2659                if self.base.is_some()
2660                    && !self
2661                        .topo_view()
2662                        .neighbors(etype, core_storage::topology::Direction::Out, src)
2663                        .contains(&dst)
2664                {
2665                    return Ok(());
2666                }
2667                self.topo.remove_edge(etype, src, dst);
2668                self.edge_props.remove_edge(etype, src, dst);
2669                // View maintenance for manual edge delete (topo already updated above).
2670                self.view_store.on_edge_changed(
2671                    etype,
2672                    src,
2673                    dst,
2674                    false,
2675                    &mut self.props,
2676                    &build_topo_view(&self.topo, &self.base),
2677                    &self.ids,
2678                    &self.syms,
2679                    &self.labels,
2680                    self.base.as_ref().map(|b| {
2681                        b.columns()
2682                            .expect("base columns section bounds validated at open")
2683                    }),
2684                );
2685                // Rule engine: via-hop rules must retract when user via-edges are deleted.
2686                let cursor = self.engine.pending_delta_count();
2687                let mut eng = std::mem::take(&mut self.engine);
2688                {
2689                    let mut gm = make_graph_mut(
2690                        &self.ids,
2691                        &mut self.syms,
2692                        &self.labels,
2693                        build_props_view(&self.props, &self.base),
2694                        &mut self.topo,
2695                        &mut self.edge_props,
2696                    );
2697                    eng.on_edge_changed(edge_type, src, dst, &mut gm);
2698                }
2699                self.engine = eng;
2700                if !self.view_store.is_empty() {
2701                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2702                    for d in &new_deltas {
2703                        self.view_store.on_edge_changed(
2704                            d.etype_sym,
2705                            d.src_id,
2706                            d.dst_id,
2707                            d.fired,
2708                            &mut self.props,
2709                            &build_topo_view(&self.topo, &self.base),
2710                            &self.ids,
2711                            &self.syms,
2712                            &self.labels,
2713                            self.base.as_ref().map(|b| {
2714                                b.columns()
2715                                    .expect("base columns section bounds validated at open")
2716                            }),
2717                        );
2718                    }
2719                }
2720            }
2721            WalRecord::DeleteNode { key } => {
2722                // Recovery-safe: already-tombstoned / unknown key is a clean
2723                // no-op. Crash-window replay over a snapshot that already
2724                // applied this record cannot recover the retired id from the
2725                // key (`IdMap::get` is None), so every subsequent step is
2726                // skipped. Each step is independently idempotent if invoked
2727                // twice on a still-live id: retraction is a no-op on empty
2728                // provenance, `remove_edge` returns false, `remove_all` is a
2729                // no-op, `ids.delete` returns None, label sentinel is sticky.
2730                let Some(n) = self.ids.get(key) else {
2731                    return Ok(());
2732                };
2733
2734                // (1) Retract derived edges + de-index while props/labels live.
2735                let cursor = self.engine.pending_delta_count();
2736                let mut eng = std::mem::take(&mut self.engine);
2737                {
2738                    let mut gm = make_graph_mut(
2739                        &self.ids,
2740                        &mut self.syms,
2741                        &self.labels,
2742                        build_props_view(&self.props, &self.base),
2743                        &mut self.topo,
2744                        &mut self.edge_props,
2745                    );
2746                    eng.on_node_removed(n, &mut gm);
2747                }
2748                self.engine = eng;
2749                // Derived-edge retractions → view updates for neighbors.
2750                if !self.view_store.is_empty() {
2751                    #[cfg(test)]
2752                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2753                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2754                    for d in &new_deltas {
2755                        self.view_store.on_edge_changed(
2756                            d.etype_sym,
2757                            d.src_id,
2758                            d.dst_id,
2759                            d.fired,
2760                            &mut self.props,
2761                            &build_topo_view(&self.topo, &self.base),
2762                            &self.ids,
2763                            &self.syms,
2764                            &self.labels,
2765                            self.base.as_ref().map(|b| {
2766                                b.columns()
2767                                    .expect("base columns section bounds validated at open")
2768                            }),
2769                        );
2770                    }
2771                }
2772
2773                // (2) Sweep remaining user edges touching n, both directions,
2774                // every etype. Collect then remove so neighbor slices stay valid.
2775                // Remove from topo first, then call view maintenance so Avg/Min/Max
2776                // recompute sees the correct (reduced) neighbor set.
2777                let etypes: Vec<u32> = self.topo.etypes().collect();
2778                let mut doomed = Vec::new();
2779                for et in &etypes {
2780                    for &dst in self.topo.neighbors(*et, Direction::Out, n).as_ref() {
2781                        doomed.push((*et, n, dst));
2782                    }
2783                    for &src in self.topo.neighbors(*et, Direction::In, n).as_ref() {
2784                        doomed.push((*et, src, n));
2785                    }
2786                }
2787                for (et, s, d) in doomed {
2788                    self.topo.remove_edge(et, s, d);
2789                    self.edge_props.remove_edge(et, s, d);
2790                    // View maintenance: n's own view values will be cleared by
2791                    // remove_all below; only update surviving neighbors.
2792                    self.view_store.on_edge_changed(
2793                        et,
2794                        s,
2795                        d,
2796                        false,
2797                        &mut self.props,
2798                        &build_topo_view(&self.topo, &self.base),
2799                        &self.ids,
2800                        &self.syms,
2801                        &self.labels,
2802                        self.base.as_ref().map(|b| {
2803                            b.columns()
2804                                .expect("base columns section bounds validated at open")
2805                        }),
2806                    );
2807                }
2808
2809                // (3) Drop every remaining prop (`ColumnStore::remove_all`).
2810                self.props.remove_all(n);
2811                // Full-text index maintenance: remove all tokens for this node.
2812                self.fulltext.remove_node(n);
2813
2814                // (4) Retire the dense id and stamp the label sentinel.
2815                self.ids.delete(key);
2816                if let Some(slot) = self.labels.get_mut(n as usize) {
2817                    *slot = u32::MAX;
2818                }
2819            }
2820            WalRecord::Batch(inner) => {
2821                // Apply each inner record in order through the same apply path.
2822                // Inner records are validated free of nested Batch by encode_record.
2823                for rec in inner {
2824                    self.apply(rec)?;
2825                }
2826            }
2827            WalRecord::RebuildRule { name } => {
2828                // Replay-over-snapshot idempotency: the snapshot may already
2829                // reflect a later delete_rule, so the rule is absent; skip.
2830                if !self.engine.rules().any(|r| r.name == *name) {
2831                    return Ok(());
2832                }
2833                let cursor = self.engine.pending_delta_count();
2834                let mut eng = std::mem::take(&mut self.engine);
2835                let result = {
2836                    let mut gm = make_graph_mut(
2837                        &self.ids,
2838                        &mut self.syms,
2839                        &self.labels,
2840                        build_props_view(&self.props, &self.base),
2841                        &mut self.topo,
2842                        &mut self.edge_props,
2843                    );
2844                    eng.rebuild(name, &mut gm)
2845                };
2846                self.engine = eng;
2847                result.map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
2848                // Derived-edge delta changes → view updates.
2849                if !self.view_store.is_empty() {
2850                    #[cfg(test)]
2851                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2852                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2853                    for d in &new_deltas {
2854                        self.view_store.on_edge_changed(
2855                            d.etype_sym,
2856                            d.src_id,
2857                            d.dst_id,
2858                            d.fired,
2859                            &mut self.props,
2860                            &build_topo_view(&self.topo, &self.base),
2861                            &self.ids,
2862                            &self.syms,
2863                            &self.labels,
2864                            self.base.as_ref().map(|b| {
2865                                b.columns()
2866                                    .expect("base columns section bounds validated at open")
2867                            }),
2868                        );
2869                    }
2870                }
2871            }
2872            WalRecord::CreateView { def_bytes } => {
2873                let def: ViewDef =
2874                    bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
2875                        detail: format!("CreateView def_bytes deserialize failed: {e}"),
2876                    })?;
2877                // Replay-over-snapshot idempotency: view already present → skip.
2878                if self.view_store.has_view(&def.name) {
2879                    return Ok(());
2880                }
2881                self.view_store
2882                    .create_view(
2883                        def,
2884                        &mut self.props,
2885                        &build_topo_view(&self.topo, &self.base),
2886                        &self.ids,
2887                        &self.syms,
2888                        &self.labels,
2889                    )
2890                    .map_err(|e| GraphError::RuleInvalid { detail: e })?;
2891            }
2892            WalRecord::DeleteView { name } => {
2893                // Replay-over-snapshot idempotency: view already absent → skip.
2894                if !self.view_store.has_view(name) {
2895                    return Ok(());
2896                }
2897                self.view_store
2898                    .delete_view(name, &mut self.props, &self.ids, &self.labels, &self.syms)
2899                    .map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
2900            }
2901            WalRecord::EnableFulltext { label, field } => {
2902                // Replay-over-snapshot idempotency: already enabled → skip.
2903                if self.fulltext.is_enabled(label, field) {
2904                    return Ok(());
2905                }
2906                self.fulltext.enable(label, field);
2907                // Backfill: index all live nodes of this label that have the field.
2908                let n = self.ids.len() as u32;
2909                for id in 0..n {
2910                    let Some(&sym) = self.labels.get(id as usize) else {
2911                        continue;
2912                    };
2913                    if sym == u32::MAX {
2914                        continue; // tombstoned
2915                    }
2916                    let Some(lbl) = self.syms.resolve(sym) else {
2917                        continue;
2918                    };
2919                    if lbl != label {
2920                        continue;
2921                    }
2922                    if let Some(value) = build_props_view(&self.props, &self.base)
2923                        .get(id, field)
2924                        .map(|vr| vr.into_value())
2925                    {
2926                        self.fulltext.add_tokens(id, field, &value);
2927                    }
2928                }
2929            }
2930            WalRecord::DisableFulltext { label, field } => {
2931                // Replay-over-snapshot idempotency: already disabled → skip.
2932                if !self.fulltext.is_enabled(label, field) {
2933                    return Ok(());
2934                }
2935                // If another label still indexes this field, the postings column
2936                // is kept — but it must not contain node_ids from the now-disabled
2937                // label.  Remove them before calling disable() so the field_indexed
2938                // guard inside disable() sees the correct post-removal state.
2939                if self.fulltext.field_indexed_by_other(label, field) {
2940                    if let Some(label_sym) = self.syms.get(label) {
2941                        for (node_id, &lsym) in self.labels.iter().enumerate() {
2942                            if lsym == label_sym {
2943                                self.fulltext.remove_node_field(node_id as u32, field);
2944                            }
2945                        }
2946                    }
2947                }
2948                self.fulltext.disable(label, field);
2949            }
2950            // History markers carry no replay state — rules re-derive edges
2951            // deterministically on open/replay. Skip unconditionally.
2952            WalRecord::DerivedEdgeAdded { .. } | WalRecord::DerivedEdgeRetracted { .. } => {}
2953            // ── rename_node ──────────────────────────────────────────────────
2954            WalRecord::RenameNode { old_key, new_key } => {
2955                // Recovery-safe: if old_key is already gone (key was renamed
2956                // by a snapshot or a prior replay frame), skip cleanly.
2957                if self.ids.get(old_key).is_none() {
2958                    return Ok(());
2959                }
2960                // The rename only updates the key-table; the dense id, all
2961                // topo edges, props, labels, and rule state are id-indexed and
2962                // require no change.
2963                self.ids
2964                    .rename(old_key, new_key)
2965                    .map_err(|e| GraphError::Corrupt {
2966                        detail: format!("wal replay RenameNode {old_key}→{new_key}: {e}"),
2967                    })?;
2968            }
2969        }
2970        Ok(())
2971    }
2972
2973    /// Intern `s` in `syms` and emit a WAL `Intern` record so `*Id` records
2974    /// replay on WAL-only `open_at` (no snapshot intern table). Apply is
2975    /// idempotent when the string is already bound. Always emit: after
2976    /// `snapshot()` the WAL is truncated and live intern is not on disk.
2977    fn intern_wal(&mut self, s: &str) -> (u32, WalRecord) {
2978        let id = if let Some(id) = self.syms.get(s) {
2979            id
2980        } else {
2981            self.syms.intern(s)
2982        };
2983        (
2984            id,
2985            WalRecord::Intern {
2986                id,
2987                text: s.to_string(),
2988            },
2989        )
2990    }
2991
2992    /// Rewrite user-facing records into dense-id records. On `Err`, no live
2993    /// state is left mutated: speculative interns made while building the
2994    /// output are rolled back, so a later successful mutation cannot log an
2995    /// `Intern` record whose id replay would never reproduce.
2996    fn rewrite_wal_dense(&mut self, recs: Vec<WalRecord>) -> Result<Vec<WalRecord>> {
2997        let syms_checkpoint = self.syms.len();
2998        let result = self.rewrite_wal_dense_inner(recs);
2999        if result.is_err() {
3000            self.syms.truncate(syms_checkpoint);
3001        }
3002        result
3003    }
3004
3005    fn rewrite_wal_dense_inner(&mut self, recs: Vec<WalRecord>) -> Result<Vec<WalRecord>> {
3006        let mut out = Vec::with_capacity(recs.len());
3007        // Node ids allocated by later apply(InsertNodeId) in this same batch.
3008        let mut pending: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
3009        let mut interned = std::collections::HashSet::<u32>::new();
3010        let mut next = u32::try_from(self.ids.len()).map_err(|_| GraphError::Corrupt {
3011            detail: "id space exhausted".into(),
3012        })?;
3013        let lookup = |ids: &IdMap,
3014                      pending: &std::collections::HashMap<String, u32>,
3015                      key: &str|
3016         -> Option<u32> { ids.get(key).or_else(|| pending.get(key).copied()) };
3017        for rec in recs {
3018            match rec {
3019                WalRecord::InsertNode { label, key, props } => {
3020                    let (label_id, intern) = self.intern_wal(&label);
3021                    if interned.insert(label_id) {
3022                        out.push(intern);
3023                    }
3024                    let mut props_id = Vec::with_capacity(props.len());
3025                    for (field, value) in props {
3026                        let (field_id, intern) = self.intern_wal(&field);
3027                        if interned.insert(field_id) {
3028                            out.push(intern);
3029                        }
3030                        props_id.push((field_id, value));
3031                    }
3032                    if lookup(&self.ids, &pending, &key).is_none() {
3033                        pending.insert(key.clone(), next);
3034                        next = next.checked_add(1).ok_or_else(|| GraphError::Corrupt {
3035                            detail: "id space exhausted".into(),
3036                        })?;
3037                    }
3038                    out.push(WalRecord::InsertNodeId {
3039                        label: label_id,
3040                        key,
3041                        props: props_id,
3042                    });
3043                }
3044                WalRecord::SetProp { key, field, value } => {
3045                    let id =
3046                        lookup(&self.ids, &pending, &key).ok_or_else(|| GraphError::Corrupt {
3047                            detail: format!("dense WAL rewrite missing key {key}"),
3048                        })?;
3049                    let (field_id, intern) = self.intern_wal(&field);
3050                    if interned.insert(field_id) {
3051                        out.push(intern);
3052                    }
3053                    out.push(WalRecord::SetPropId {
3054                        id,
3055                        field: field_id,
3056                        value,
3057                    });
3058                }
3059                WalRecord::InsertEdge {
3060                    edge_type,
3061                    src_key,
3062                    dst_key,
3063                } => {
3064                    let (etype, intern) = self.intern_wal(&edge_type);
3065                    if interned.insert(etype) {
3066                        out.push(intern);
3067                    }
3068                    let src = lookup(&self.ids, &pending, &src_key).ok_or_else(|| {
3069                        GraphError::Corrupt {
3070                            detail: format!("dense WAL rewrite missing src {src_key}"),
3071                        }
3072                    })?;
3073                    let dst = lookup(&self.ids, &pending, &dst_key).ok_or_else(|| {
3074                        GraphError::Corrupt {
3075                            detail: format!("dense WAL rewrite missing dst {dst_key}"),
3076                        }
3077                    })?;
3078                    out.push(WalRecord::InsertEdgeId { etype, src, dst });
3079                }
3080                WalRecord::RenameNode {
3081                    ref old_key,
3082                    ref new_key,
3083                } => {
3084                    // Track the rename in `pending` so subsequent InsertEdge /
3085                    // SetProp records in this batch can resolve the new key.
3086                    let id = lookup(&self.ids, &pending, old_key).ok_or_else(|| {
3087                        GraphError::Corrupt {
3088                            detail: format!(
3089                                "dense WAL rewrite: RenameNode old key {old_key} not found"
3090                            ),
3091                        }
3092                    })?;
3093                    pending.remove(old_key.as_str());
3094                    pending.insert(new_key.clone(), id);
3095                    out.push(rec);
3096                }
3097                other => out.push(other),
3098            }
3099        }
3100        Ok(out)
3101    }
3102
3103    fn log_dense(&mut self, recs: Vec<WalRecord>) -> Result<()> {
3104        let recs = self.rewrite_wal_dense(recs)?;
3105        match recs.len() {
3106            0 => Ok(()),
3107            1 => self.log_then_apply(recs.into_iter().next().unwrap()),
3108            _ => self.log_then_apply(WalRecord::Batch(recs)),
3109        }
3110    }
3111
3112    /// Durable write, then notify the event sink. Replay (`apply` during
3113    /// `open`) never enters this function, so it is the replay-silent seam.
3114    fn log_then_apply(&mut self, rec: WalRecord) -> Result<()> {
3115        self.log_then_apply_with(rec, None, self.fsync)
3116    }
3117
3118    /// Whether this frame must fsync under `policy`.
3119    ///
3120    /// Batched contract: user-visible batches (>1 mutation) fsync; single
3121    /// mutations do not. The dense rewrite wraps a single mutation in a
3122    /// `Batch([Intern.., <one *Id record>])`, so `Intern` records are excluded
3123    /// from the count — removing that filter would make every single-op write
3124    /// fsync under Batched (or, if the threshold were raised instead, skip a
3125    /// needed fsync for real two-op batches).
3126    fn wal_needs_sync(policy: FsyncPolicy, rec: &WalRecord) -> bool {
3127        match policy {
3128            FsyncPolicy::Relaxed => false,
3129            FsyncPolicy::Strict => true,
3130            FsyncPolicy::Batched => match rec {
3131                // Intern + one mutation is the single-op rewrite, not a user batch.
3132                WalRecord::Batch(inner) => {
3133                    inner
3134                        .iter()
3135                        .filter(|r| !matches!(r, WalRecord::Intern { .. }))
3136                        .count()
3137                        > 1
3138                }
3139                _ => false,
3140            },
3141        }
3142    }
3143
3144    /// # Apply-infallibility invariant (load-bearing)
3145    ///
3146    /// The ordering is: WAL append → fsync → apply. If `apply` returned `Err`
3147    /// for a `Batch` frame after a successful WAL write, the WAL would contain
3148    /// the full frame while in-memory state would reflect only the ops before
3149    /// the failure. On reopen, WAL replay would then apply the entire batch —
3150    /// diverging permanently from what the pre-crash process had in memory.
3151    ///
3152    /// For `Batch` frames this situation cannot arise because:
3153    /// - All validation runs via `commit_logged_batch`/`MutPreview` **before**
3154    ///   the WAL write. `MutPreview` uses the same `&mut self` that apply will
3155    ///   use, with no concurrent mutation between validation exit and apply entry.
3156    /// - Every `apply` arm for a validated op is either infallible by construction
3157    ///   (`InsertNode`, `RemoveProp`, `DeleteEdge`, `DeleteNode`), has idempotency
3158    ///   guards that return `Ok(())` (`CreateRule`, `DeleteRule`), or is
3159    ///   guaranteed-present by validation (`InsertEdge`/`SetProp` key lookups).
3160    /// - `on_node_changed` and `on_node_removed` return `()` — never `Err`.
3161    ///
3162    /// A `debug_assert!` below fires in debug builds if `apply` ever returns
3163    /// `Err` for a `Batch` frame, making any future regression immediately visible
3164    /// in tests rather than silently diverging crash-recovery behaviour.
3165    fn log_then_apply_with(
3166        &mut self,
3167        rec: WalRecord,
3168        ingest: Option<(String, usize)>,
3169        policy: FsyncPolicy,
3170    ) -> Result<()> {
3171        // Read-only guard: as-of instances must never write the WAL.
3172        if self.read_only {
3173            return Err(GraphError::ReadOnly);
3174        }
3175        // Degraded guard: fsync failure left WAL truncated; in-memory state
3176        // is ahead of the on-disk WAL, so further mutations would deepen the
3177        // divergence.  Reopen the database to recover.
3178        if self.degraded {
3179            return Err(GraphError::Io(std::io::Error::other(
3180                "database degraded after group-commit fsync failure; reopen required",
3181            )));
3182        }
3183        // Ensure retained provenance bytes are decoded into the live mutable
3184        // fields before any mutation touches self.engine.provenance.  This is a
3185        // no-op if provenance was never stored (fresh store) or has already been
3186        // consumed (subsequent mutations).  WAL replay calls apply() directly
3187        // and is covered by consume_retained_state_eager before replay.
3188        self.ensure_v8_base_sections_loaded();
3189        self.engine.ensure_provenance_loaded_mut();
3190        // Invariant (I-1): no stale deltas may enter from a previous apply.
3191        // If any engine method ever accumulates deltas before erroring, they would
3192        // contaminate the *next* commit's event stream. This assert fires in debug
3193        // builds, making any future regression visible at the earliest point.
3194        debug_assert_eq!(
3195            self.engine.pending_delta_count(),
3196            0,
3197            "stale engine deltas at log_then_apply_with entry — \
3198             a previous apply arm may have accumulated deltas before erroring; \
3199             the caller must drain_deltas() on any error path before returning"
3200        );
3201        self.fs.append(FileId::Wal, &encode_record(&rec))?;
3202        if Self::wal_needs_sync(policy, &rec) {
3203            self.fs.sync(FileId::Wal)?;
3204        }
3205        // Marker writing always needs the engine deltas, but the engine only
3206        // accumulates them when emit_deltas is true (normally gated on subscribers
3207        // or views being present).  Enable emission for this apply if it is
3208        // currently off, then restore the original state unconditionally via an
3209        // RAII guard — this prevents a panic in apply() from leaking the flag.
3210        struct RestoreEmitDeltas(*mut RuleEngine, bool);
3211        impl Drop for RestoreEmitDeltas {
3212            fn drop(&mut self) {
3213                // SAFETY: pointer into self (GraphDb); guard is dropped within
3214                // this frame before log_then_apply_with returns.
3215                unsafe { (*self.0).set_emit_deltas(self.1) };
3216            }
3217        }
3218        let original_emit = self.engine.emit_deltas();
3219        if !original_emit {
3220            self.engine.set_emit_deltas(true);
3221        }
3222        // SAFETY: raw pointer into self; guard dropped within this frame.
3223        let _emit_guard = RestoreEmitDeltas(&mut self.engine as *mut _, original_emit);
3224
3225        let apply_result = self.apply(&rec);
3226        // For Batch frames, post-validation apply must be infallible (see above).
3227        // A debug_assert here catches any future change that makes apply fallible
3228        // before the caller notices via silent WAL/memory divergence.
3229        if matches!(&rec, WalRecord::Batch(_)) {
3230            debug_assert!(
3231                apply_result.is_ok(),
3232                "Batch apply returned Err after successful WAL write — \
3233                 the validate-then-apply invariant has been violated; \
3234                 see log_then_apply_with invariant doc"
3235            );
3236        }
3237        if apply_result.is_err() {
3238            // Discard any partial deltas accumulated by the failed apply.
3239            // They must not ride the next commit's event stream (I-1).
3240            // _emit_guard restores emit_deltas on drop automatically.
3241            let _ = self.engine.drain_deltas();
3242            let _ = self.engine.take_rebuild_needed();
3243            apply_result?;
3244        }
3245        self.commit_seq += 1;
3246        let seq = self.commit_seq;
3247        // Update per-node last-change map for the committed record.
3248        // Must happen after commit_seq is incremented so the seq is correct.
3249        self.update_last_change_from_rec(&rec, seq);
3250        // Drain engine deltas and distribute to subscribers before the existing
3251        // MutationEvent sink fires — both happen post-fsync, post-apply.
3252        // _emit_guard restores emit_deltas after this line when it drops.
3253        let engine_deltas = self.engine.drain_deltas();
3254
3255        // Append history-marker WAL records for any derived-edge changes so
3256        // that `edge_history` and `was_linked` can surface rule-attributed
3257        // events. Markers are STATE NO-OPS during replay; they are written
3258        // without an additional fsync (the triggering commit's sync already
3259        // happened; the next commit's sync covers these lazily).
3260        if !engine_deltas.is_empty() {
3261            let markers: Vec<WalRecord> = engine_deltas
3262                .iter()
3263                .map(|d| {
3264                    if d.fired {
3265                        WalRecord::DerivedEdgeAdded {
3266                            rule: d.rule.clone(),
3267                            edge_type: d.edge_type.clone(),
3268                            src_key: d.src_key.clone(),
3269                            dst_key: d.dst_key.clone(),
3270                        }
3271                    } else {
3272                        WalRecord::DerivedEdgeRetracted {
3273                            rule: d.rule.clone(),
3274                            edge_type: d.edge_type.clone(),
3275                            src_key: d.src_key.clone(),
3276                            dst_key: d.dst_key.clone(),
3277                        }
3278                    }
3279                })
3280                .collect();
3281            let marker_frame = if markers.len() == 1 {
3282                markers.into_iter().next().unwrap()
3283            } else {
3284                WalRecord::Batch(markers)
3285            };
3286            // Ignore append errors: markers are best-effort history
3287            // annotations. Losing them does not affect state correctness.
3288            let _ = self.fs.append(FileId::Wal, &encode_record(&marker_frame));
3289        }
3290
3291        // Record MVCC CommitDelta for the epoch reader.  The WAL record is
3292        // stored as-is (including any nested Batch / Intern records); the
3293        // ReaderSnapshot's apply_one function handles all variants.
3294        {
3295            let derived_inserts = engine_deltas
3296                .iter()
3297                .filter(|d| d.fired)
3298                .map(|d| (d.etype_sym, d.src_id, d.dst_id))
3299                .collect();
3300            let derived_deletes = engine_deltas
3301                .iter()
3302                .filter(|d| !d.fired)
3303                .map(|d| (d.etype_sym, d.src_id, d.dst_id))
3304                .collect();
3305            let delta = Arc::new(crate::reader::CommitDelta {
3306                records: vec![rec.clone()],
3307                derived_inserts,
3308                derived_deletes,
3309            });
3310            self.delta_tail.push(delta);
3311            self.commits_since_fold += 1;
3312            if self.commits_since_fold >= crate::reader::FOLD_EVERY_K {
3313                self.fold_now();
3314            }
3315        }
3316
3317        if self.defer_events {
3318            // Group-commit drain thread: hold events until after the group
3319            // fsync so subscribers only observe durable data (R2).
3320            self.deferred_events.push(DeferredEvent {
3321                rec: rec.clone(),
3322                engine_deltas,
3323                seq,
3324                ingest,
3325            });
3326        } else {
3327            self.distribute_events(&rec, &engine_deltas, seq);
3328            self.emit_committed(&rec, ingest);
3329        }
3330        // Drift is only known after apply, so auto-rebuild cannot join the
3331        // triggering op's WAL frame. Issue RebuildRule as a second commit.
3332        // Skip when `rec` is itself RebuildRule: rebuild resets drift, so a
3333        // retrigger loop is impossible if the fit succeeded, but we still
3334        // drain the flag so a leftover cannot re-enter.
3335        let rebuilds = self.engine.take_rebuild_needed();
3336        if !matches!(&rec, WalRecord::RebuildRule { .. }) {
3337            let mut failed = Vec::new();
3338            for name in rebuilds {
3339                if self.engine.rules().any(|r| r.name == name) {
3340                    // User op is already durable. A failed second commit must
3341                    // not surface as the caller's error.
3342                    if let Err(e) =
3343                        self.log_then_apply(WalRecord::RebuildRule { name: name.clone() })
3344                    {
3345                        eprintln!(
3346                            "auto-rebuild of rule {name:?} failed after durable user commit: {e}"
3347                        );
3348                        failed.push(name);
3349                    }
3350                }
3351            }
3352            for name in failed {
3353                self.engine.queue_rebuild_needed(name);
3354            }
3355        }
3356        Ok(())
3357    }
3358
3359    /// Install a post-commit hook. Replaces any previous sink.
3360    ///
3361    /// The sink runs inside `log_then_apply` after a successful
3362    /// durable commit, while the caller still holds `&mut self`. When this
3363    /// database is behind a [`crate::SharedDb`], that means the **write
3364    /// guard is held**. The sink must never call `read` / `write` (or any
3365    /// other method) on the same `SharedDb` — the `RwLock` is not
3366    /// re-entrant and doing so deadlocks. The sink is `Send + Sync`;
3367    /// `std::sync::mpsc::Sender` is not `Sync` and will not type-check.
3368    /// Intended examples: `std::sync::mpsc::SyncSender`,
3369    /// `tokio::sync::mpsc::Sender`, `tokio::sync::broadcast::Sender`
3370    /// (non-blocking `send`), or `Arc<Mutex<Vec<MutationEvent>>>`.
3371    pub fn set_event_sink(&mut self, sink: Box<dyn Fn(MutationEvent) + Send + Sync>) {
3372        self.event_sink = Some(sink);
3373    }
3374
3375    /// Whether a post-commit event sink is currently installed.
3376    pub fn has_event_sink(&self) -> bool {
3377        self.event_sink.is_some()
3378    }
3379
3380    /// Set WAL fsync cadence. Default [`FsyncPolicy::Strict`].
3381    pub fn set_fsync_policy(&mut self, p: FsyncPolicy) {
3382        self.fsync = p;
3383    }
3384
3385    /// Return the current WAL fsync cadence.
3386    pub fn fsync_policy(&self) -> FsyncPolicy {
3387        self.fsync
3388    }
3389
3390    // ── Group-commit event deferral ───────────────────────────────────────────
3391
3392    /// Enable or disable deferred event mode.
3393    ///
3394    /// When `true`, event notifications (subscription `DbEvent`s and legacy
3395    /// `MutationEvent` sink calls) are buffered rather than fired immediately.
3396    /// Call [`flush_deferred_events`] after the group fsync to deliver them,
3397    /// or [`discard_deferred_events`] if the fsync failed and the group must
3398    /// be treated as lost.
3399    pub fn set_deferred_events_mode(&mut self, defer: bool) {
3400        self.defer_events = defer;
3401    }
3402
3403    /// Fire all buffered events accumulated since [`set_deferred_events_mode`]
3404    /// was set to true.  Clears the buffer.
3405    ///
3406    /// Called by the drain thread AFTER a successful group fsync, so
3407    /// subscribers observe only data that is durably on disk.
3408    pub fn flush_deferred_events(&mut self) {
3409        let events = std::mem::take(&mut self.deferred_events);
3410        for de in events {
3411            self.distribute_events(&de.rec, &de.engine_deltas, de.seq);
3412            self.emit_committed(&de.rec, de.ingest);
3413        }
3414    }
3415
3416    /// Discard all buffered events without firing them.
3417    ///
3418    /// Called by the drain thread when a group fsync fails: the WAL has been
3419    /// truncated back to the pre-group offset, so the committed-but-unsynced
3420    /// ops must not be observable to subscribers.
3421    pub fn discard_deferred_events(&mut self) {
3422        self.deferred_events.clear();
3423    }
3424
3425    // ── Degraded state ────────────────────────────────────────────────────────
3426
3427    /// Mark this database as degraded.
3428    ///
3429    /// Called by the group-commit drain thread after a group fsync failure and
3430    /// WAL truncation: the in-memory state is now ahead of the on-disk WAL, so
3431    /// further mutations would deepen the divergence.  All subsequent calls to
3432    /// [`log_then_apply_with`] return `Err` until the database is reopened.
3433    pub fn set_degraded(&mut self) {
3434        self.degraded = true;
3435    }
3436
3437    fn emit(&self, ev: MutationEvent) {
3438        if let Some(sink) = &self.event_sink {
3439            sink(ev);
3440        }
3441    }
3442
3443    fn emit_committed(&self, rec: &WalRecord, ingest: Option<(String, usize)>) {
3444        match rec {
3445            WalRecord::Batch(inner) => {
3446                for r in inner {
3447                    if let Some(ev) = event_from_record(r, &self.syms, &self.ids) {
3448                        self.emit(ev);
3449                    }
3450                }
3451                match ingest {
3452                    Some((label, inserted)) => {
3453                        self.emit(MutationEvent::Ingested { label, inserted })
3454                    }
3455                    None => {
3456                        let ops = inner
3457                            .iter()
3458                            .filter(|r| !matches!(r, WalRecord::Intern { .. }))
3459                            .count();
3460                        if ops > 1 {
3461                            self.emit(MutationEvent::BatchApplied { ops });
3462                        }
3463                    }
3464                }
3465            }
3466            other => {
3467                if let Some(ev) = event_from_record(other, &self.syms, &self.ids) {
3468                    self.emit(ev);
3469                }
3470            }
3471        }
3472    }
3473
3474    // -----------------------------------------------------------------------
3475    // Subscription API
3476    // -----------------------------------------------------------------------
3477
3478    /// Distribute post-commit events to all live subscribers.
3479    ///
3480    /// Build a row-key → row-data map from a [`ResultSet`].
3481    ///
3482    /// Each row is serialized to JSON to form its key; a debug fallback is used
3483    /// if serialization fails. Used by both the initial-seed path in
3484    /// [`Self::subscribe_query`] and the per-commit diff path in
3485    /// [`Self::distribute_events`] to keep the two in sync.
3486    fn result_to_row_map(
3487        result: &core_query::ResultSet,
3488    ) -> std::collections::HashMap<String, Vec<Option<Value>>> {
3489        (0..result.len())
3490            .map(|i| {
3491                let row = result.row(i).to_vec();
3492                let key = serde_json::to_string(&row).unwrap_or_else(|_| format!("{row:?}"));
3493                (key, row)
3494            })
3495            .collect()
3496    }
3497
3498    /// Distribute post-commit events to all live subscribers.
3499    ///
3500    /// Called from `log_then_apply_with` after apply + fsync, before the
3501    /// legacy MutationEvent sink. Prunes dead `Weak` entries in-place.
3502    ///
3503    /// Query subscriptions (subscribe_query) re-execute their plan on every
3504    /// call and diff the result against the previous run. Zero overhead when
3505    /// no query subscriptions are active.
3506    fn distribute_events(&mut self, rec: &WalRecord, engine_deltas: &[EngineEdgeDelta], seq: u64) {
3507        if self.subscriptions.is_empty() && self.query_subscriptions.is_empty() {
3508            return;
3509        }
3510
3511        if !self.subscriptions.is_empty() {
3512            // Build write events from the WAL record.
3513            let write_events: Vec<DbEvent> =
3514                Self::write_events_from_record(rec, seq, &self.syms, &self.ids);
3515
3516            // Build edge events from engine deltas.  Weight is looked up from
3517            // edge_props at distribution time (after apply), so it's always fresh.
3518            let edge_events: Vec<DbEvent> = engine_deltas
3519                .iter()
3520                .map(|d| {
3521                    if d.fired {
3522                        let weight = self
3523                            .edge_props
3524                            .get(d.etype_sym, d.src_id, d.dst_id, "weight")
3525                            .and_then(|v| {
3526                                if let core_storage::Value::Float(f) = v {
3527                                    Some(*f)
3528                                } else {
3529                                    None
3530                                }
3531                            });
3532                        DbEvent::EdgeFired {
3533                            rule: d.rule.clone(),
3534                            src_key: d.src_key.clone(),
3535                            dst_key: d.dst_key.clone(),
3536                            edge_type: d.edge_type.clone(),
3537                            weight,
3538                            commit_seq: seq,
3539                        }
3540                    } else {
3541                        DbEvent::EdgeRetracted {
3542                            rule: d.rule.clone(),
3543                            src_key: d.src_key.clone(),
3544                            dst_key: d.dst_key.clone(),
3545                            edge_type: d.edge_type.clone(),
3546                            commit_seq: seq,
3547                        }
3548                    }
3549                })
3550                .collect();
3551
3552            // Prune dead entries; push matching events to live ones.
3553            self.subscriptions.retain(|entry| {
3554                let Some(inner) = entry.inner.upgrade() else {
3555                    return false;
3556                };
3557                for ev in &write_events {
3558                    if event_matches(ev, &entry.filter) {
3559                        inner.push(ev.clone());
3560                    }
3561                }
3562                for ev in &edge_events {
3563                    if event_matches(ev, &entry.filter) {
3564                        inner.push(ev.clone());
3565                    }
3566                }
3567                true
3568            });
3569
3570            // Turn off delta accumulation if all subscribers dropped and no views remain.
3571            if self.subscriptions.is_empty() && self.view_store.is_empty() {
3572                self.engine.set_emit_deltas(false);
3573            }
3574        }
3575
3576        // Query subscriptions: full re-run per commit, then diff rows.
3577        // IMPORTANT: full re-execution on every commit — use LIMIT to bound cost.
3578        // Differential evaluation is roadmap / Phase 5.
3579        if !self.query_subscriptions.is_empty() {
3580            // Take the list out so we can call self.view() without borrow conflict.
3581            let mut query_subs = std::mem::take(&mut self.query_subscriptions);
3582            let empty_params = BTreeMap::new();
3583            query_subs.retain_mut(|entry| {
3584                let Some(inner) = entry.inner.upgrade() else {
3585                    return false; // subscriber dropped — prune
3586                };
3587                let result = match execute(&self.view(), &entry.ops, &Params(&empty_params)) {
3588                    Ok(r) => r,
3589                    Err(e) => {
3590                        // Keep the subscription alive; skip the diff for this commit.
3591                        // Re-run errors are transient (e.g., planner change) and
3592                        // self-heal when the next commit succeeds.
3593                        eprintln!("[mushroomdb] subscribe_query re-run failed: {e}");
3594                        return true;
3595                    }
3596                };
3597                // Build new row map: serialized-key → row data.
3598                let new_row_map = Self::result_to_row_map(&result);
3599                // Removed rows: in prev but not in new.
3600                for (key, row) in &entry.prev_row_map {
3601                    if !new_row_map.contains_key(key) {
3602                        inner.push(DbEvent::QueryRowRemoved {
3603                            columns: entry.columns.clone(),
3604                            row: row.clone(),
3605                        });
3606                    }
3607                }
3608                // Added rows: in new but not in prev.
3609                for (key, row) in &new_row_map {
3610                    if !entry.prev_row_map.contains_key(key) {
3611                        inner.push(DbEvent::QueryRowAdded {
3612                            columns: entry.columns.clone(),
3613                            row: row.clone(),
3614                        });
3615                    }
3616                }
3617                entry.prev_row_map = new_row_map;
3618                true
3619            });
3620            self.query_subscriptions = query_subs;
3621        }
3622    }
3623
3624    /// Returns `true` if any live subscriber or view definition requires delta
3625    /// accumulation. Used to set `engine.emit_deltas` on subscribe/view DDL.
3626    fn needs_emit_deltas(&self) -> bool {
3627        !self.view_store.is_empty()
3628            || self
3629                .subscriptions
3630                .iter()
3631                .any(|e| e.inner.upgrade().is_some())
3632    }
3633
3634    /// Convert a WAL record into `DbEvent` write events with the given seq.
3635    fn write_events_from_record(
3636        rec: &WalRecord,
3637        seq: u64,
3638        intern: &Interner,
3639        ids: &IdMap,
3640    ) -> Vec<DbEvent> {
3641        match rec {
3642            WalRecord::InsertNode { label, key, .. } => vec![DbEvent::NodeInserted {
3643                label: label.clone(),
3644                key: key.clone(),
3645                commit_seq: seq,
3646            }],
3647            // *Id arms run after a successful apply, so resolution can only
3648            // fail on a programming error. Skip the event rather than emit a
3649            // fabricated "" that clients can't tell from a real empty value
3650            // (mirrors event_from_record returning None).
3651            WalRecord::InsertNodeId { label, key, .. } => intern
3652                .resolve(*label)
3653                .map(|label| DbEvent::NodeInserted {
3654                    label: label.to_string(),
3655                    key: key.clone(),
3656                    commit_seq: seq,
3657                })
3658                .into_iter()
3659                .collect(),
3660            WalRecord::SetProp { key, field, .. } => vec![DbEvent::PropSet {
3661                key: key.clone(),
3662                field: field.clone(),
3663                commit_seq: seq,
3664            }],
3665            WalRecord::SetPropId { id, field, .. } => ids
3666                .key_of(*id)
3667                .zip(intern.resolve(*field))
3668                .map(|(key, field)| DbEvent::PropSet {
3669                    key: key.to_string(),
3670                    field: field.to_string(),
3671                    commit_seq: seq,
3672                })
3673                .into_iter()
3674                .collect(),
3675            WalRecord::RemoveProp { key, field } => vec![DbEvent::PropRemoved {
3676                key: key.clone(),
3677                field: field.clone(),
3678                commit_seq: seq,
3679            }],
3680            WalRecord::InsertEdge {
3681                edge_type,
3682                src_key,
3683                dst_key,
3684            } => vec![DbEvent::EdgeInserted {
3685                edge_type: edge_type.clone(),
3686                src: src_key.clone(),
3687                dst: dst_key.clone(),
3688                commit_seq: seq,
3689            }],
3690            WalRecord::InsertEdgeId { etype, src, dst } => (|| {
3691                Some(DbEvent::EdgeInserted {
3692                    edge_type: intern.resolve(*etype)?.to_string(),
3693                    src: ids.key_of(*src)?.to_string(),
3694                    dst: ids.key_of(*dst)?.to_string(),
3695                    commit_seq: seq,
3696                })
3697            })()
3698            .into_iter()
3699            .collect(),
3700            WalRecord::DeleteEdge {
3701                edge_type,
3702                src_key,
3703                dst_key,
3704            } => vec![DbEvent::EdgeDeleted {
3705                edge_type: edge_type.clone(),
3706                src: src_key.clone(),
3707                dst: dst_key.clone(),
3708                commit_seq: seq,
3709            }],
3710            WalRecord::DeleteNode { key } => vec![DbEvent::NodeDeleted {
3711                key: key.clone(),
3712                commit_seq: seq,
3713            }],
3714            WalRecord::Batch(inner) => inner
3715                .iter()
3716                .flat_map(|r| Self::write_events_from_record(r, seq, intern, ids))
3717                .collect(),
3718            WalRecord::CreateRule { .. }
3719            | WalRecord::DeleteRule { .. }
3720            | WalRecord::RebuildRule { .. }
3721            | WalRecord::CreateView { .. }
3722            | WalRecord::DeleteView { .. }
3723            | WalRecord::EnableFulltext { .. }
3724            | WalRecord::DisableFulltext { .. }
3725            | WalRecord::Intern { .. }
3726            // History markers produce no DbEvent — the engine delta already
3727            // fired the EdgeFired/EdgeRetracted subscription events.
3728            | WalRecord::DerivedEdgeAdded { .. }
3729            | WalRecord::DerivedEdgeRetracted { .. }
3730            | WalRecord::RenameNode { .. } => vec![],
3731        }
3732    }
3733
3734    /// Subscribe to edge-fire and edge-retract events for one named rule.
3735    ///
3736    /// Returns `Err(GraphError::RuleNotFound)` if `rule_name` is not
3737    /// currently registered. Dropping the returned [`Subscription`] handle
3738    /// unregisters the subscriber — no further events are queued, no
3739    /// resources leak.
3740    pub fn subscribe_rule(&mut self, rule_name: &str) -> core_storage::Result<Subscription> {
3741        if self.read_only {
3742            return Err(core_storage::GraphError::ReadOnly);
3743        }
3744        if !self.engine.rules().any(|r| r.name == rule_name) {
3745            return Err(core_storage::GraphError::RuleNotFound {
3746                name: rule_name.to_string(),
3747            });
3748        }
3749        let inner = SubInner::new(self.sub_capacity());
3750        self.subscriptions.push(SubEntry {
3751            filter: SubFilter::Rule(rule_name.to_string()),
3752            inner: std::sync::Arc::downgrade(&inner),
3753        });
3754        self.engine.set_emit_deltas(true);
3755        Ok(Subscription(inner))
3756    }
3757
3758    /// Subscribe to edge-fire and edge-retract events for **all** rules.
3759    ///
3760    /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
3761    /// as-of instances never commit, so `distribute_events` never runs and the
3762    /// subscription would never deliver events.
3763    pub fn subscribe_all_rules(&mut self) -> core_storage::Result<Subscription> {
3764        if self.read_only {
3765            return Err(core_storage::GraphError::ReadOnly);
3766        }
3767        let inner = SubInner::new(self.sub_capacity());
3768        self.subscriptions.push(SubEntry {
3769            filter: SubFilter::AllRules,
3770            inner: std::sync::Arc::downgrade(&inner),
3771        });
3772        self.engine.set_emit_deltas(true);
3773        Ok(Subscription(inner))
3774    }
3775
3776    /// Subscribe to write events: node insert/delete, prop set/remove.
3777    ///
3778    /// Does not include edge-fire / edge-retract (rule-derived edge events).
3779    ///
3780    /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
3781    /// as-of instances never commit, so `distribute_events` never runs and the
3782    /// subscription would never deliver events.
3783    pub fn subscribe_writes(&mut self) -> core_storage::Result<Subscription> {
3784        if self.read_only {
3785            return Err(core_storage::GraphError::ReadOnly);
3786        }
3787        let inner = SubInner::new(self.sub_capacity());
3788        self.subscriptions.push(SubEntry {
3789            filter: SubFilter::Writes,
3790            inner: std::sync::Arc::downgrade(&inner),
3791        });
3792        self.engine.set_emit_deltas(true);
3793        Ok(Subscription(inner))
3794    }
3795
3796    /// Subscribe to incremental Cypher query results.
3797    ///
3798    /// Parses and plans `cypher`; rejects the query if the plan is not in the
3799    /// allowlisted subset (see [`core_query::cypher::is_subscribable`]):
3800    ///   - `MATCH (n:Label) WHERE … RETURN … [LIMIT n]`
3801    ///   - `MATCH (a)-[r:TYPE]->(b) RETURN … [LIMIT n]`  (exactly one hop)
3802    ///
3803    /// SKIP is not supported — it shifts the result window on every commit,
3804    /// causing spurious Added/Removed churn for rows whose data never changed.
3805    /// Multi-hop Expand chains are not supported; each additional MATCH clause
3806    /// widens scope beyond the documented single-scan / single-hop subset.
3807    ///
3808    /// After each successful commit, the plan is **fully re-executed** and the
3809    /// result is diffed against the previous run. Added rows produce
3810    /// [`DbEvent::QueryRowAdded`]; removed rows produce
3811    /// [`DbEvent::QueryRowRemoved`].
3812    ///
3813    /// **Full re-run per commit; use LIMIT to bound execution cost.**
3814    /// The existing 1 M intermediate-row cap applies. Differential evaluation
3815    /// is roadmap / Phase 5.
3816    ///
3817    /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
3818    /// as-of instances never commit, so `distribute_events` never runs and the
3819    /// subscription would never deliver events.
3820    ///
3821    /// Returns `Err(GraphError::QueryError)` if the query fails to parse, plan,
3822    /// or if the plan shape is not in the allowlist.
3823    pub fn subscribe_query(&mut self, cypher: &str) -> Result<Subscription> {
3824        if self.read_only {
3825            return Err(GraphError::ReadOnly);
3826        }
3827        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
3828            detail: format!("lex: {e}"),
3829        })?;
3830        let ast = parse(&tokens).map_err(|e| GraphError::QueryError {
3831            detail: format!("parse: {e}"),
3832        })?;
3833        let ops = plan(&ast).map_err(|e| GraphError::QueryError {
3834            detail: format!("plan: {e}"),
3835        })?;
3836        if !is_subscribable(&ops) {
3837            return Err(GraphError::QueryError {
3838                detail: "subscribe_query only supports allowlisted plan shapes: \
3839                         MATCH (n:Label) WHERE … RETURN … [LIMIT n] or \
3840                         MATCH (a)-[r:TYPE]->(b) RETURN … [LIMIT n] (exactly one hop). \
3841                         Not supported: multi-hop Expand chains, SKIP (creates \
3842                         unstable offset windows), ORDER BY, DISTINCT, aggregates, \
3843                         variable-length paths, OPTIONAL MATCH, WITH, UNWIND. \
3844                         Use LIMIT to bound re-execution cost."
3845                    .to_string(),
3846            });
3847        }
3848        // Execute once to capture initial state (initial rows are not emitted as
3849        // events — the subscriber learns the baseline via the first query call).
3850        let empty_params = BTreeMap::new();
3851        let initial = execute(&self.view(), &ops, &Params(&empty_params)).map_err(|e| {
3852            GraphError::QueryError {
3853                detail: format!("execute: {e}"),
3854            }
3855        })?;
3856        let columns = initial.columns().to_vec();
3857        let prev_row_map = Self::result_to_row_map(&initial);
3858        let inner = SubInner::new(self.sub_capacity());
3859        self.query_subscriptions.push(QuerySubEntry {
3860            ops,
3861            columns,
3862            prev_row_map,
3863            inner: std::sync::Arc::downgrade(&inner),
3864        });
3865        Ok(Subscription(inner))
3866    }
3867
3868    /// Queue capacity used for new subscriptions.
3869    fn sub_capacity(&self) -> usize {
3870        self.sub_capacity
3871    }
3872
3873    /// Override per-subscriber queue capacity for subsequently created
3874    /// subscriptions on this db instance.
3875    ///
3876    /// Default is [`DEFAULT_SUB_CAPACITY`] (65,536 events). Use a smaller
3877    /// value in tests to exercise the [`DbEvent::Lagged`] path without
3878    /// generating tens of thousands of events.
3879    ///
3880    /// This is a test-support escape hatch. Calling it in production reduces
3881    /// subscriber reliability (more Lagged events). It is hidden from rustdoc
3882    /// to discourage accidental production use.
3883    #[doc(hidden)]
3884    pub fn set_sub_capacity(&mut self, capacity: usize) {
3885        self.sub_capacity = capacity;
3886    }
3887
3888    // -----------------------------------------------------------------------
3889
3890    /// Start an atomic batch.
3891    ///
3892    /// The returned [`BatchBuilder`] borrows `self` mutably until
3893    /// [`BatchBuilder::commit`]. Builder methods queue ops only — no
3894    /// validation, no WAL I/O. `commit` validates every queued op against
3895    /// live state plus preceding ops in this batch (duplicate key inside
3896    /// the batch is `Err`; an edge between two nodes created earlier in
3897    /// the batch is valid; `delete_node` then insert of the same key is a
3898    /// fresh identity). Validation never mutates the database. Any failure
3899    /// leaves WAL bytes and in-memory state identical to before `commit`.
3900    /// On success, one `WalRecord::Batch` frame is appended (one fsync)
3901    /// and each inner record is applied in order so rules fire per record.
3902    /// An empty batch, or a batch of only no-ops, writes zero WAL bytes.
3903    ///
3904    /// **Rule-window limitation:** batch validation cannot see edges that a
3905    /// rule created earlier in the *same* batch will derive at apply time, so
3906    /// a `delete_edge` / `insert_edge` in that window is silently no-oped
3907    /// where sequential calls would return `Err(RuleOwned)`. State integrity
3908    /// is unaffected (idempotent apply, provenance intact). Create rules in
3909    /// their own batch, or sequentially, when later ops may touch derived
3910    /// edges.
3911    pub fn batch(&mut self) -> BatchBuilder<'_, F> {
3912        BatchBuilder {
3913            db: self,
3914            ops: Vec::new(),
3915        }
3916    }
3917
3918    /// Closure-style atomic write batch.
3919    ///
3920    /// Equivalent to calling [`GraphDb::batch`], invoking `build` to queue ops,
3921    /// then committing. All ops queued inside `build` are validated in order and
3922    /// committed as a single `WalRecord::Batch` frame (one fsync). Rules fire
3923    /// once per inner record, in order, after commit — semantically identical to
3924    /// sequential single-op writes.
3925    ///
3926    /// **Error semantics — validate-then-apply.** `build` queues ops without
3927    /// touching the database. [`BatchBuilder::commit`] validates every op against
3928    /// live state plus earlier ops in this batch before writing anything. If op N
3929    /// fails validation (duplicate key, unknown key, rule-owned edge, …) the
3930    /// entire batch is rejected: no WAL bytes are written and no in-memory state
3931    /// changes. The database is identical to its state before `write_batch` was
3932    /// called.
3933    ///
3934    /// **Atomicity is crash-level, NOT isolation-level.** On replay after a crash,
3935    /// a partial (torn) `Batch` frame applies NONE of its ops — the frame is
3936    /// either fully applied or not at all. However, while applying a committed
3937    /// batch, concurrent readers may observe intermediate states as ops are applied
3938    /// sequentially in memory. There is no interactive transaction isolation in v1.
3939    /// This is documented as "crash-atomic write batches; no interactive
3940    /// transactions or read isolation."
3941    ///
3942    /// **Returns** `(nodes_inserted, edges_inserted)`. An empty or all-noop batch
3943    /// writes zero WAL bytes and returns `(0, 0)`.
3944    ///
3945    /// # Example
3946    ///
3947    /// ```rust,ignore
3948    /// let (nodes, edges) = db.write_batch(|b| {
3949    ///     b.insert_node("Person", "alice", vec![("age".into(), Value::Int(30))]);
3950    ///     b.insert_node("Person", "bob", vec![]);
3951    ///     b.insert_edge("KNOWS", "alice", "bob");
3952    ///     b.set_prop("alice", "role", Value::Str("admin".into()));
3953    ///     b.delete_node("old_key");
3954    /// })?;
3955    /// // One fsync; on crash replay: all five ops land or none do.
3956    /// ```
3957    pub fn write_batch<C>(&mut self, build: C) -> Result<(usize, usize)>
3958    where
3959        C: FnOnce(&mut BatchBuilder<'_, F>),
3960    {
3961        let mut b = self.batch();
3962        build(&mut b);
3963        b.commit()
3964    }
3965
3966    /// Insert `rows` as nodes of `label`. One call is one atomic batch:
3967    /// auto-declared KeyMatch rules (if any) first, then the accepted node
3968    /// inserts, so incremental fire sees the new rules. Per-row key problems
3969    /// are collected in [`IngestReport::row_errors`] and skipped; a commit
3970    /// `Err` means nothing was applied.
3971    ///
3972    /// Auto-FK rule names are `auto_fk_<src_label_lowercase>_<field>` so
3973    /// distinct source labels sharing an FK field each get their own rule.
3974    pub fn ingest(
3975        &mut self,
3976        label: &str,
3977        rows: Vec<BTreeMap<String, Value>>,
3978        opts: &IngestOptions,
3979    ) -> Result<IngestReport> {
3980        self.ingest_with_edges(label, rows, opts, &[])
3981    }
3982
3983    /// [`ingest`] plus user edges in the **same** previewed WAL batch.
3984    /// A failing edge rejects the whole request; nothing is applied.
3985    pub fn ingest_with_edges(
3986        &mut self,
3987        label: &str,
3988        rows: Vec<BTreeMap<String, Value>>,
3989        opts: &IngestOptions,
3990        edges: &[(String, String, String)],
3991    ) -> Result<IngestReport> {
3992        crate::ingest::run(self, label, rows, opts, edges)
3993    }
3994
3995    /// Parse `json` as an array of objects and ingest via [`GraphDb::ingest`].
3996    ///
3997    /// JSON `null` fields are silently omitted (not stored, not a row error).
3998    /// Nested objects and arrays-of-objects are a per-row error (row skipped).
3999    /// Parse failures and a top-level value that is not an array of objects
4000    /// return [`GraphError::IngestError`].
4001    pub fn ingest_json(
4002        &mut self,
4003        label: &str,
4004        json: &str,
4005        opts: &IngestOptions,
4006    ) -> Result<IngestReport> {
4007        crate::ingest::run_json(self, label, json, opts)
4008    }
4009
4010    fn commit_logged_batch(
4011        &mut self,
4012        ops: Vec<BatchOp>,
4013        ingest: Option<(String, usize)>,
4014    ) -> Result<(usize, usize)> {
4015        // Read-only guard: catches empty-batch calls before the early-return
4016        // that skips log_then_apply_with, ensuring all mutation entry points fail.
4017        if self.read_only {
4018            return Err(GraphError::ReadOnly);
4019        }
4020        // Ensure provenance is decoded before MutPreview accesses it
4021        // (note_delete_rule / is_rule_owned may call engine.provenance()).
4022        self.engine.ensure_provenance_loaded_mut();
4023        let recs = {
4024            let mut preview = MutPreview::new(self);
4025            let mut recs = Vec::with_capacity(ops.len());
4026            for op in ops {
4027                match op {
4028                    BatchOp::InsertNode { label, key, props } => {
4029                        preview.check_insert_node(&key)?;
4030                        preview.note_insert_node(&key, &props);
4031                        recs.push(WalRecord::InsertNode { label, key, props });
4032                    }
4033                    BatchOp::InsertEdge {
4034                        edge_type,
4035                        src_key,
4036                        dst_key,
4037                    } => {
4038                        if preview.prepare_insert_edge(&edge_type, &src_key, &dst_key)? {
4039                            preview.note_insert_edge(&edge_type, &src_key, &dst_key);
4040                            recs.push(WalRecord::InsertEdge {
4041                                edge_type,
4042                                src_key,
4043                                dst_key,
4044                            });
4045                        }
4046                    }
4047                    BatchOp::SetProp { key, field, value } => {
4048                        preview.check_live_key(&key)?;
4049                        preview.note_set_prop(&key, &field, &value);
4050                        recs.push(WalRecord::SetProp { key, field, value });
4051                    }
4052                    BatchOp::RemoveProp { key, field } => {
4053                        if preview.prepare_remove_prop(&key, &field)? {
4054                            preview.note_remove_prop(&key, &field);
4055                            recs.push(WalRecord::RemoveProp { key, field });
4056                        }
4057                    }
4058                    BatchOp::DeleteEdge {
4059                        edge_type,
4060                        src_key,
4061                        dst_key,
4062                    } => {
4063                        if preview.prepare_delete_edge(&edge_type, &src_key, &dst_key)? {
4064                            preview.note_delete_edge(&edge_type, &src_key, &dst_key);
4065                            recs.push(WalRecord::DeleteEdge {
4066                                edge_type,
4067                                src_key,
4068                                dst_key,
4069                            });
4070                        }
4071                    }
4072                    BatchOp::DeleteNode { key } => {
4073                        preview.check_live_key(&key)?;
4074                        preview.note_delete_node(&key);
4075                        recs.push(WalRecord::DeleteNode { key });
4076                    }
4077                    BatchOp::CreateRule(def) => {
4078                        preview.check_create_rule(&def)?;
4079                        let def_bytes =
4080                            bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
4081                                detail: format!("serialize rule: {e}"),
4082                            })?;
4083                        preview.note_create_rule(&def.name);
4084                        recs.push(WalRecord::CreateRule { def_bytes });
4085                    }
4086                    BatchOp::DeleteRule { name } => {
4087                        preview.check_delete_rule(&name)?;
4088                        preview.note_delete_rule(&name);
4089                        recs.push(WalRecord::DeleteRule { name });
4090                    }
4091                    BatchOp::RenameNode { old_key, new_key } => {
4092                        preview.check_rename_node(&old_key, &new_key)?;
4093                        preview.note_rename_node(&old_key, &new_key);
4094                        recs.push(WalRecord::RenameNode { old_key, new_key });
4095                    }
4096                    BatchOp::InsertEdgeUpsert {
4097                        edge_type,
4098                        src_key,
4099                        dst_key,
4100                        placeholder_label,
4101                    } => {
4102                        // Auto-create any missing endpoints as plain InsertNode ops.
4103                        // Rules fire and last-change is updated for each created node.
4104                        for key in [&src_key, &dst_key] {
4105                            if !preview.has_key(key) {
4106                                preview.check_insert_node(key)?;
4107                                preview.note_insert_node(key, &[]);
4108                                recs.push(WalRecord::InsertNode {
4109                                    label: placeholder_label.clone(),
4110                                    key: key.clone(),
4111                                    props: vec![],
4112                                });
4113                            }
4114                        }
4115                        if preview.prepare_insert_edge(&edge_type, &src_key, &dst_key)? {
4116                            preview.note_insert_edge(&edge_type, &src_key, &dst_key);
4117                            recs.push(WalRecord::InsertEdge {
4118                                edge_type,
4119                                src_key,
4120                                dst_key,
4121                            });
4122                        }
4123                    }
4124                }
4125            }
4126            recs
4127        };
4128        if recs.is_empty() {
4129            return Ok((0, 0));
4130        }
4131        // rewrite_wal_dense converts every InsertNode/InsertEdge into its
4132        // *Id form, so only the dense variants can appear in `recs` here.
4133        let recs = self.rewrite_wal_dense(recs)?;
4134        let nodes_inserted = recs
4135            .iter()
4136            .filter(|r| matches!(r, WalRecord::InsertNodeId { .. }))
4137            .count();
4138        let edges_inserted = recs
4139            .iter()
4140            .filter(|r| matches!(r, WalRecord::InsertEdgeId { .. }))
4141            .count();
4142        // Ingest / write_batch / query_write: one Batch frame, one fsync per call
4143        // under Strict.  Pass self.fsync directly so Strict stays Strict —
4144        // wal_needs_sync(Strict, _) always returns true regardless of op count.
4145        // Mapping Strict → Batched (the prior bug) caused wal_needs_sync to
4146        // short-circuit on single-op batches and silently skip the fsync.
4147        // Batched fsyncs only for multi-op batches; Relaxed always skips.
4148        self.log_then_apply_with(WalRecord::Batch(recs), ingest, self.fsync)?;
4149        Ok((nodes_inserted, edges_inserted))
4150    }
4151
4152    fn commit_batch(&mut self, ops: Vec<BatchOp>) -> Result<(usize, usize)> {
4153        self.commit_logged_batch(ops, None)
4154    }
4155
4156    /// Commit one submission WITHOUT an fsync — for use inside `commit_group`
4157    /// and the group-commit drain thread, which do a single group fsync later.
4158    fn commit_batch_nosync(&mut self, ops: Vec<BatchOp>) -> Result<(usize, usize)> {
4159        // Restore fsync policy even on panic via a raw-pointer drop guard.
4160        // A panic here would poison the RwLock anyway, but the correct policy
4161        // must be in place if the guard is ever unwrapped.
4162        struct RestoreFsync(*mut FsyncPolicy, FsyncPolicy);
4163        impl Drop for RestoreFsync {
4164            fn drop(&mut self) {
4165                // SAFETY: the pointer is valid for the full duration of
4166                // commit_batch_nosync; the guard is dropped before the frame
4167                // returns, and GraphDb outlives this frame.
4168                unsafe {
4169                    *self.0 = self.1;
4170                }
4171            }
4172        }
4173        let saved = self.fsync;
4174        // SAFETY: raw pointer into self; guard dropped within this frame.
4175        let _g = RestoreFsync(&mut self.fsync as *mut FsyncPolicy, saved);
4176        self.fsync = FsyncPolicy::Relaxed;
4177        self.commit_logged_batch(ops, None)
4178    }
4179
4180    /// Commit multiple op-batches as a **group**: each submission gets its own
4181    /// WAL `Batch` frame, but there is exactly **one** `Fs::sync` for the whole
4182    /// group (under `Strict` / `Batched` policy; `Relaxed` skips all syncs).
4183    ///
4184    /// # Durability semantics
4185    ///
4186    /// A crash before the group fsync may lose **all** submissions in the group.
4187    /// A crash after the group fsync preserves all of them.  No submission is
4188    /// ever torn: each WAL frame is either fully applied on replay or dropped
4189    /// in its entirety (CRC-protected frame boundaries).
4190    ///
4191    /// Events and subscription notifications fire per-submission immediately
4192    /// after apply, which may be before the group fsync.  From a subscriber's
4193    /// perspective this is equivalent to the `Relaxed` durability window.
4194    /// Submitters using [`SharedDb::submit_batch`] only unblock after the group
4195    /// fsync, so from their perspective durability is fully guaranteed.
4196    ///
4197    /// # MVCC interplay
4198    ///
4199    /// Each submission records its own `CommitDelta`; the fold-every-K counter
4200    /// increments per submission (not per group), preserving existing reader
4201    /// snapshot semantics.
4202    ///
4203    /// # Returns
4204    ///
4205    /// One `Result<(nodes_inserted, edges_inserted)>` per input group element,
4206    /// in order.  Failures are per-submission (validation errors); the group
4207    /// fsync error (if any) is returned as the second tuple element.
4208    pub fn commit_group(
4209        &mut self,
4210        groups: Vec<Vec<BatchOp>>,
4211    ) -> (Vec<Result<(usize, usize)>>, Option<GraphError>) {
4212        let mut results = Vec::with_capacity(groups.len());
4213        for ops in groups {
4214            results.push(self.commit_batch_nosync(ops));
4215        }
4216        let any_ok = results.iter().any(|r| r.is_ok());
4217        let sync_err = if self.fsync != FsyncPolicy::Relaxed && any_ok {
4218            self.fs
4219                .sync(core_storage::fs::FileId::Wal)
4220                .map_err(GraphError::Io)
4221                .err()
4222        } else {
4223            None
4224        };
4225        (results, sync_err)
4226    }
4227
4228    /// Like [`commit_group`] but skips the group fsync entirely.
4229    ///
4230    /// Used by the drain thread to apply submissions under the write lock and
4231    /// then perform the single fsync OUTSIDE the lock (via
4232    /// `core_storage::sync_wal_at`), reducing the write-lock hold time visible
4233    /// to concurrent readers.
4234    pub fn commit_group_nosync(
4235        &mut self,
4236        groups: Vec<Vec<BatchOp>>,
4237    ) -> Vec<Result<(usize, usize)>> {
4238        let mut results = Vec::with_capacity(groups.len());
4239        for ops in groups {
4240            results.push(self.commit_batch_nosync(ops));
4241        }
4242        results
4243    }
4244
4245    pub fn insert_node(
4246        &mut self,
4247        label: &str,
4248        key: &str,
4249        props: Vec<(String, Value)>,
4250    ) -> Result<()> {
4251        if self.read_only {
4252            return Err(GraphError::ReadOnly);
4253        }
4254        MutPreview::new(self).check_insert_node(key)?;
4255        self.log_dense(vec![WalRecord::InsertNode {
4256            label: label.into(),
4257            key: key.into(),
4258            props,
4259        }])
4260    }
4261
4262    pub fn insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
4263        if self.read_only {
4264            return Err(GraphError::ReadOnly);
4265        }
4266        if !MutPreview::new(self).prepare_insert_edge(edge_type, src_key, dst_key)? {
4267            return Ok(false);
4268        }
4269        self.log_dense(vec![WalRecord::InsertEdge {
4270            edge_type: edge_type.into(),
4271            src_key: src_key.into(),
4272            dst_key: dst_key.into(),
4273        }])?;
4274        Ok(true)
4275    }
4276
4277    pub fn set_prop(&mut self, key: &str, field: &str, value: Value) -> Result<()> {
4278        if self.read_only {
4279            return Err(GraphError::ReadOnly);
4280        }
4281        if let Some(view_name) = self.view_store.view_for_prop(field) {
4282            return Err(GraphError::ViewPropReadOnly {
4283                view_name: view_name.to_string(),
4284            });
4285        }
4286        MutPreview::new(self).check_live_key(key)?;
4287        self.log_dense(vec![WalRecord::SetProp {
4288            key: key.into(),
4289            field: field.into(),
4290            value,
4291        }])
4292    }
4293
4294    /// Remove a property. Returns `Ok(false)` (and does not log) if the field
4295    /// is already absent. Unknown or tombstoned keys are `Err(KeyNotFound)`.
4296    pub fn remove_prop(&mut self, key: &str, field: &str) -> Result<bool> {
4297        if self.read_only {
4298            return Err(GraphError::ReadOnly);
4299        }
4300        if let Some(view_name) = self.view_store.view_for_prop(field) {
4301            return Err(GraphError::ViewPropReadOnly {
4302                view_name: view_name.to_string(),
4303            });
4304        }
4305        if !MutPreview::new(self).prepare_remove_prop(key, field)? {
4306            return Ok(false);
4307        }
4308        self.log_then_apply(WalRecord::RemoveProp {
4309            key: key.into(),
4310            field: field.into(),
4311        })?;
4312        Ok(true)
4313    }
4314
4315    /// Delete a user edge. Returns `Ok(false)` (and does not log) if the edge
4316    /// is absent. Unknown keys are `Err(KeyNotFound)`. Rule-owned edges — in
4317    /// provenance, or a pair a live rule would derive — are `Err(RuleOwned)`
4318    /// (the rule would just put the edge back; delete or change the rule).
4319    pub fn delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
4320        if self.read_only {
4321            return Err(GraphError::ReadOnly);
4322        }
4323        if !MutPreview::new(self).prepare_delete_edge(edge_type, src_key, dst_key)? {
4324            return Ok(false);
4325        }
4326        self.log_then_apply(WalRecord::DeleteEdge {
4327            edge_type: edge_type.into(),
4328            src_key: src_key.into(),
4329            dst_key: dst_key.into(),
4330        })?;
4331        Ok(true)
4332    }
4333
4334    /// Delete a live node. Unknown or already-tombstoned keys are
4335    /// `Err(KeyNotFound)` and are not logged. Validation runs before the WAL
4336    /// write; `apply` of a logged `DeleteNode` for an already-tombstoned key
4337    /// (crash window) is a clean no-op.
4338    ///
4339    /// Returns a [`DeleteReport`] with counts of manual and derived edges
4340    /// removed (computed from live state before the deletion is applied).
4341    pub fn delete_node(&mut self, key: &str) -> Result<DeleteReport> {
4342        if self.read_only {
4343            return Err(GraphError::ReadOnly);
4344        }
4345        // Provenance must be loaded before we query provenance_touching.
4346        self.engine.ensure_provenance_loaded_mut();
4347        let id = self
4348            .ids
4349            .get(key)
4350            .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
4351
4352        // Count edges before the delete is applied so we can report counts.
4353        let derived_set: BTreeSet<(u32, u32, u32)> = self
4354            .engine
4355            .provenance_touching(id)
4356            .map(|(_, etype, src, dst)| (etype, src, dst))
4357            .collect();
4358        let derived_edges = derived_set.len() as u64;
4359
4360        let mut total_topo = 0u64;
4361        let tv = self.topo_view();
4362        for et in tv.etypes() {
4363            total_topo += tv.neighbors(et, Direction::Out, id).len() as u64
4364                + tv.neighbors(et, Direction::In, id).len() as u64;
4365        }
4366        // For symmetric rules (e.g. Overlap), a→b and b→a are two separate directed
4367        // triples in both the topo scan (Out and In from id) and in provenance_touching.
4368        // The subtraction remains correct because both counts include both directions.
4369        let manual_edges = total_topo.saturating_sub(derived_edges);
4370
4371        self.log_then_apply(WalRecord::DeleteNode { key: key.into() })?;
4372        Ok(DeleteReport {
4373            manual_edges,
4374            derived_edges,
4375        })
4376    }
4377
4378    /// Rename a live node's key.  The dense id (and therefore all edges,
4379    /// props, history, and last-change tracking) is unaffected.
4380    ///
4381    /// Returns `Err(KeyNotFound)` if `old` is not a live key.
4382    /// Returns `Err(DuplicateKey)` if `new` is already live.
4383    pub fn rename_node(&mut self, old: &str, new: &str) -> Result<()> {
4384        if self.read_only {
4385            return Err(GraphError::ReadOnly);
4386        }
4387        MutPreview::new(self).check_rename_node(old, new)?;
4388        self.log_then_apply(WalRecord::RenameNode {
4389            old_key: old.into(),
4390            new_key: new.into(),
4391        })
4392    }
4393
4394    /// Return the IVF drift counter for the dst-side candidate index of `rule`.
4395    /// `None` if the rule does not exist or is not approximate.
4396    ///
4397    /// The drift counter increments on IVF insert/remove after the last fit.
4398    /// When dst-side drift exceeds [`core_rules::IVF_DRIFT_REBUILD`], apply
4399    /// WAL-logs `RebuildRule` as a second commit (rebuild resets the counter).
4400    pub fn ivf_dst_drift(&self, rule: &str) -> Option<u64> {
4401        // SideIvfExport = (centroids, node→cluster, drift)
4402        self.engine
4403            .export_ivf_state()
4404            .remove(rule)
4405            .map(|(_src, dst)| dst.2)
4406    }
4407
4408    /// Validate and WAL-log a new rule, then backfill derived edges inside apply.
4409    /// Validation and duplicate-name check run before logging so invalid rules
4410    /// never enter the WAL.
4411    pub fn create_rule(&mut self, def: RuleDef) -> Result<()> {
4412        if self.read_only {
4413            return Err(GraphError::ReadOnly);
4414        }
4415        MutPreview::new(self).check_create_rule(&def)?;
4416        let def_bytes = bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
4417            detail: format!("serialize rule: {e}"),
4418        })?;
4419        self.log_then_apply(WalRecord::CreateRule { def_bytes })
4420    }
4421
4422    /// WAL-log rule deletion. Returns RuleNotFound if the rule does not exist.
4423    pub fn delete_rule(&mut self, name: &str) -> Result<()> {
4424        if self.read_only {
4425            return Err(GraphError::ReadOnly);
4426        }
4427        MutPreview::new(self).check_delete_rule(name)?;
4428        self.log_then_apply(WalRecord::DeleteRule { name: name.into() })
4429    }
4430
4431    /// Return a snapshot of all registered rules.
4432    pub fn rules(&self) -> Vec<RuleDef> {
4433        self.engine.rules().cloned().collect()
4434    }
4435
4436    // -----------------------------------------------------------------------
4437    // Rule suggestion API
4438    // -----------------------------------------------------------------------
4439
4440    /// Profile the database and suggest linking rules with previewed edge counts.
4441    ///
4442    /// Uses the default seed ([`core_rules::SUGGEST_DEFAULT_SEED`]) for deterministic
4443    /// sampling. Suggestions are sorted by estimated edge count (descending).
4444    /// **NO auto-accept** — call [`GraphDb::create_rule`] explicitly to apply.
4445    pub fn suggest_rules(&self) -> Vec<core_rules::RuleSuggestion> {
4446        self.suggest_rules_seeded(core_rules::SUGGEST_DEFAULT_SEED)
4447    }
4448
4449    /// Like [`suggest_rules`] but with a caller-supplied RNG seed for
4450    /// reproducibility. Same seed + same data = identical output.
4451    pub fn suggest_rules_seeded(&self, seed: u64) -> Vec<core_rules::RuleSuggestion> {
4452        self.suggest_rules_with_config(&core_rules::suggest::SuggestConfig::default(), seed)
4453            .suggestions
4454    }
4455
4456    /// [`suggest_rules_seeded`] with a fully custom [`SuggestConfig`].
4457    ///
4458    /// Returns a [`core_rules::SuggestReport`] that includes both the candidate list
4459    /// and a `truncated` flag indicating whether the global budget fired before all
4460    /// candidates were evaluated.
4461    pub fn suggest_rules_with_config(
4462        &self,
4463        config: &core_rules::suggest::SuggestConfig,
4464        seed: u64,
4465    ) -> core_rules::SuggestReport {
4466        use std::collections::BTreeMap;
4467
4468        // Collect (node_id, key) pairs per label, skipping tombstoned nodes.
4469        let mut label_nodes: BTreeMap<String, Vec<(u32, String)>> = BTreeMap::new();
4470        for id in 0..self.ids.len() as u32 {
4471            let Some(key) = self.ids.key_of(id) else {
4472                continue;
4473            };
4474            let Some(&sym) = self.labels.get(id as usize) else {
4475                continue;
4476            };
4477            if sym == u32::MAX {
4478                continue; // tombstoned
4479            }
4480            let Some(label) = self.syms.resolve(sym) else {
4481                continue;
4482            };
4483            label_nodes
4484                .entry(label.to_string())
4485                .or_default()
4486                .push((id, key.to_string()));
4487        }
4488
4489        let existing = self.rules();
4490        let pv = build_props_view(&self.props, &self.base);
4491        let all_fields: Vec<String> = pv.field_names();
4492
4493        core_rules::suggest::suggest_rules(
4494            &label_nodes,
4495            &|id, field| pv.get(id, field).map(|vr| vr.into_value()),
4496            &all_fields,
4497            &existing,
4498            config,
4499            seed,
4500        )
4501    }
4502
4503    /// Recompute a rule's derived edges from scratch. WAL-logged so un-trip
4504    /// plus later mutations replay identically (rebuild is a pure function
4505    /// of state).
4506    ///
4507    /// Only exit from the tripped latch: if the full desired set fits the
4508    /// budget, it is applied completely and `tripped` clears; if it still
4509    /// exceeds the budget, provenance is left untouched and `tripped` stays
4510    /// true. Counts as a fire evaluation (see [`RuleStats::fires`]).
4511    /// Unknown rule → `RuleNotFound`, nothing logged.
4512    pub fn rebuild_rule(&mut self, name: &str) -> Result<()> {
4513        if self.read_only {
4514            return Err(GraphError::ReadOnly);
4515        }
4516        if !self.engine.rules().any(|r| r.name == name) {
4517            return Err(GraphError::RuleNotFound { name: name.into() });
4518        }
4519        self.log_then_apply(WalRecord::RebuildRule { name: name.into() })
4520    }
4521
4522    // -----------------------------------------------------------------------
4523    // Materialized view API
4524    // -----------------------------------------------------------------------
4525
4526    /// Register a new materialized property view, backfill its values for all
4527    /// existing nodes, and WAL-log the definition.
4528    ///
4529    /// # Errors
4530    /// - `ReadOnly`: called on an as-of instance.
4531    /// - `RuleInvalid`: name collision, view_prop collision, or invalid def.
4532    pub fn create_view(&mut self, def: ViewDef) -> Result<()> {
4533        if self.read_only {
4534            return Err(GraphError::ReadOnly);
4535        }
4536        // Pre-validate before WAL write.
4537        def.validate()
4538            .map_err(|e| GraphError::RuleInvalid { detail: e })?;
4539        if self.view_store.has_view(&def.name) {
4540            return Err(GraphError::RuleInvalid {
4541                detail: format!("view {:?} already exists", def.name),
4542            });
4543        }
4544        if let Some(existing) = self.view_store.view_for_prop(&def.view_prop) {
4545            return Err(GraphError::RuleInvalid {
4546                detail: format!(
4547                    "view_prop {:?} is already used by view {:?}",
4548                    def.view_prop, existing
4549                ),
4550            });
4551        }
4552        let def_bytes = bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
4553            detail: format!("serialize view: {e}"),
4554        })?;
4555        // Enable delta accumulation before the view is registered so subsequent
4556        // incremental edge events reach view maintenance from this point onward.
4557        // (The backfill inside create_view reads topo directly; it does not rely
4558        // on pending deltas.)
4559        self.engine.set_emit_deltas(true);
4560        self.log_then_apply(WalRecord::CreateView { def_bytes })
4561    }
4562
4563    /// Remove a named view and delete its values from every node.
4564    ///
4565    /// # Errors
4566    /// - `ReadOnly`: called on an as-of instance.
4567    /// - `RuleNotFound`: view does not exist.
4568    pub fn delete_view(&mut self, name: &str) -> Result<()> {
4569        if self.read_only {
4570            return Err(GraphError::ReadOnly);
4571        }
4572        if !self.view_store.has_view(name) {
4573            return Err(GraphError::RuleNotFound { name: name.into() });
4574        }
4575        let result = self.log_then_apply(WalRecord::DeleteView { name: name.into() });
4576        // After deletion, disable accumulation if no listeners remain.
4577        if !self.needs_emit_deltas() {
4578            self.engine.set_emit_deltas(false);
4579        }
4580        result
4581    }
4582
4583    /// Snapshot of all registered view definitions.
4584    pub fn views(&self) -> Vec<ViewDef> {
4585        self.view_store.views().cloned().collect()
4586    }
4587
4588    // -----------------------------------------------------------------------
4589    // Full-text-lite API
4590    // -----------------------------------------------------------------------
4591
4592    /// Enable full-text indexing for all nodes of `label` on property `field`.
4593    ///
4594    /// After this call, every subsequent write to `(label, field)` is reflected
4595    /// in the index incrementally.  Existing nodes are backfilled immediately.
4596    /// The declaration is persisted as a WAL record; the index itself is rebuilt
4597    /// from scratch on re-open (no snapshot format changes).
4598    ///
4599    /// # Errors
4600    /// - [`GraphError::ReadOnly`]: called on an as-of instance.
4601    /// - [`GraphError::RuleInvalid`]: `(label, field)` is already indexed.
4602    pub fn enable_fulltext(&mut self, label: &str, field: &str) -> Result<()> {
4603        if self.read_only {
4604            return Err(GraphError::ReadOnly);
4605        }
4606        if self.fulltext.is_enabled(label, field) {
4607            return Err(GraphError::RuleInvalid {
4608                detail: format!("full-text index for ({label:?}, {field:?}) already enabled"),
4609            });
4610        }
4611        self.log_then_apply(WalRecord::EnableFulltext {
4612            label: label.into(),
4613            field: field.into(),
4614        })
4615    }
4616
4617    /// Disable full-text indexing for `(label, field)` and drop its postings.
4618    ///
4619    /// # Errors
4620    /// - [`GraphError::ReadOnly`]: called on an as-of instance.
4621    /// - [`GraphError::RuleNotFound`]: `(label, field)` is not currently indexed.
4622    pub fn disable_fulltext(&mut self, label: &str, field: &str) -> Result<()> {
4623        if self.read_only {
4624            return Err(GraphError::ReadOnly);
4625        }
4626        if !self.fulltext.is_enabled(label, field) {
4627            return Err(GraphError::RuleNotFound {
4628                name: format!("fulltext({label},{field})"),
4629            });
4630        }
4631        self.log_then_apply(WalRecord::DisableFulltext {
4632            label: label.into(),
4633            field: field.into(),
4634        })
4635    }
4636
4637    /// Whether `(label, field)` is currently indexed for full-text search.
4638    pub fn is_fulltext_enabled(&self, label: &str, field: &str) -> bool {
4639        self.fulltext.is_enabled(label, field)
4640    }
4641
4642    /// Search a full-text-indexed field.
4643    ///
4644    /// Returns `(node_key, match_count)` pairs sorted by match_count descending,
4645    /// ties broken by key (lexicographic).  Tombstoned nodes are excluded.
4646    ///
4647    /// **Query syntax:**
4648    /// - Space-separated terms are AND'd: `"foo bar"` requires both.
4649    /// - `OR` between terms forms disjunction: `"foo OR bar"` matches either.
4650    /// - Trailing `*` on a term is a prefix match: `"rust*"` matches `rustlang`, `rusty`.
4651    /// - `AND` keyword is accepted explicitly and is the default.
4652    /// - Tokenization is unicode-alphanumeric (same as index time); case-insensitive.
4653    ///
4654    /// **Unindexed field:** returns `Ok(vec![])` if `field` is not indexed.
4655    /// Pin: this is the documented, tested, stable behavior for v1.
4656    ///
4657    /// **Memory / performance:** O(postings) lookup; no scan.  The index is
4658    /// in-memory and proportional to total indexed text across all enabled fields.
4659    ///
4660    /// **v2 grammar:** supports `"phrase"`, `-negation`, `prefix*`, `OR`, `AND`.
4661    /// Results are BM25-scored (k1=1.2, b=0.75) and sorted by score descending,
4662    /// key ascending for deterministic tiebreaking.
4663    pub fn search(&self, field: &str, query: &str) -> Vec<(String, f64)> {
4664        // Resolve node_ids to keys (excluding tombstones) then re-sort by
4665        // (score DESC, key ASC) to give a deterministic, key-lexicographic
4666        // tiebreak.  FulltextIndex::search sorts by (score DESC, node_id ASC)
4667        // which diverges from key order when nodes were not inserted in key-lex order.
4668        let mut results: Vec<(String, f64)> = self
4669            .fulltext
4670            .search(field, query, 0)
4671            .into_iter()
4672            .filter_map(|(id, score)| self.ids.key_of(id).map(|key| (key.to_string(), score)))
4673            .collect();
4674        results.sort_by(|a, b| {
4675            b.1.partial_cmp(&a.1)
4676                .unwrap_or(std::cmp::Ordering::Equal)
4677                .then(a.0.cmp(&b.0))
4678        });
4679        results
4680    }
4681
4682    /// Hybrid search: Reciprocal Rank Fusion (RRF) over fulltext + vector results.
4683    ///
4684    /// Takes up to `4*k` fulltext hits for `(text_field, query_text)` and up to
4685    /// `4*k` vector hits for `(vector_field, query_vec, min=0.0)`, then fuses
4686    /// them with RRF using a fixed constant of 60.
4687    ///
4688    /// ```text
4689    /// score(d) = Σ  1 / (60 + rank_i(d))    (rank 1-based per list)
4690    /// ```
4691    ///
4692    /// Returns the top `k` nodes by fused score, ties broken by node key
4693    /// ascending (deterministic).
4694    ///
4695    /// # Vector leg fallback
4696    ///
4697    /// When `query_vec` is empty the vector leg is skipped entirely and
4698    /// results are ranked by the text list alone through the same RRF path
4699    /// (each text result scores `1/(60 + rank)` from that single list).
4700    ///
4701    /// When `label` is `None`, the vector leg **always** returns empty results.
4702    /// Internally `label` is mapped to `""`, which does not match any rule-created
4703    /// HNSW index (all such indexes are keyed to a specific non-empty label), and
4704    /// the brute-force fallback finds no nodes with an empty label.  The fused
4705    /// ranking is therefore text-only in this case.
4706    pub fn search_hybrid(
4707        &self,
4708        text_field: &str,
4709        query_text: &str,
4710        vector_field: &str,
4711        query_vec: &[f64],
4712        label: Option<&str>,
4713        k: usize,
4714    ) -> Vec<(String, f64)> {
4715        use std::collections::HashMap;
4716
4717        const RRF_K: f64 = 60.0;
4718        let pool = 4 * k;
4719
4720        // Accumulate per-node RRF scores.
4721        let mut scores: HashMap<String, f64> = HashMap::new();
4722
4723        // Text leg.
4724        let text_hits = self.search(text_field, query_text);
4725        for (rank0, (key, _count)) in text_hits.into_iter().take(pool).enumerate() {
4726            let rank = (rank0 + 1) as f64;
4727            *scores.entry(key).or_insert(0.0) += 1.0 / (RRF_K + rank);
4728        }
4729
4730        // Vector leg (skipped when query_vec is empty).
4731        if !query_vec.is_empty() {
4732            let lbl = label.unwrap_or("");
4733            let vec_hits = self.find_similar_vector(vector_field, lbl, query_vec, pool, 0.0);
4734            for (rank0, (key, _sim)) in vec_hits.into_iter().enumerate() {
4735                let rank = (rank0 + 1) as f64;
4736                *scores.entry(key).or_insert(0.0) += 1.0 / (RRF_K + rank);
4737            }
4738        }
4739
4740        // Sort: score DESC, then key ASC for deterministic tie-breaking.
4741        let mut ranked: Vec<(String, f64)> = scores.into_iter().collect();
4742        ranked.sort_by(|a, b| {
4743            b.1.partial_cmp(&a.1)
4744                .unwrap_or(std::cmp::Ordering::Equal)
4745                .then(a.0.cmp(&b.0))
4746        });
4747        ranked.truncate(k);
4748        ranked
4749    }
4750
4751    /// For DST/testing: scratch BM25 search over live nodes without the index.
4752    /// Walks every live node, re-stems field tokens, computes corpus stats, and
4753    /// returns BM25-ranked results.
4754    ///
4755    /// The oracle: the ordered key list of `search(field, q)` must equal that of
4756    /// `scratch_search(field, q)` at every quiescent state.
4757    #[doc(hidden)]
4758    pub fn scratch_search(&self, field: &str, query: &str) -> Vec<(String, f64)> {
4759        use core_storage::fulltext::{parse_query, value_tokens_stemmed_with_positions};
4760        use std::collections::BTreeMap;
4761
4762        let groups = parse_query(query);
4763        if groups.is_empty() {
4764            return vec![];
4765        }
4766
4767        // --- Pass 1: collect all live indexed nodes with stemmed token data ---
4768        struct NodeData {
4769            key: String,
4770            /// stemmed_token → positions (sorted)
4771            tokens: BTreeMap<String, Vec<u32>>,
4772            dl: u32,
4773        }
4774
4775        let mut nodes: Vec<NodeData> = Vec::new();
4776        for id in 0..self.ids.len() as u32 {
4777            let Some(key) = self.ids.key_of(id) else {
4778                continue;
4779            };
4780            let Some(&sym) = self.labels.get(id as usize) else {
4781                continue;
4782            };
4783            if sym == u32::MAX {
4784                continue;
4785            }
4786            let label = match self.syms.resolve(sym) {
4787                Some(l) => l,
4788                None => continue,
4789            };
4790            if !self.fulltext.is_enabled(label, field) {
4791                continue;
4792            }
4793            let Some(value) = self.props_view().get(id, field).map(|vr| vr.into_value()) else {
4794                continue;
4795            };
4796            // Use value_tokens_stemmed_with_positions so list elements are
4797            // separated by POSITION_GAP — identical to the index path, which
4798            // prevents phrase queries from matching across element boundaries.
4799            let stemmed_with_pos = match &value {
4800                Value::Str(_) | Value::List(_) => value_tokens_stemmed_with_positions(&value),
4801                _ => continue,
4802            };
4803            let dl = stemmed_with_pos.len() as u32;
4804            let mut tok_map: BTreeMap<String, Vec<u32>> = BTreeMap::new();
4805            for (tok, pos) in stemmed_with_pos {
4806                tok_map.entry(tok).or_default().push(pos);
4807            }
4808            nodes.push(NodeData {
4809                key: key.to_string(),
4810                tokens: tok_map,
4811                dl,
4812            });
4813        }
4814
4815        if nodes.is_empty() {
4816            return vec![];
4817        }
4818
4819        // --- BM25 corpus stats ---
4820        let n = nodes.len() as f64;
4821        let avg_dl: f64 = nodes.iter().map(|nd| nd.dl as f64).sum::<f64>() / n;
4822        // df per stemmed token across all live indexed nodes.
4823        let mut df_map: BTreeMap<&str, f64> = BTreeMap::new();
4824        for nd in &nodes {
4825            for tok in nd.tokens.keys() {
4826                *df_map.entry(tok.as_str()).or_insert(0.0) += 1.0;
4827            }
4828        }
4829
4830        const K1: f64 = 1.2;
4831        const B: f64 = 0.75;
4832
4833        // --- Pass 2: score each node against each OR-group ---
4834        let mut results: Vec<(String, f64)> = Vec::new();
4835        for nd in &nodes {
4836            let dl = nd.dl as f64;
4837            let mut total_score = 0.0f64;
4838
4839            'group: for group in &groups {
4840                let mut group_score = 0.0f64;
4841
4842                for term in group {
4843                    if term.negated {
4844                        // Negated: if doc has this stemmed token → group fails.
4845                        let present = if term.prefix {
4846                            nd.tokens.keys().any(|t| t.starts_with(term.token.as_str()))
4847                        } else {
4848                            nd.tokens.contains_key(term.token.as_str())
4849                        };
4850                        if present {
4851                            continue 'group;
4852                        }
4853                        continue;
4854                    }
4855                    if term.prefix {
4856                        // Prefix: sum BM25 for all matching stemmed tokens.
4857                        let mut prefix_matched = false;
4858                        for (tok, positions) in &nd.tokens {
4859                            if tok.starts_with(term.token.as_str()) {
4860                                let tf = positions.len() as f64;
4861                                let df = df_map.get(tok.as_str()).copied().unwrap_or(1.0);
4862                                let idf = ((n - df + 0.5) / (df + 0.5) + 1.0).ln();
4863                                let tf_norm =
4864                                    tf * (K1 + 1.0) / (tf + K1 * (1.0 - B + B * dl / avg_dl));
4865                                group_score += idf * tf_norm;
4866                                prefix_matched = true;
4867                            }
4868                        }
4869                        if !prefix_matched {
4870                            continue 'group;
4871                        }
4872                    } else {
4873                        // term.token is already stemmed by parse_query; use directly.
4874                        match nd.tokens.get(term.token.as_str()) {
4875                            None => continue 'group,
4876                            Some(positions) => {
4877                                let tf = positions.len() as f64;
4878                                let df = df_map.get(term.token.as_str()).copied().unwrap_or(1.0);
4879                                let idf = ((n - df + 0.5) / (df + 0.5) + 1.0).ln();
4880                                let tf_norm =
4881                                    tf * (K1 + 1.0) / (tf + K1 * (1.0 - B + B * dl / avg_dl));
4882                                group_score += idf * tf_norm;
4883                            }
4884                        }
4885                    }
4886                }
4887
4888                if group_score > 0.0 {
4889                    total_score += group_score;
4890                }
4891            }
4892
4893            if total_score > 0.0 {
4894                results.push((nd.key.clone(), total_score));
4895            }
4896        }
4897
4898        results.sort_by(|a, b| {
4899            b.1.partial_cmp(&a.1)
4900                .unwrap_or(std::cmp::Ordering::Equal)
4901                .then(a.0.cmp(&b.0))
4902        });
4903        results
4904    }
4905
4906    /// Return the current view-maintained value of `view_prop` for node `key`.
4907    /// Equivalent to `get_prop` but documents that it reads a view-managed column.
4908    pub fn get_view_prop(&self, key: &str, view_prop: &str) -> Option<Value> {
4909        let id = self.ids.get(key)?;
4910        self.props_view()
4911            .get(id, view_prop)
4912            .map(|vr| vr.into_value())
4913    }
4914
4915    /// For testing / DST oracle: scratch recompute of a view value for one node.
4916    ///
4917    /// Returns `None` if the node does not exist, the view does not exist, or
4918    /// the view has no result for the node (e.g. Avg with no qualifying neighbors).
4919    #[doc(hidden)]
4920    pub fn scratch_view_value(&self, key: &str, view_name: &str) -> Option<Value> {
4921        let node = self.ids.get(key)?;
4922        let def = self.view_store.views().find(|v| v.name == view_name)?;
4923        // Use TopologyView so that NeighborAgg sees base + overlay edges
4924        // without materialising a temporary Topology (I1).
4925        let topo_view = self.topo_view();
4926        core_rules::views::compute_view_value(
4927            def,
4928            node,
4929            self.props_view(),
4930            &topo_view,
4931            &self.ids,
4932            &self.syms,
4933            &self.labels,
4934        )
4935    }
4936
4937    // -----------------------------------------------------------------------
4938    // Graph algorithm API
4939    // -----------------------------------------------------------------------
4940
4941    /// Run PageRank over the unified topology (manual + derived edges).
4942    ///
4943    /// Returns a [`PageRankReport`] with scores sorted descending (ties: key
4944    /// ascending).  Set `config.edge_type` to restrict to one edge type.
4945    /// `config.converged` is `true` only when the power iteration converged
4946    /// within `config.max_iters` and within any time budget.
4947    pub fn pagerank(&self, config: &crate::algo::PageRankConfig) -> crate::algo::PageRankReport {
4948        crate::algo::pagerank(&self.topo, &self.ids, &self.syms, &self.labels, config)
4949    }
4950
4951    /// Weakly-connected components over the unified topology (treated as
4952    /// undirected regardless of how edges were inserted).
4953    ///
4954    /// Component IDs are the key of the smallest member in the component
4955    /// (deterministic).  Result sorted by (component_id, key).
4956    pub fn connected_components(&self, config: &crate::algo::WccConfig) -> crate::algo::WccReport {
4957        crate::algo::wcc(&self.topo, &self.ids, &self.syms, &self.labels, config)
4958    }
4959
4960    /// Degree centrality for every live node.
4961    ///
4962    /// `direction`: `AlgoDir::Out` = out-degree, `AlgoDir::In` = in-degree,
4963    /// `AlgoDir::Both` = out + in (total directed degree).
4964    ///
4965    /// For one-shot ranking use this; for a live property updated on every
4966    /// write, create a Degree materialized view instead (see `docs/site/algorithms.md`).
4967    pub fn degree_centrality(
4968        &self,
4969        config: &crate::algo::DegreeConfig,
4970    ) -> crate::algo::DegreeReport {
4971        crate::algo::degree_centrality(&self.topo, &self.ids, &self.syms, &self.labels, config)
4972    }
4973
4974    /// Write a vector of `(node_key, score)` pairs as `prop_name` on each node,
4975    /// atomically via a single write-batch (one WAL frame, one fsync).
4976    ///
4977    /// # Errors
4978    /// - [`GraphError::ReadOnly`]: called on an as-of instance.
4979    /// - [`GraphError::RuleInvalid`]: `prop_name` is managed by an existing view
4980    ///   (collision check mirrors `create_view`).
4981    /// - [`GraphError::KeyNotFound`]: a key in `scores` does not exist as a live node.
4982    pub fn write_scores(&mut self, prop_name: &str, scores: &[(String, f64)]) -> Result<()> {
4983        if self.read_only {
4984            return Err(GraphError::ReadOnly);
4985        }
4986        // Collision check: refuse if prop_name is view-managed.
4987        if let Some(view_name) = self.view_store.view_for_prop(prop_name) {
4988            return Err(GraphError::RuleInvalid {
4989                detail: format!(
4990                    "prop {:?} is managed by view {:?} and cannot be written as scores",
4991                    prop_name, view_name
4992                ),
4993            });
4994        }
4995        // Refuse if prop_name is a view name itself (confusing namespace collision).
4996        if self.view_store.has_view(prop_name) {
4997            return Err(GraphError::RuleInvalid {
4998                detail: format!(
4999                    "prop_name {:?} collides with an existing view name",
5000                    prop_name
5001                ),
5002            });
5003        }
5004        // Write all scores in a single crash-atomic batch.
5005        self.write_batch(|b| {
5006            for (key, score) in scores {
5007                b.set_prop(key, prop_name, Value::Float(*score));
5008            }
5009        })?;
5010        Ok(())
5011    }
5012
5013    /// Return the value of `field` for the node with key `key`, or `None` if
5014    /// the node or field is absent.  Reads through the overlay-over-base
5015    /// `ColumnsView`, materialising base values on demand (zero heap cost for
5016    /// overlay hits; one clone per base hit).
5017    pub fn get_prop(&self, key: &str, field: &str) -> Option<Value> {
5018        let id = self.ids.get(key)?;
5019        self.props_view().get(id, field).map(|vr| vr.into_value())
5020    }
5021
5022    pub fn has_node(&self, key: &str) -> bool {
5023        self.ids.get(key).is_some()
5024    }
5025
5026    /// Borrow the raw id map. Used by `NodeMask::from_keys` to resolve keys.
5027    pub(crate) fn ids(&self) -> &IdMap {
5028        &self.ids
5029    }
5030
5031    // -----------------------------------------------------------------------
5032    // RBAC role resolution
5033    // -----------------------------------------------------------------------
5034
5035    /// Parse `roles.json` bytes from `fs`.
5036    ///
5037    /// Return values:
5038    ///   `Ok(Some(roles))` — file absent (returns `vec![]`) **or** file present
5039    ///                       and valid; in both cases `mask_for_role` uses the
5040    ///                       list normally (an absent file means no roles defined).
5041    ///   `Ok(None)`        — file present but corrupt or unrecognised version
5042    ///                       → poisoned state; `mask_for_role` returns `Err` for
5043    ///                       any role name until the file is fixed and the DB
5044    ///                       re-opened (or `apply_schema` is called to repair it).
5045    ///
5046    /// Note: `None` signals corruption, not absence — the opposite of what an
5047    /// optional "file missing" convention would suggest.  The open path stores
5048    /// this result on `db.roles` directly.
5049    fn load_roles_from_fs(fs: &F) -> Result<Option<Vec<RoleDef>>> {
5050        let bytes = fs.read(FileId::Roles).map_err(GraphError::Io)?;
5051        if bytes.is_empty() {
5052            // Empty bytes means either the file is absent or zero-byte — both
5053            // are treated identically as "no roles defined".  A zero-byte
5054            // roles.json does NOT widen access: an absent file and a zero-byte
5055            // file both resolve to an empty role list (sees nothing by default).
5056            return Ok(Some(vec![]));
5057        }
5058        match serde_json::from_slice::<RolesFile>(&bytes) {
5059            Ok(f) if f.version == 1 => Ok(Some(f.roles)),
5060            // Corrupt or unrecognised version: poison the roles state.
5061            _ => Ok(None),
5062        }
5063    }
5064
5065    /// Resolve a role to a node-visibility mask against the current graph state.
5066    ///
5067    /// Returns `Err` when:
5068    /// - `roles.json` was present but corrupt at open (poisoned state), or
5069    /// - `role` does not match any defined role name.
5070    ///
5071    /// The mask union is: explicit `keys` (unknown keys silently ignored) plus
5072    /// all live nodes carrying any label in `labels`.  Label resolution is live
5073    /// — new nodes of an allowed label are visible without re-applying the
5074    /// schema.  An empty union = empty mask = sees nothing.
5075    pub fn mask_for_role(&self, role: &str) -> Result<crate::mask::NodeMask> {
5076        let roles = self.roles.as_ref().ok_or_else(|| GraphError::Corrupt {
5077            detail:
5078                "roles.json was corrupt at open; fix the file and re-open to restore role access"
5079                    .into(),
5080        })?;
5081        let def = roles
5082            .iter()
5083            .find(|r| r.name == role)
5084            .ok_or_else(|| GraphError::KeyNotFound {
5085                key: format!("role:{role}"),
5086            })?;
5087
5088        let mut visible = std::collections::HashSet::new();
5089
5090        // Key leg: resolve explicit keys to dense ids (unknown keys ignored).
5091        for key in &def.keys {
5092            if let Some(id) = self.ids.get(key) {
5093                visible.insert(id);
5094            }
5095        }
5096
5097        // Label leg: live scan — iterate labels vec for matching symbol.
5098        for label_name in &def.labels {
5099            if let Some(sym) = self.syms.get(label_name) {
5100                for (i, &s) in self.labels.iter().enumerate() {
5101                    if s == sym {
5102                        visible.insert(i as u32);
5103                    }
5104                }
5105            }
5106        }
5107
5108        Ok(crate::mask::NodeMask::from_ids(visible))
5109    }
5110
5111    /// Return the current list of role definitions.
5112    ///
5113    /// Returns an empty list when no roles are defined or when `roles.json`
5114    /// was corrupt at open (check [`mask_for_role`](Self::mask_for_role) for
5115    /// the fail-loud error in that case).
5116    pub fn roles(&self) -> Vec<RoleDef> {
5117        self.roles.as_deref().unwrap_or(&[]).to_vec()
5118    }
5119
5120    /// Write `roles` to `roles.json` atomically and update the in-memory list.
5121    ///
5122    /// Called by `apply_schema` when roles change. Never called on unchanged
5123    /// re-apply — this preserves byte-identical idempotency.
5124    pub(crate) fn commit_roles(&mut self, roles: Vec<RoleDef>) -> Result<()> {
5125        let file = RolesFile::v1(roles.clone());
5126        let bytes = serde_json::to_vec(&file).map_err(|e| GraphError::Corrupt {
5127            detail: format!("roles serialization: {e}"),
5128        })?;
5129        self.fs
5130            .write_atomic(FileId::Roles, &bytes)
5131            .map_err(GraphError::Io)?;
5132        self.roles = Some(roles);
5133        // Refresh the MVCC frozen overlay so that reader() immediately sees the
5134        // updated role definitions without waiting for the next K-commit fold.
5135        self.fold_now();
5136        Ok(())
5137    }
5138
5139    fn view(&self) -> GraphView<'_> {
5140        GraphView {
5141            ids: &self.ids,
5142            syms: &self.syms,
5143            labels: &self.labels,
5144            props: self.props_view(),
5145            topo: self.topo_view(),
5146            edge_props: self.edge_props_view(),
5147            mask: None,
5148        }
5149    }
5150
5151    fn view_masked<'a>(&'a self, mask: &'a crate::mask::NodeMask) -> GraphView<'a> {
5152        GraphView {
5153            ids: &self.ids,
5154            syms: &self.syms,
5155            labels: &self.labels,
5156            props: self.props_view(),
5157            topo: self.topo_view(),
5158            edge_props: self.edge_props_view(),
5159            mask: Some(&mask.visible),
5160        }
5161    }
5162
5163    /// Execute a read-only Cypher query with a node visibility mask.
5164    ///
5165    /// Only nodes whose key is in `mask` are accessible: label scans, key
5166    /// lookups, and neighbor expansions all respect the mask. Edges where
5167    /// either endpoint is hidden are silently dropped.
5168    ///
5169    /// Returns `Err` with a "masked queries are read-only" message when
5170    /// `cypher` is a write statement (CREATE / MERGE / MATCH…SET / DELETE).
5171    pub fn query_masked(
5172        &self,
5173        cypher: &str,
5174        params: &std::collections::BTreeMap<String, Value>,
5175        mask: &crate::mask::NodeMask,
5176    ) -> Result<ResultSet> {
5177        // Reject write statements up front.
5178        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
5179            detail: format!("lex: {e}"),
5180        })?;
5181        if is_write_tokens(&tokens) {
5182            return Err(GraphError::MaskedReadOnly);
5183        }
5184        let ast = parse(&tokens).map_err(|e| GraphError::QueryError {
5185            detail: format!("parse: {e}"),
5186        })?;
5187        let ops = plan(&ast).map_err(|e| GraphError::QueryError {
5188            detail: format!("plan: {e}"),
5189        })?;
5190        execute(&self.view_masked(mask), &ops, &Params(params)).map_err(|e| {
5191            GraphError::QueryError {
5192                detail: format!("execute: {e}"),
5193            }
5194        })
5195    }
5196
5197    pub fn node_ref(&self, key: &str) -> Option<NodeRef<'_, F>> {
5198        let id = self.ids.get(key)?;
5199        Some(NodeRef { db: self, id })
5200    }
5201
5202    /// BFS neighborhood expansion restricted to visible nodes in `mask`.
5203    ///
5204    /// Hidden nodes are never used as traversal intermediaries in either
5205    /// [`MaskMode::Omit`] or [`MaskMode::Stub`] — a visible node reachable
5206    /// only through a hidden node will not appear in results.
5207    ///
5208    /// In [`MaskMode::Stub`] mode, hidden nodes that are direct neighbours of
5209    /// a visited visible node are appended to the result as stub rows
5210    /// (`label` column is `null`, same key+depth columns as visible rows).
5211    /// They are NOT added to the BFS frontier.
5212    ///
5213    /// Returns `None` when `key` does not exist (caller should 404).
5214    ///
5215    /// **SECURITY**: role-token callers always pass an Omit-mode mask, so
5216    /// stub rows are never produced on the role path.
5217    pub fn neighborhood_masked(
5218        &self,
5219        key: &str,
5220        depth: u32,
5221        edge_types: Option<&[&str]>,
5222        dir: Dir,
5223        mask: &crate::mask::NodeMask,
5224    ) -> Option<ResultSet> {
5225        let start_id = self.ids.get(key)?;
5226        let view = self.view_masked(mask);
5227        let resolved: Option<Vec<u32>> = edge_types.map(|names| {
5228            names
5229                .iter()
5230                .filter_map(|name| view.syms.get(name))
5231                .collect()
5232        });
5233        let nb = neighborhood(&view, start_id, depth, resolved.as_deref(), dir);
5234        let mut rs = ResultSet::new(vec!["key".into(), "label".into(), "depth".into()]);
5235        // Collect visible BFS results (start_id at depth 0, BFS nodes after).
5236        let mut visited: Vec<(u32, u32)> = Vec::with_capacity(nb.nodes.len() + 1);
5237        visited.push((start_id, 0));
5238        for (nid, d) in &nb.nodes {
5239            let k = view.key_of(*nid);
5240            let label = view
5241                .label_of(*nid)
5242                .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
5243            rs.push_row(vec![
5244                Some(Value::Str(k.to_string())),
5245                Some(Value::Str(label.to_string())),
5246                Some(Value::Int(*d as i64)),
5247            ]);
5248            visited.push((*nid, *d));
5249        }
5250        // Stub mode: add hidden direct neighbours of each visited node as stubs.
5251        // Hidden nodes are edge-endpoints only — they are not added to the BFS
5252        // frontier, so the BFS never expands through them.
5253        if mask.mode() == crate::mask::MaskMode::Stub {
5254            let raw_view = self.view();
5255            let mut seen: std::collections::HashSet<u32> =
5256                visited.iter().map(|(id, _)| *id).collect();
5257            for (node_id, node_depth) in &visited {
5258                if *node_depth >= depth {
5259                    continue;
5260                }
5261                for e in expand(&raw_view, *node_id, resolved.as_deref(), dir) {
5262                    let nbr = if e.src == *node_id { e.dst } else { e.src };
5263                    if !mask.contains_id(nbr) && seen.insert(nbr) {
5264                        if let Some(k) = self.ids.key_of(nbr) {
5265                            rs.push_row(vec![
5266                                Some(Value::Str(k.to_string())),
5267                                None,
5268                                Some(Value::Int((*node_depth + 1) as i64)),
5269                            ]);
5270                        }
5271                    }
5272                }
5273            }
5274        }
5275        Some(rs)
5276    }
5277
5278    /// Live node's key, label, and columnar props. Unknown or tombstoned → `None`.
5279    pub fn node_info(&self, key: &str) -> Option<NodeInfo> {
5280        let n = self.node_ref(key)?;
5281        Some(NodeInfo {
5282            key: n.key().to_string(),
5283            label: n.label().to_string(),
5284            props: n.props(),
5285        })
5286    }
5287
5288    /// Look up a node with mask awareness.
5289    ///
5290    /// | Key state         | Omit mode       | Stub mode              |
5291    /// |-------------------|-----------------|------------------------|
5292    /// | does not exist    | `None` (→ 404)  | `None` (→ 404)         |
5293    /// | exists, visible   | `Some(Visible)` | `Some(Visible)`        |
5294    /// | exists, hidden    | `None` (→ 404)  | `Some(Restricted)`     |
5295    ///
5296    /// **SECURITY**: only call from client-mask (full-token) paths.
5297    /// Role-token paths must use [`node_info`] after an explicit visibility check.
5298    pub fn node_info_masked(
5299        &self,
5300        key: &str,
5301        mask: &crate::mask::NodeMask,
5302    ) -> Option<MaskedNodeResult> {
5303        let id = self.ids.get(key)?;
5304        if mask.contains_id(id) {
5305            Some(MaskedNodeResult::Visible(self.node_info(key)?))
5306        } else {
5307            match mask.mode() {
5308                crate::mask::MaskMode::Stub => Some(MaskedNodeResult::Restricted),
5309                crate::mask::MaskMode::Omit => None,
5310            }
5311        }
5312    }
5313
5314    /// Get edges for `key` with mask-aware hidden-endpoint handling.
5315    ///
5316    /// - Omit mode: edges to hidden endpoints are excluded (same as role-path filtering).
5317    /// - Stub mode: edges to hidden endpoints are included; `src_restricted`/`dst_restricted`
5318    ///   is `true` for each hidden endpoint.
5319    ///
5320    /// Unknown key → [`GraphError::KeyNotFound`].
5321    ///
5322    /// **SECURITY**: only call from client-mask (full-token) paths.
5323    pub fn node_edges_masked(
5324        &self,
5325        key: &str,
5326        mask: &crate::mask::NodeMask,
5327    ) -> Result<Vec<MaskedEdge>> {
5328        self.ensure_v8_base_sections_loaded();
5329        let id = self
5330            .ids
5331            .get(key)
5332            .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
5333        let derived: BTreeSet<(u32, u32, u32)> = self
5334            .engine
5335            .provenance_touching(id)
5336            .map(|(_rule, etype, src, dst)| (etype, src, dst))
5337            .collect();
5338        let mut edges = Vec::new();
5339        let tv = self.topo_view();
5340        for etype in tv.etypes() {
5341            // etype comes from the archived CSR (access_unchecked, no eager CRC).
5342            // A bit-flip in the large TOPOLOGY section can produce an etype id
5343            // that is not in the interner.  Return Corrupt rather than panic.
5344            let edge_type = self
5345                .syms
5346                .resolve(etype)
5347                .ok_or_else(|| GraphError::Corrupt {
5348                    detail: format!("v8: topology etype {etype} not in interner"),
5349                })?
5350                .to_string();
5351            for dir in [Direction::Out, Direction::In] {
5352                for &nbr in tv.neighbors(etype, dir, id).as_ref() {
5353                    let nbr_restricted = !mask.contains_id(nbr);
5354                    if nbr_restricted && mask.mode() == crate::mask::MaskMode::Omit {
5355                        continue;
5356                    }
5357                    let nbr_key = self
5358                        .ids
5359                        .key_of(nbr)
5360                        .ok_or_else(|| GraphError::Corrupt {
5361                            detail: format!("topology id {nbr} has no key"),
5362                        })?
5363                        .to_string();
5364                    let (src_id, dst_id, src_key, dst_key, src_restricted, dst_restricted) =
5365                        match dir {
5366                            Direction::Out => {
5367                                (id, nbr, key.to_string(), nbr_key, false, nbr_restricted)
5368                            }
5369                            Direction::In => {
5370                                (nbr, id, nbr_key, key.to_string(), nbr_restricted, false)
5371                            }
5372                        };
5373                    edges.push(MaskedEdge {
5374                        edge_type: edge_type.clone(),
5375                        src_key,
5376                        src_restricted,
5377                        dst_key,
5378                        dst_restricted,
5379                        derived: derived.contains(&(etype, src_id, dst_id)),
5380                    });
5381                }
5382            }
5383        }
5384        edges.sort_by(|a, b| {
5385            a.edge_type
5386                .cmp(&b.edge_type)
5387                .then(a.src_key.cmp(&b.src_key))
5388                .then(a.dst_key.cmp(&b.dst_key))
5389        });
5390        edges.dedup_by(|a, b| {
5391            a.edge_type == b.edge_type && a.src_key == b.src_key && a.dst_key == b.dst_key
5392        });
5393        Ok(edges)
5394    }
5395
5396    /// Every directed edge incident on `key`, both directions, every etype.
5397    ///
5398    /// Walk is `topology.etypes()` × `{Out, In}` × `neighbors()`. `derived` is
5399    /// membership in [`RuleEngine::provenance_touching`] (O(degree) via the
5400    /// Plan-8 `by_node` index). Sorted by `(edge_type, src_key, dst_key)`.
5401    /// Unknown key → [`GraphError::KeyNotFound`].
5402    pub fn node_edges(&self, key: &str) -> Result<Vec<EdgeInfo>> {
5403        self.ensure_v8_base_sections_loaded();
5404        let id = self
5405            .ids
5406            .get(key)
5407            .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
5408        let derived: BTreeSet<(u32, u32, u32)> = self
5409            .engine
5410            .provenance_touching(id)
5411            .map(|(_rule, etype, src, dst)| (etype, src, dst))
5412            .collect();
5413        let mut edges = Vec::new();
5414        let tv = self.topo_view();
5415        for etype in tv.etypes() {
5416            // Same guard as node_edges_masked: etype from unchecked-CRC CSR.
5417            let edge_type = self
5418                .syms
5419                .resolve(etype)
5420                .ok_or_else(|| GraphError::Corrupt {
5421                    detail: format!("v8: topology etype {etype} not in interner"),
5422                })?
5423                .to_string();
5424            for dir in [Direction::Out, Direction::In] {
5425                for &nbr in tv.neighbors(etype, dir, id).as_ref() {
5426                    let (src, dst, src_key, dst_key) = match dir {
5427                        Direction::Out => (
5428                            id,
5429                            nbr,
5430                            key.to_string(),
5431                            self.ids
5432                                .key_of(nbr)
5433                                .ok_or_else(|| GraphError::Corrupt {
5434                                    detail: format!("topology id {nbr} has no key"),
5435                                })?
5436                                .to_string(),
5437                        ),
5438                        Direction::In => (
5439                            nbr,
5440                            id,
5441                            self.ids
5442                                .key_of(nbr)
5443                                .ok_or_else(|| GraphError::Corrupt {
5444                                    detail: format!("topology id {nbr} has no key"),
5445                                })?
5446                                .to_string(),
5447                            key.to_string(),
5448                        ),
5449                    };
5450                    edges.push(EdgeInfo {
5451                        edge_type: edge_type.clone(),
5452                        src_key,
5453                        dst_key,
5454                        derived: derived.contains(&(etype, src, dst)),
5455                    });
5456                }
5457            }
5458        }
5459        edges.sort_by(|a, b| {
5460            a.edge_type
5461                .cmp(&b.edge_type)
5462                .then(a.src_key.cmp(&b.src_key))
5463                .then(a.dst_key.cmp(&b.dst_key))
5464        });
5465        // Self-loops appear in both Out and In; sort makes the pair adjacent
5466        // (sort key matches PartialEq for this case) so one pass drops the dup.
5467        edges.dedup();
5468        Ok(edges)
5469    }
5470
5471    // ── Backup ────────────────────────────────────────────────────────────────
5472
5473    /// Copy this store to `dest` as a consistent, verified snapshot.
5474    ///
5475    /// Copies every durable file in the database directory — `snapshot.bin`,
5476    /// `wal.bin`, all `wal.<N>.archive` files, `wal.floor`, `wal.genesis`, and
5477    /// `roles.json` — into a freshly created `dest` directory using OS-level
5478    /// `copy` calls (no large in-process buffers).
5479    ///
5480    /// # Consistency guarantee
5481    ///
5482    /// The guarantee is **process-local**: the caller holds `&self`, which
5483    /// prevents any concurrent writer in the **same process** from modifying
5484    /// the files during the copy.  Running `mushroomdb backup` against a
5485    /// directory that is **concurrently being written by another process** (e.g.
5486    /// `mushroomdb serve`) is **unsafe** — the copy can be torn.  The post-copy
5487    /// `verified: true` result reduces but does not eliminate the risk of a
5488    /// silent corrupt backup (CRC catches many bit-flips; it cannot catch a
5489    /// consistent mid-write snapshot).
5490    ///
5491    /// **The safe path for a live-served store is `POST /backup` on the HTTP
5492    /// server.** That handler acquires the read lock on the shared database
5493    /// before calling this method, which is the correct cross-process
5494    /// synchronisation point because the server is the single process writing
5495    /// the files.
5496    ///
5497    /// After copying, opens the destination read-only and runs the CRC section
5498    /// verifier (`verify_snapshot`) to confirm byte-for-byte integrity.
5499    /// `BackupReport::verified` reflects whether both checks passed.
5500    ///
5501    /// Returns `Err` when `self` is not backed by a `RealFs` (e.g. `SimFs`).
5502    pub fn backup_to(&self, dest: &std::path::Path) -> Result<BackupReport> {
5503        // Derive source directory from snapshot_path (RealFs only).
5504        let src_dir = match self.fs.snapshot_path() {
5505            Some(p) => p.parent().map(|d| d.to_path_buf()).ok_or_else(|| {
5506                GraphError::Io(std::io::Error::other("snapshot has no parent dir"))
5507            })?,
5508            None => {
5509                return Err(GraphError::Io(std::io::Error::other(
5510                    "backup_to requires a real filesystem (RealFs)",
5511                )))
5512            }
5513        };
5514
5515        std::fs::create_dir_all(dest)?;
5516
5517        let mut files: Vec<String> = Vec::new();
5518        let mut bytes: u64 = 0;
5519
5520        // Helper: copy src_dir/name → dest/name if the file exists.
5521        let mut try_copy = |name: &str| -> std::io::Result<()> {
5522            let src_path = src_dir.join(name);
5523            if src_path.exists() {
5524                let n = std::fs::copy(&src_path, dest.join(name))?;
5525                bytes += n;
5526                files.push(name.to_string());
5527            }
5528            Ok(())
5529        };
5530
5531        try_copy("snapshot.bin")?;
5532        try_copy("snapshot.bin.bak")?;
5533        try_copy("wal.bin")?;
5534        try_copy("wal.floor")?;
5535        try_copy("wal.genesis")?;
5536        try_copy("roles.json")?;
5537
5538        // Copy WAL archives.
5539        let archives = self.fs.list_archives()?;
5540        for n in &archives {
5541            let name = format!("wal.{n}.archive");
5542            let n_bytes = std::fs::copy(src_dir.join(&name), dest.join(&name))?;
5543            bytes += n_bytes;
5544            files.push(name);
5545        }
5546
5547        files.sort();
5548
5549        // Post-copy verification: open dest and run CRC checks.
5550        let snap_in_dest = dest.join("snapshot.bin").exists();
5551        let crc_ok = if snap_in_dest {
5552            crate::verify_snapshot(dest)
5553                .map(|results| results.iter().all(|(_, _, _, r)| r.is_ok()))
5554                .unwrap_or(false)
5555        } else {
5556            true // WAL-only store: nothing to CRC-check in snapshot
5557        };
5558        let opens_ok = GraphDb::<core_storage::fs::RealFs>::open(dest).is_ok();
5559        let verified = crc_ok && opens_ok;
5560
5561        Ok(BackupReport {
5562            files,
5563            bytes,
5564            verified,
5565        })
5566    }
5567
5568    // ── Export helpers ────────────────────────────────────────────────────────
5569
5570    /// All live nodes, sorted by key (deterministic).
5571    ///
5572    /// Reads base + WAL overlay. Tombstoned nodes are excluded.
5573    pub fn all_nodes_for_export(&self) -> Vec<NodeInfo> {
5574        self.ensure_v8_base_sections_loaded();
5575        let pv = self.props_view();
5576        let mut nodes = Vec::new();
5577        for id in 0..self.ids.len() as u32 {
5578            let Some(key) = self.ids.key_of(id) else {
5579                continue;
5580            };
5581            let Some(&sym) = self.labels.get(id as usize) else {
5582                continue;
5583            };
5584            if sym == u32::MAX {
5585                continue; // tombstoned
5586            }
5587            let Some(label) = self.syms.resolve(sym) else {
5588                continue;
5589            };
5590            let mut props = BTreeMap::new();
5591            for field in pv.field_names() {
5592                if let Some(vr) = pv.get(id, &field) {
5593                    props.insert(field, vr.into_value());
5594                }
5595            }
5596            nodes.push(NodeInfo {
5597                key: key.to_string(),
5598                label: label.to_string(),
5599                props,
5600            });
5601        }
5602        nodes.sort_by(|a, b| a.key.cmp(&b.key));
5603        nodes
5604    }
5605
5606    /// All directed edges, sorted by `(edge_type, src, dst)`. Each edge appears once.
5607    ///
5608    /// Derived edges carry `derived: true` and the creating rule's name in `rule`.
5609    /// Manual edges carry `derived: false` and `rule: None`.
5610    /// Deterministic across runs on the same store state.
5611    pub fn all_edges_for_export(&self) -> Vec<ExportEdge> {
5612        self.ensure_v8_base_sections_loaded();
5613
5614        // Build (etype_sym, src_id, dst_id) → rule_name for O(1) derivation lookup.
5615        let mut prov: HashMap<(u32, u32, u32), String> = HashMap::new();
5616        for (rule_name, triples) in self.engine.provenance() {
5617            for &(etype, src, dst) in triples {
5618                prov.insert((etype, src, dst), rule_name.clone());
5619            }
5620        }
5621
5622        let tv = self.topo_view();
5623        let mut edges = Vec::new();
5624
5625        for id in 0..self.ids.len() as u32 {
5626            let Some(key) = self.ids.key_of(id) else {
5627                continue;
5628            };
5629            let Some(&lsym) = self.labels.get(id as usize) else {
5630                continue;
5631            };
5632            if lsym == u32::MAX {
5633                continue; // tombstoned
5634            }
5635
5636            for etype_sym in tv.etypes() {
5637                // etype from archived CSR (access_unchecked, no eager CRC).
5638                // Skip edges whose etype is not in the interner; this can only
5639                // occur with a corrupt large TOPOLOGY section (bit-flip on an
5640                // etype field in the archived data).  The function returns Vec,
5641                // not Result, so we continue rather than propagate.
5642                let Some(edge_type) = self.syms.resolve(etype_sym) else {
5643                    continue;
5644                };
5645                let edge_type = edge_type.to_string();
5646                for &nbr in tv.neighbors(etype_sym, Direction::Out, id).as_ref() {
5647                    let Some(dst_key) = self.ids.key_of(nbr) else {
5648                        continue; // skip corrupt entries
5649                    };
5650                    let prov_key = (etype_sym, id, nbr);
5651                    let rule = prov.get(&prov_key).cloned();
5652                    let derived = rule.is_some();
5653                    edges.push(ExportEdge {
5654                        edge_type: edge_type.clone(),
5655                        src: key.to_string(),
5656                        dst: dst_key.to_string(),
5657                        derived,
5658                        rule,
5659                    });
5660                }
5661            }
5662        }
5663
5664        edges.sort_by(|a, b| {
5665            a.edge_type
5666                .cmp(&b.edge_type)
5667                .then(a.src.cmp(&b.src))
5668                .then(a.dst.cmp(&b.dst))
5669        });
5670        edges
5671    }
5672
5673    pub fn nodes_with_label(&self, label: &str) -> Vec<NodeRef<'_, F>> {
5674        self.view()
5675            .nodes_with_label(label)
5676            .into_iter()
5677            .map(|id| NodeRef { db: self, id })
5678            .collect()
5679    }
5680
5681    pub fn find_nodes(&self, label: &str, filter: &Filter) -> Vec<NodeRef<'_, F>> {
5682        let view = self.view();
5683        view.nodes_with_label(label)
5684            .into_iter()
5685            .filter(|&id| {
5686                eval_filter(filter, &|field| {
5687                    view.prop(id, field).map(|vr| vr.into_value())
5688                })
5689            })
5690            .map(|id| NodeRef { db: self, id })
5691            .collect()
5692    }
5693
5694    /// Find nodes with the given `label` whose `field` vector is most similar
5695    /// to `q` (cosine similarity), returning up to `k` results with similarity
5696    /// ≥ `min`, sorted descending.
5697    ///
5698    /// Uses the HNSW index when one is available (fast path); otherwise falls
5699    /// back to an O(n) brute-force scan over all nodes with that label (exact).
5700    pub fn find_similar_vector(
5701        &self,
5702        field: &str,
5703        label: &str,
5704        q: &[f64],
5705        k: usize,
5706        min: f64,
5707    ) -> Vec<(String, f64)> {
5708        // Ensure any HNSW blobs retained from the snapshot are deserialized
5709        // before the first ANN query on a clean-open (no-WAL) path.
5710        self.engine.ensure_hnsw_loaded();
5711        // L2-normalise query for cosine via dot product.
5712        let norm: f64 = q.iter().map(|x| x * x).sum::<f64>().sqrt();
5713        if norm == 0.0 {
5714            return vec![];
5715        }
5716        let q_unit: Vec<f64> = q.iter().map(|x| x / norm).collect();
5717
5718        // Try HNSW fast path.
5719        if let Some(hits) = self.engine.hnsw_search_dst(field, label, &q_unit, k) {
5720            let mut out: Vec<(String, f64)> = hits
5721                .into_iter()
5722                .filter(|&(_, sim)| sim >= min)
5723                .filter_map(|(id, sim)| self.ids.key_of(id).map(|key| (key.to_string(), sim)))
5724                .collect();
5725            out.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
5726            out.truncate(k);
5727            return out;
5728        }
5729
5730        // Brute-force fallback: O(n) scan.
5731        let view = self.view();
5732        let mut scored: Vec<(String, f64)> = view
5733            .nodes_with_label(label)
5734            .into_iter()
5735            .filter_map(|id| {
5736                let v = view.prop(id, field)?;
5737                let v_owned = v.into_value();
5738                let xs = value_as_float_list(&v_owned)?;
5739                let v_norm: f64 = xs.iter().map(|x| x * x).sum::<f64>().sqrt();
5740                if v_norm == 0.0 {
5741                    return None;
5742                }
5743                let dot: f64 = q_unit
5744                    .iter()
5745                    .zip(xs.iter())
5746                    .map(|(a, b)| a * (b / v_norm))
5747                    .sum();
5748                if dot < min {
5749                    return None;
5750                }
5751                let key = self.ids.key_of(id)?.to_string();
5752                Some((key, dot))
5753            })
5754            .collect();
5755        scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
5756        scored.truncate(k);
5757        scored
5758    }
5759
5760    /// Lex → parse → plan → execute `cypher` over a read-only view.
5761    /// Every pipeline `Err(String)` becomes `GraphError::QueryError` with a
5762    /// stage prefix (`lex:` / `parse:` / `plan:` / `execute:`).
5763    pub fn query(&self, cypher: &str, params: &BTreeMap<String, Value>) -> Result<ResultSet> {
5764        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
5765            detail: format!("lex: {e}"),
5766        })?;
5767        let ast = parse(&tokens).map_err(|e| GraphError::QueryError {
5768            detail: format!("parse: {e}"),
5769        })?;
5770        let ops = plan(&ast).map_err(|e| GraphError::QueryError {
5771            detail: format!("plan: {e}"),
5772        })?;
5773        execute(&self.view(), &ops, &Params(params)).map_err(|e| GraphError::QueryError {
5774            detail: format!("execute: {e}"),
5775        })
5776    }
5777
5778    /// Convenience entry-point that accepts a slice of `(name, value)` pairs
5779    /// instead of a pre-built `BTreeMap`.  Equivalent to building the map and
5780    /// calling [`GraphDb::query`].
5781    pub fn query_with_params(&self, cypher: &str, params: &[(&str, Value)]) -> Result<ResultSet> {
5782        let map: BTreeMap<String, Value> = params
5783            .iter()
5784            .map(|(k, v)| (k.to_string(), v.clone()))
5785            .collect();
5786        self.query(cypher, &map)
5787    }
5788
5789    /// Execute a Cypher write statement (CREATE / MATCH…SET / MATCH…DELETE / MERGE).
5790    ///
5791    /// All mutations flow through the same `insert_node` / `set_prop` /
5792    /// `delete_edge` / `insert_edge` path as the Rust API so the rule engine
5793    /// fires and the WAL captures everything with one fsync per statement.
5794    ///
5795    /// Returns a one-row [`ResultSet`] with columns `created`, `properties_set`,
5796    /// and `deleted` matching the write-result contract.
5797    ///
5798    /// **Mutation routing**: mutations are collected into a single
5799    /// [`BatchBuilder`] and committed atomically (one WAL `Batch` frame, one
5800    /// fsync). The MATCH phase for SET/DELETE uses a read-only `execute` call
5801    /// over `self.view()` — the borrow is dropped before the batch is opened.
5802    ///
5803    /// **Limitations (v1)**:
5804    /// - SET RHS must be a literal, `$param`, or arithmetic; bare property copy → named error.
5805    /// - `DETACH DELETE n` → calls `delete_node` for each matched node (removes all edges).
5806    /// - Bare `DELETE n` → error if n has any incident edges; succeeds for isolated nodes.
5807    /// - MERGE supports `ON CREATE SET` / `ON MATCH SET` in the same write batch.
5808    /// - Deleting a derived edge → named error "cannot delete derived edge".
5809    pub fn query_write(
5810        &mut self,
5811        cypher: &str,
5812        params: &BTreeMap<String, Value>,
5813    ) -> Result<ResultSet> {
5814        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
5815            detail: format!("lex: {e}"),
5816        })?;
5817        let stmt = parse_write(&tokens).map_err(|e| GraphError::QueryError {
5818            detail: format!("parse: {e}"),
5819        })?;
5820        self.exec_write_stmt(stmt, params)
5821    }
5822
5823    fn exec_write_stmt(
5824        &mut self,
5825        stmt: WriteStatement,
5826        params: &BTreeMap<String, Value>,
5827    ) -> Result<ResultSet> {
5828        match stmt {
5829            WriteStatement::Create(s) => self.exec_create(s, params),
5830            WriteStatement::MatchSet(s) => self.exec_match_set(s, params),
5831            WriteStatement::MatchDelete(s) => self.exec_match_delete(s, params),
5832            WriteStatement::MatchDeleteNode(s) => self.exec_match_delete_node(s, params),
5833            WriteStatement::Merge(s) => self.exec_merge(s, params),
5834        }
5835    }
5836
5837    fn exec_create(
5838        &mut self,
5839        stmt: core_query::cypher::CreateStmt,
5840        params: &BTreeMap<String, Value>,
5841    ) -> Result<ResultSet> {
5842        // Extract the node key from props: require a string-valued `id` field.
5843        let mut var_to_key: BTreeMap<String, String> = BTreeMap::new();
5844        for node in &stmt.nodes {
5845            let var = node.var.as_deref().unwrap_or("_cn0");
5846            let key = node
5847                .props
5848                .iter()
5849                .find(|(f, _)| f == "id")
5850                .and_then(|(_, v)| {
5851                    if let Value::Str(s) = v {
5852                        Some(s.clone())
5853                    } else {
5854                        None
5855                    }
5856                })
5857                .ok_or_else(|| GraphError::QueryError {
5858                    detail: format!(
5859                        "CREATE node ({}:{}) requires a string 'id' property",
5860                        var, node.label
5861                    ),
5862                })?;
5863            var_to_key.insert(var.to_string(), key);
5864        }
5865
5866        let mut batch = self.batch();
5867        let mut created: usize = 0;
5868        for node in &stmt.nodes {
5869            let var = node.var.as_deref().unwrap_or("_cn0");
5870            let key = &var_to_key[var];
5871            batch.insert_node(&node.label, key, node.props.clone());
5872            created += 1;
5873        }
5874        for edge in &stmt.edges {
5875            let src_key = var_to_key
5876                .get(&edge.src_var)
5877                .ok_or_else(|| GraphError::QueryError {
5878                    detail: format!("CREATE edge src variable '{}' is not bound", edge.src_var),
5879                })?;
5880            let dst_key = var_to_key
5881                .get(&edge.dst_var)
5882                .ok_or_else(|| GraphError::QueryError {
5883                    detail: format!("CREATE edge dst variable '{}' is not bound", edge.dst_var),
5884                })?;
5885            batch.insert_edge(&edge.etype, src_key, dst_key);
5886        }
5887        batch.commit()?;
5888
5889        // Optional RETURN clause: project created bindings as a read result.
5890        if let Some(returns) = stmt.returns {
5891            // Each created node is looked up by its key via a separate MATCH pattern.
5892            // Multiple single-node patterns cross-join to produce 1 output row with
5893            // all variables bound (each pattern returns exactly 1 row).
5894            let patterns: Vec<Pattern> = stmt
5895                .nodes
5896                .iter()
5897                .map(|node| {
5898                    let var = node.var.as_deref().unwrap_or("_cn0");
5899                    let key = var_to_key[var].clone();
5900                    Pattern {
5901                        start: NodePat {
5902                            var: Some(var.to_string()),
5903                            label: Some(node.label.clone()),
5904                            props: vec![("id".to_string(), Operand::Lit(Value::Str(key)))],
5905                        },
5906                        chain: vec![],
5907                        shortest: false,
5908                    }
5909                })
5910                .collect();
5911            let q = Query {
5912                matches: patterns,
5913                optional_clauses: vec![],
5914                where_expr: None,
5915                unwinds: vec![],
5916                post_unwind_where: None,
5917                stages: vec![],
5918                returns,
5919                distinct: false,
5920                order_by: vec![],
5921                skip: None,
5922                limit: None,
5923            };
5924            let ops = plan(&q).map_err(|e| GraphError::QueryError {
5925                detail: format!("plan: {e}"),
5926            })?;
5927            return execute(&self.view(), &ops, &Params(params)).map_err(|e| {
5928                GraphError::QueryError {
5929                    detail: format!("execute: {e}"),
5930                }
5931            });
5932        }
5933
5934        let mut rs = write_result_set();
5935        rs.push_row(vec![
5936            Some(Value::Int(created as i64)),
5937            Some(Value::Int(0)),
5938            Some(Value::Int(0)),
5939        ]);
5940        Ok(rs)
5941    }
5942
5943    fn exec_match_set(
5944        &mut self,
5945        stmt: core_query::cypher::MatchSetStmt,
5946        params: &BTreeMap<String, Value>,
5947    ) -> Result<ResultSet> {
5948        let project_returns = stmt.returns.clone();
5949        // Collect unique node vars targeted by SET clauses, plus RETURN bindings
5950        // so the post-write projection can look them up by key.
5951        let mut set_vars: Vec<String> = Vec::new();
5952        for s in &stmt.sets {
5953            if !set_vars.contains(&s.var) {
5954                set_vars.push(s.var.clone());
5955            }
5956        }
5957        let rel_vars = pattern_rel_vars(&stmt.matches);
5958        let mut lookup_vars = set_vars.clone();
5959        for v in pattern_node_vars(&stmt.matches) {
5960            add_var(&mut lookup_vars, &v);
5961        }
5962        if let Some(ref returns) = project_returns {
5963            for v in ret_node_vars(returns) {
5964                if !rel_vars.iter().any(|r| r == &v) {
5965                    add_var(&mut lookup_vars, &v);
5966                }
5967            }
5968        }
5969
5970        // Synthesize a read query: MATCH … WHERE … RETURN <lookup_vars>, <set_values…>
5971        // SET values are projected as ScalarExpr items so that arithmetic expressions
5972        // (e.g. `SET n.score = n.score * 1.5`) are evaluated in the matched-row context.
5973        let mut set_returns: Vec<RetItem> = lookup_vars
5974            .iter()
5975            .map(|v| RetItem {
5976                value: RetVal::Var(v.clone()),
5977                alias: None,
5978            })
5979            .collect();
5980        // One computed column per SET clause; alias is `__sv_<i>`.
5981        let set_val_cols: Vec<String> = stmt
5982            .sets
5983            .iter()
5984            .enumerate()
5985            .map(|(i, _)| format!("__sv_{i}"))
5986            .collect();
5987        for (sc, col) in stmt.sets.iter().zip(&set_val_cols) {
5988            set_returns.push(RetItem {
5989                value: RetVal::ScalarExpr(sc.value.clone()),
5990                alias: Some(col.clone()),
5991            });
5992        }
5993        // Capture relationship types while r is bound; SET does not change them.
5994        for r in &rel_vars {
5995            set_returns.push(RetItem {
5996                value: RetVal::FuncCall {
5997                    name: "type".into(),
5998                    args: vec![Operand::Var(r.clone())],
5999                },
6000                alias: Some(rel_type_alias(r)),
6001            });
6002        }
6003
6004        let read_q = Query {
6005            matches: stmt.matches.clone(),
6006            optional_clauses: vec![],
6007            where_expr: stmt.where_expr.clone(),
6008            unwinds: vec![],
6009            post_unwind_where: None,
6010            stages: vec![],
6011            returns: set_returns,
6012            distinct: false,
6013            order_by: vec![],
6014            skip: None,
6015            limit: None,
6016        };
6017        let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
6018            detail: format!("plan: {e}"),
6019        })?;
6020        // MATCH phase is read-only; borrow ends before batch opens.
6021        let match_rs =
6022            execute(&self.view(), &ops, &Params(params)).map_err(|e| GraphError::QueryError {
6023                detail: format!("execute: {e}"),
6024            })?;
6025
6026        // Collect (key, field, value) for each matched row × each SET clause.
6027        let mut set_ops: Vec<(String, String, Value)> = Vec::new();
6028        for row_i in 0..match_rs.len() {
6029            for (sc, col) in stmt.sets.iter().zip(&set_val_cols) {
6030                let key = match match_rs.get(row_i, &sc.var) {
6031                    Some(Value::Str(k)) => k.clone(),
6032                    _ => {
6033                        return Err(GraphError::QueryError {
6034                            detail: format!(
6035                                "SET variable '{}' did not resolve to a node key",
6036                                sc.var
6037                            ),
6038                        })
6039                    }
6040                };
6041                // The SET value was already evaluated by the executor.
6042                let value = match match_rs.get(row_i, col) {
6043                    Some(v) => v.clone(),
6044                    None => {
6045                        return Err(GraphError::QueryError {
6046                            detail: format!(
6047                                "SET value for {}.{} evaluated to null",
6048                                sc.var, sc.field
6049                            ),
6050                        })
6051                    }
6052                };
6053                set_ops.push((key, sc.field.clone(), value));
6054            }
6055        }
6056
6057        // Apply as one atomic batch.
6058        let props_set = set_ops.len();
6059        let mut batch = self.batch();
6060        for (key, field, value) in set_ops {
6061            batch.set_prop(&key, &field, value);
6062        }
6063        batch.commit()?;
6064
6065        if let Some(returns) = project_returns {
6066            return project_set_return_rows(self, &rel_vars, &match_rs, &returns, params);
6067        }
6068
6069        let mut rs = write_result_set();
6070        rs.push_row(vec![
6071            Some(Value::Int(0)),
6072            Some(Value::Int(props_set as i64)),
6073            Some(Value::Int(0)),
6074        ]);
6075        Ok(rs)
6076    }
6077
6078    fn exec_match_delete(
6079        &mut self,
6080        stmt: core_query::cypher::MatchDeleteStmt,
6081        params: &BTreeMap<String, Value>,
6082    ) -> Result<ResultSet> {
6083        // Collect unique node vars needed to identify edge endpoints.
6084        let mut node_vars: Vec<String> = Vec::new();
6085        for ed in &stmt.deletes {
6086            if !node_vars.contains(&ed.src_var) {
6087                node_vars.push(ed.src_var.clone());
6088            }
6089            if !node_vars.contains(&ed.dst_var) {
6090                node_vars.push(ed.dst_var.clone());
6091            }
6092        }
6093
6094        // Synthesize read query.
6095        let returns: Vec<RetItem> = node_vars
6096            .iter()
6097            .map(|v| RetItem {
6098                value: RetVal::Var(v.clone()),
6099                alias: None,
6100            })
6101            .collect();
6102        let read_q = Query {
6103            matches: stmt.matches,
6104            optional_clauses: vec![],
6105            where_expr: stmt.where_expr,
6106            unwinds: vec![],
6107            post_unwind_where: None,
6108            stages: vec![],
6109            returns,
6110            distinct: false,
6111            order_by: vec![],
6112            skip: None,
6113            limit: None,
6114        };
6115        let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
6116            detail: format!("plan: {e}"),
6117        })?;
6118        let match_rs =
6119            execute(&self.view(), &ops, &Params(params)).map_err(|e| GraphError::QueryError {
6120                detail: format!("execute: {e}"),
6121            })?;
6122
6123        // Collect (etype, src_key, dst_key) for each row × each delete target.
6124        let mut del_ops: Vec<(String, String, String)> = Vec::new();
6125        for row_i in 0..match_rs.len() {
6126            for ed in &stmt.deletes {
6127                let src_key = match match_rs.get(row_i, &ed.src_var) {
6128                    Some(Value::Str(k)) => k.clone(),
6129                    _ => {
6130                        return Err(GraphError::QueryError {
6131                            detail: format!(
6132                                "DELETE src variable '{}' did not resolve to a node key",
6133                                ed.src_var
6134                            ),
6135                        })
6136                    }
6137                };
6138                let dst_key = match match_rs.get(row_i, &ed.dst_var) {
6139                    Some(Value::Str(k)) => k.clone(),
6140                    _ => {
6141                        return Err(GraphError::QueryError {
6142                            detail: format!(
6143                                "DELETE dst variable '{}' did not resolve to a node key",
6144                                ed.dst_var
6145                            ),
6146                        })
6147                    }
6148                };
6149                del_ops.push((ed.etype.clone(), src_key, dst_key));
6150            }
6151        }
6152
6153        // Apply as one atomic batch.
6154        let deleted = del_ops.len();
6155        let mut batch = self.batch();
6156        for (etype, src_key, dst_key) in del_ops {
6157            batch.delete_edge(&etype, &src_key, &dst_key);
6158        }
6159        batch.commit().map_err(|e| match e {
6160            GraphError::RuleOwned { .. } => GraphError::QueryError {
6161                detail: "cannot delete derived edge; retract via the rule or change the property"
6162                    .to_string(),
6163            },
6164            other => other,
6165        })?;
6166
6167        let mut rs = write_result_set();
6168        rs.push_row(vec![
6169            Some(Value::Int(0)),
6170            Some(Value::Int(0)),
6171            Some(Value::Int(deleted as i64)),
6172        ]);
6173        Ok(rs)
6174    }
6175
6176    /// Execute `MATCH … [DETACH] DELETE <node_var> [, …]`.
6177    ///
6178    /// Collects the matching node keys via an ephemeral read query, then calls
6179    /// `delete_node` on each one.  When `stmt.detach` is `false` (bare DELETE)
6180    /// the executor first checks that the node has no incident edges; if any
6181    /// remain it returns a named error matching openCypher semantics.
6182    fn exec_match_delete_node(
6183        &mut self,
6184        stmt: MatchDeleteNodeStmt,
6185        params: &BTreeMap<String, Value>,
6186    ) -> Result<ResultSet> {
6187        // Build a read query returning only the node keys we need.
6188        let returns: Vec<RetItem> = stmt
6189            .node_vars
6190            .iter()
6191            .map(|v| RetItem {
6192                value: RetVal::Var(v.clone()),
6193                alias: None,
6194            })
6195            .collect();
6196        let read_q = Query {
6197            matches: stmt.matches,
6198            optional_clauses: vec![],
6199            where_expr: stmt.where_expr,
6200            unwinds: vec![],
6201            post_unwind_where: None,
6202            stages: vec![],
6203            returns,
6204            distinct: false,
6205            order_by: vec![],
6206            skip: None,
6207            limit: None,
6208        };
6209        let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
6210            detail: format!("plan: {e}"),
6211        })?;
6212        let match_rs =
6213            execute(&self.view(), &ops, &Params(params)).map_err(|e| GraphError::QueryError {
6214                detail: format!("execute: {e}"),
6215            })?;
6216
6217        // Collect unique node keys to delete (deduplicate across rows × vars).
6218        let mut keys: Vec<String> = Vec::new();
6219        for row_i in 0..match_rs.len() {
6220            for var in &stmt.node_vars {
6221                if let Some(Value::Str(k)) = match_rs.get(row_i, var) {
6222                    if !keys.contains(k) {
6223                        keys.push(k.clone());
6224                    }
6225                }
6226            }
6227        }
6228
6229        if !stmt.detach {
6230            // openCypher bare DELETE: error if any matched node has incident edges.
6231            for key in &keys {
6232                if let Some(id) = self.ids.get(key) {
6233                    let tv = self.topo_view();
6234                    let has_edges = tv.etypes().any(|et| {
6235                        !tv.neighbors(et, Direction::Out, id).is_empty()
6236                            || !tv.neighbors(et, Direction::In, id).is_empty()
6237                    });
6238                    if has_edges {
6239                        return Err(GraphError::QueryError {
6240                            detail: format!(
6241                                "Cannot delete node `{key}` because it still has incident edges. \
6242                                 Use DETACH DELETE to remove the node and all its edges."
6243                            ),
6244                        });
6245                    }
6246                }
6247            }
6248        }
6249
6250        let mut nodes_deleted = 0i64;
6251        let mut edges_deleted = 0i64;
6252        for key in keys {
6253            match self.delete_node(&key) {
6254                Ok(report) => {
6255                    nodes_deleted += 1;
6256                    edges_deleted += (report.manual_edges + report.derived_edges) as i64;
6257                }
6258                Err(GraphError::KeyNotFound { .. }) => {
6259                    // Node may have been deleted by an earlier iteration (e.g., via
6260                    // multiple MATCH rows for the same node).  Safe to skip.
6261                }
6262                Err(e) => return Err(e),
6263            }
6264        }
6265
6266        let mut rs = write_result_set();
6267        rs.push_row(vec![
6268            Some(Value::Int(0)),
6269            Some(Value::Int(0)),
6270            Some(Value::Int(nodes_deleted + edges_deleted)),
6271        ]);
6272        Ok(rs)
6273    }
6274
6275    fn exec_merge(
6276        &mut self,
6277        stmt: core_query::cypher::MergeStmt,
6278        params: &BTreeMap<String, Value>,
6279    ) -> Result<ResultSet> {
6280        // MERGE: check if a node with the given key already exists.
6281        let key = match &stmt.key_value {
6282            Value::Str(s) => s.clone(),
6283            _ => {
6284                return Err(GraphError::QueryError {
6285                    detail: format!(
6286                        "MERGE key value must be a string (got {:?})",
6287                        stmt.key_value
6288                    ),
6289                })
6290            }
6291        };
6292
6293        if let Some(var) = stmt.var.as_deref() {
6294            for sc in stmt.on_create.iter().chain(&stmt.on_match) {
6295                if sc.var != var {
6296                    return Err(GraphError::QueryError {
6297                        detail: format!(
6298                            "SET variable '{}' does not match MERGE variable '{var}'",
6299                            sc.var
6300                        ),
6301                    });
6302                }
6303            }
6304        }
6305
6306        let existed = self.has_node(&key);
6307        let mut created = 0i64;
6308        if !existed || !stmt.on_match.is_empty() {
6309            let mut batch = self.batch();
6310            if !existed {
6311                let props = vec![(stmt.key_field.clone(), stmt.key_value.clone())];
6312                batch.insert_node(&stmt.label, &key, props);
6313                for sc in &stmt.on_create {
6314                    let value = resolve_merge_set_value(&sc.value, params)?;
6315                    batch.set_prop(&key, &sc.field, value);
6316                }
6317                created = 1;
6318            } else {
6319                for sc in &stmt.on_match {
6320                    let value = resolve_merge_set_value(&sc.value, params)?;
6321                    batch.set_prop(&key, &sc.field, value);
6322                }
6323            }
6324            batch.commit()?;
6325        }
6326
6327        // Optional RETURN clause: project the node (created or matched) as a read result.
6328        if let Some(returns) = stmt.returns {
6329            let var = stmt.var.as_deref().unwrap_or("_mn0");
6330            let q = Query {
6331                matches: vec![Pattern {
6332                    start: NodePat {
6333                        var: Some(var.to_string()),
6334                        label: Some(stmt.label.clone()),
6335                        props: vec![("id".to_string(), Operand::Lit(stmt.key_value.clone()))],
6336                    },
6337                    chain: vec![],
6338                    shortest: false,
6339                }],
6340                optional_clauses: vec![],
6341                where_expr: None,
6342                unwinds: vec![],
6343                post_unwind_where: None,
6344                stages: vec![],
6345                returns,
6346                distinct: false,
6347                order_by: vec![],
6348                skip: None,
6349                limit: None,
6350            };
6351            let ops = plan(&q).map_err(|e| GraphError::QueryError {
6352                detail: format!("plan: {e}"),
6353            })?;
6354            return execute(&self.view(), &ops, &Params(params)).map_err(|e| {
6355                GraphError::QueryError {
6356                    detail: format!("execute: {e}"),
6357                }
6358            });
6359        }
6360
6361        let mut rs = write_result_set();
6362        rs.push_row(vec![
6363            Some(Value::Int(created)),
6364            Some(Value::Int(0)),
6365            Some(Value::Int(0)),
6366        ]);
6367        Ok(rs)
6368    }
6369
6370    /// Return all rule-owned edges between `key_a` and `key_b` (either direction),
6371    /// annotated with rule name, edge type, direction, and weight.
6372    /// Results are sorted by (rule, edge_type).
6373    /// Returns `Err(KeyNotFound)` if either key is unknown.
6374    pub fn explain(&self, key_a: &str, key_b: &str) -> Result<Vec<Explanation>> {
6375        self.ensure_v8_base_sections_loaded();
6376        let id_a = self
6377            .ids
6378            .get(key_a)
6379            .ok_or_else(|| GraphError::KeyNotFound { key: key_a.into() })?;
6380        let id_b = self
6381            .ids
6382            .get(key_b)
6383            .ok_or_else(|| GraphError::KeyNotFound { key: key_b.into() })?;
6384
6385        let mut results = Vec::new();
6386
6387        // Walk the smaller incident set so explain is O(min(deg(a), deg(b)))
6388        // rather than O(total provenance).
6389        let scan = if self.engine.provenance_touching_len(id_a)
6390            <= self.engine.provenance_touching_len(id_b)
6391        {
6392            id_a
6393        } else {
6394            id_b
6395        };
6396        for (rule_name, etype, src, dst) in self.engine.provenance_touching(scan) {
6397            if !((src == id_a && dst == id_b) || (src == id_b && dst == id_a)) {
6398                continue;
6399            }
6400            let Some(rule_def) = self.engine.rules().find(|r| r.name == rule_name) else {
6401                continue;
6402            };
6403            let edge_type = match self.syms.resolve(etype) {
6404                Some(s) => s.to_string(),
6405                None => continue,
6406            };
6407            // Provenance (src, dst) ids come from the archived PROVENANCE section
6408            // (large, no eager CRC).  A corrupt section can produce ids that are
6409            // out of range; return Corrupt rather than panic.
6410            let src_key = self
6411                .ids
6412                .key_of(src)
6413                .ok_or_else(|| GraphError::Corrupt {
6414                    detail: format!("v8: provenance src id {src} not in id table"),
6415                })?
6416                .to_string();
6417            let dst_key = self
6418                .ids
6419                .key_of(dst)
6420                .ok_or_else(|| GraphError::Corrupt {
6421                    detail: format!("v8: provenance dst id {dst} not in id table"),
6422                })?
6423                .to_string();
6424            let weight = rule_def.weight_prop.as_deref().and_then(|prop| {
6425                self.edge_props_view()
6426                    .get(etype, src, dst, prop)
6427                    .and_then(|v| {
6428                        if let Value::Float(f) = v {
6429                            Some(f)
6430                        } else {
6431                            None
6432                        }
6433                    })
6434            });
6435            results.push(Explanation {
6436                rule: rule_name.to_string(),
6437                edge_type,
6438                src_key,
6439                dst_key,
6440                weight,
6441                predicate: PredicateSummary {
6442                    approximate: rule_def.approximate,
6443                    ..PredicateSummary::from(&rule_def.predicate)
6444                },
6445            });
6446        }
6447
6448        results.sort_by(|a, b| a.rule.cmp(&b.rule).then(a.edge_type.cmp(&b.edge_type)));
6449        Ok(results)
6450    }
6451
6452    pub fn neighbors(&self, key: &str, edge_type: &str, dir: Direction) -> Result<Vec<String>> {
6453        let id = self
6454            .ids
6455            .get(key)
6456            .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
6457        let Some(sym) = self.syms.get(edge_type) else {
6458            return Ok(Vec::new());
6459        };
6460        self.topo_view()
6461            .neighbors(sym, dir, id)
6462            .iter()
6463            .map(|&n| {
6464                self.ids
6465                    .key_of(n)
6466                    .map(|k| k.to_string())
6467                    .ok_or_else(|| GraphError::Corrupt {
6468                        detail: format!("topology id {n} has no key"),
6469                    })
6470            })
6471            .collect::<Result<Vec<_>>>()
6472    }
6473
6474    /// Return the last-change commit sequence for `key`, or `None` if the node
6475    /// does not exist or has never been mutated since the last V5-V7 snapshot
6476    /// (horizon-bounded for legacy stores).
6477    ///
6478    /// The returned sequence is a monotonically increasing counter that starts
6479    /// at 1 for the first commit after `open` and increments with every
6480    /// successful write.  WAL replay at open also assigns sequences (1..N for N
6481    /// replayed frames), so sequences are consistent across snapshot+WAL cycles.
6482    ///
6483    /// For V5-V7 stores opened without a V8 snapshot, nodes that were present
6484    /// in the snapshot but not touched by any WAL frame will return `None`
6485    /// (horizon-bounded: CAS against such nodes is only safe after the first
6486    /// V8 snapshot or after the node is next mutated).
6487    pub fn last_changed(&self, key: &str) -> Option<u64> {
6488        let id = self.ids.get(key)?;
6489        self.last_change.get(&id).copied()
6490    }
6491
6492    /// The current commit sequence (number of successful commits since open,
6493    /// including WAL replay frames).  Useful for recording a baseline before
6494    /// a read-modify-write cycle.
6495    pub fn commit_seq(&self) -> u64 {
6496        self.commit_seq
6497    }
6498
6499    /// Check that all `preconds` are satisfied against the current db state.
6500    /// Returns `Err(GraphError::CasConflict)` on the first failing precondition.
6501    pub(crate) fn check_preconditions(&self, preconds: &[Precondition]) -> Result<()> {
6502        for precond in preconds {
6503            match precond {
6504                Precondition::NodeUnchangedSince { key, expected } => {
6505                    // Missing entry means the node predates the WAL window or
6506                    // does not exist; treat as 0 (before any commit).
6507                    let actual = self.last_changed(key).unwrap_or_default();
6508                    if actual != *expected {
6509                        return Err(GraphError::CasConflict {
6510                            key: key.clone(),
6511                            expected: *expected,
6512                            actual,
6513                        });
6514                    }
6515                }
6516                Precondition::NodeAbsent { key } => {
6517                    // Node must not exist (not live).
6518                    if self.ids.get(key).is_some() {
6519                        let actual = self.last_changed(key).unwrap_or(0);
6520                        return Err(GraphError::CasConflict {
6521                            key: key.clone(),
6522                            expected: u64::MAX,
6523                            actual,
6524                        });
6525                    }
6526                }
6527            }
6528        }
6529        Ok(())
6530    }
6531
6532    /// Apply a batch of mutations with compare-and-set preconditions.
6533    ///
6534    /// All preconditions are checked atomically before any operation is applied.
6535    /// If any precondition fails, the entire batch is rejected with
6536    /// [`GraphError::CasConflict`] and no WAL frame is written.
6537    ///
6538    /// # Returns
6539    /// `(nodes_inserted, edges_inserted)` on success, same as [`write_batch`].
6540    ///
6541    /// # Errors
6542    /// - [`GraphError::CasConflict`] if any precondition is not satisfied.
6543    /// - Any error that [`write_batch`] would return for the ops themselves.
6544    pub fn write_batch_cas(
6545        &mut self,
6546        preconds: Vec<Precondition>,
6547        ops: Vec<BatchOp>,
6548    ) -> Result<(usize, usize)> {
6549        self.check_preconditions(&preconds)?;
6550        self.commit_logged_batch(ops, None)
6551    }
6552
6553    /// Update the per-node last-change map for a WAL record at commit `seq`.
6554    ///
6555    /// Called after a successful apply to record which nodes were touched.
6556    /// For replay, called with the WAL-frame's replayed seq.
6557    ///
6558    /// Touch definition (see [`Precondition`] doc):
6559    /// - InsertNode / InsertNodeId / SetProp / SetPropId / RemoveProp → the node.
6560    /// - InsertEdge / InsertEdgeId / DeleteEdge → both src and dst.
6561    /// - DeleteNode → node tombstoned; last_changed() returns None so no update needed.
6562    /// - DerivedEdge markers, Intern, rule/view records → no-ops.
6563    /// - Batch → recurse into inner records.
6564    fn update_last_change_from_rec(&mut self, rec: &WalRecord, seq: u64) {
6565        match rec {
6566            WalRecord::InsertNode { key, .. }
6567            | WalRecord::SetProp { key, .. }
6568            | WalRecord::RemoveProp { key, .. } => {
6569                if let Some(id) = self.ids.get(key) {
6570                    self.last_change.insert(id, seq);
6571                }
6572            }
6573            WalRecord::InsertNodeId { key, .. } => {
6574                if let Some(id) = self.ids.get(key) {
6575                    self.last_change.insert(id, seq);
6576                }
6577            }
6578            WalRecord::SetPropId { id, .. } => {
6579                self.last_change.insert(*id, seq);
6580            }
6581            WalRecord::InsertEdge {
6582                src_key, dst_key, ..
6583            }
6584            | WalRecord::DeleteEdge {
6585                src_key, dst_key, ..
6586            } => {
6587                if let Some(src_id) = self.ids.get(src_key) {
6588                    self.last_change.insert(src_id, seq);
6589                }
6590                if let Some(dst_id) = self.ids.get(dst_key) {
6591                    self.last_change.insert(dst_id, seq);
6592                }
6593            }
6594            WalRecord::InsertEdgeId { src, dst, .. } => {
6595                self.last_change.insert(*src, seq);
6596                self.last_change.insert(*dst, seq);
6597            }
6598            // DeleteNode: node is tombstoned; last_changed(key) returns None for
6599            // deleted keys (ids.get() returns None post-tombstone), so no update needed.
6600            // History markers: state no-ops; the underlying mutation already
6601            // touched the relevant nodes' last_change entries.
6602            WalRecord::DeleteNode { .. }
6603            | WalRecord::DerivedEdgeAdded { .. }
6604            | WalRecord::DerivedEdgeRetracted { .. }
6605            | WalRecord::Intern { .. }
6606            | WalRecord::CreateRule { .. }
6607            | WalRecord::DeleteRule { .. }
6608            | WalRecord::RebuildRule { .. }
6609            | WalRecord::CreateView { .. }
6610            | WalRecord::DeleteView { .. }
6611            | WalRecord::EnableFulltext { .. }
6612            | WalRecord::DisableFulltext { .. } => {}
6613            // RenameNode: node id is stable; update last_change via the new key.
6614            // Called after apply(), so ids already reflects new_key.
6615            WalRecord::RenameNode { new_key, .. } => {
6616                if let Some(id) = self.ids.get(new_key) {
6617                    self.last_change.insert(id, seq);
6618                }
6619            }
6620            WalRecord::Batch(inner) => {
6621                for inner_rec in inner {
6622                    self.update_last_change_from_rec(inner_rec, seq);
6623                }
6624            }
6625        }
6626    }
6627
6628    pub fn node_count(&self) -> usize {
6629        self.ids.len()
6630    }
6631
6632    /// Configure archive retention: keep the `N` newest WAL archives at each
6633    /// [`snapshot_with`] call when `archive_wal: true`.
6634    ///
6635    /// `Some(N)` where N > 0 → prune oldest archives keeping the newest N.
6636    /// `Some(0)` or `None` → unlimited (no pruning).
6637    ///
6638    /// Pruning only ever happens inside [`snapshot_with`]; this method only
6639    /// stores the policy.  Archives below the retention limit are deleted
6640    /// oldest-first.  The horizon floor is updated so that
6641    /// [`was_linked`] / history APIs return `CommitOutOfRange` for commits
6642    /// in pruned archives rather than silently returning wrong data.
6643    pub fn set_wal_archive_retention(&mut self, keep: Option<u32>) {
6644        self.wal_archive_retention = keep;
6645    }
6646
6647    /// Delete any WAL archives that are fully below the current horizon floor.
6648    ///
6649    /// Orphaned archives arise when the floor is written first during retention
6650    /// pruning and then a crash interrupts the archive-delete sequence.  The
6651    /// opening cleanup ensures no subsequent read path sees stale data.
6652    ///
6653    /// Under the monotonic naming scheme, the archive name N equals the
6654    /// cumulative end-frame index of the archive in global commit space (i.e.
6655    /// the archive covers global frames `[prev_n, N)`).  An archive is
6656    /// fully orphaned when `N <= wal_horizon_floor`: all of its frames fall
6657    /// below the floor and have already been counted in it.
6658    fn cleanup_orphaned_archives(&mut self) -> Result<()> {
6659        if self.wal_horizon_floor == 0 {
6660            // Floor at 0 means no pruning has ever occurred; nothing to clean.
6661            return Ok(());
6662        }
6663        let archive_ns = self.fs.list_archives()?;
6664        for n in archive_ns {
6665            if n <= self.wal_horizon_floor {
6666                // Archive N ends at global frame N; all its frames are below
6667                // the floor (floor already accounts for them) → orphaned.
6668                self.fs.delete_archive(n).map_err(GraphError::Io)?;
6669            } else {
6670                // Archives are sorted ascending; first one above floor stops scan.
6671                break;
6672            }
6673        }
6674        Ok(())
6675    }
6676
6677    /// Collect all WAL frames from surviving archives (oldest-first) then the
6678    /// live WAL into one flat list, and return the total along with the number
6679    /// of archive frames at the front of the list.
6680    ///
6681    /// Commit indices into the returned list are LOCAL (0 = first frame of
6682    /// oldest surviving archive).  To obtain the GLOBAL index add
6683    /// `self.wal_horizon_floor`.
6684    fn all_frames(&self) -> Result<(Vec<WalRecord>, u64)> {
6685        let archive_ns = self.fs.list_archives()?;
6686        let mut all: Vec<WalRecord> = Vec::new();
6687        for n in archive_ns {
6688            let bytes = self.fs.read_archive(n)?;
6689            let (frames, _) = decode_all(&bytes);
6690            all.extend(frames);
6691        }
6692        let archive_count = all.len() as u64;
6693        let live_bytes = self.fs.read(FileId::Wal)?;
6694        let (live_frames, _) = decode_all(&live_bytes);
6695        all.extend(live_frames);
6696        Ok((all, archive_count))
6697    }
6698
6699    /// Return the total number of committed WAL frames visible in the current
6700    /// horizon window, including frames in surviving WAL archives.
6701    ///
6702    /// This is the exclusive upper bound for valid `at_commit` indices in
6703    /// `was_linked`. Valid indices are `wal_horizon_floor()..wal_total_commits()`.
6704    ///
6705    /// Returns the horizon floor when all surviving history is empty.
6706    pub fn wal_total_commits(&self) -> Result<u64> {
6707        let (frames, _) = self.all_frames()?;
6708        Ok(self.wal_horizon_floor + frames.len() as u64)
6709    }
6710
6711    /// The global frame index of the first commit reachable through surviving
6712    /// archives (0 when no archives have been pruned).
6713    pub fn wal_horizon_floor(&self) -> u64 {
6714        self.wal_horizon_floor
6715    }
6716
6717    /// Return the per-node change history for `key` by scanning the on-disk WAL.
6718    ///
6719    /// ## Horizon
6720    ///
6721    /// History reaches back only to the last WAL-truncating snapshot, exactly like `open_at`.
6722    /// Snapshots written with `keep_wal: true` preserve deeper history. This is the honest,
6723    /// zero-cost contract; a durable history log is out of scope.
6724    ///
6725    /// ## Derived edges
6726    ///
6727    /// Rule-created (derived) edges are **not** in the WAL and therefore do not appear in
6728    /// history. Only edges written directly by the application are recorded.
6729    ///
6730    /// ## Deleted nodes
6731    ///
6732    /// For nodes that have been deleted, dense-id records (SetPropId, InsertEdgeId) that
6733    /// predate the deletion may not resolve (the id is tombstoned in the live map). The
6734    /// string-keyed `DeleteNode` record still matches and produces a `NodeDeleted` entry.
6735    /// Prop/edge history of a deleted node may therefore be partially unresolvable.
6736    ///
6737    /// ## Dense-id edge entries and tombstoned partners
6738    ///
6739    /// Edge entries from dense-id WAL records (`InsertEdgeId`) are omitted when the partner
6740    /// endpoint's dense id is tombstoned. As a result, a live node's history can contain an
6741    /// `EdgeRemoved` (string-keyed, always resolves) without a corresponding `EdgeAdded`.
6742    /// Build commit-bounded alias intervals for `queried_key`.
6743    ///
6744    /// Returns a list of `(key, valid_from_inclusive, valid_until_exclusive)` tuples.
6745    /// A record written under `key` at commit `c` matches the queried identity iff
6746    /// `c >= valid_from && (valid_until.is_none() || c < valid_until)`.
6747    ///
6748    /// Each alias entry carries both a lower and an upper bound so that key-reuse
6749    /// after a rename is handled correctly: if "a" is renamed to "b" at commit 5,
6750    /// then a NEW node is created as "a" at commit 7 and renamed to "c" at commit 10,
6751    /// querying "c" must NOT surface identity-1's events (commits 0–4 under "a");
6752    /// only identity-2's events (commits 7–9 under "a") are in scope.
6753    ///
6754    /// Only **forward aliasing**: querying the *new* key surfaces events written
6755    /// under the *old* key.  The reverse direction is not supported.
6756    fn build_key_alias_intervals(
6757        &self,
6758        frames: &[core_storage::wal::WalRecord],
6759        queried_key: &str,
6760    ) -> Vec<(String, u64, Option<u64>)> {
6761        use core_storage::wal::WalRecord;
6762
6763        // Pre-pass: build reverse_rename and key_starts maps.
6764        let mut reverse_rename: HashMap<String, (String, u64)> = HashMap::new();
6765        let mut key_starts: HashMap<String, Vec<u64>> = HashMap::new();
6766
6767        for (local_i, frame) in frames.iter().enumerate() {
6768            let commit = self.wal_horizon_floor + local_i as u64;
6769            let records: &[WalRecord] = match frame {
6770                WalRecord::Batch(inner) => inner.as_slice(),
6771                single => std::slice::from_ref(single),
6772            };
6773            for rec in records {
6774                match rec {
6775                    WalRecord::InsertNode { key, .. } | WalRecord::InsertNodeId { key, .. } => {
6776                        key_starts.entry(key.clone()).or_default().push(commit);
6777                    }
6778                    WalRecord::RenameNode { old_key, new_key } => {
6779                        // new_key came into existence at this commit.
6780                        key_starts.entry(new_key.clone()).or_default().push(commit);
6781                        // Record the reverse rename: new_key was introduced by renaming old_key.
6782                        reverse_rename.insert(new_key.clone(), (old_key.clone(), commit));
6783                    }
6784                    _ => {}
6785                }
6786            }
6787        }
6788
6789        // Build alias intervals by following the reverse rename chain.
6790        let mut result: Vec<(String, u64, Option<u64>)> = Vec::new();
6791        let mut current_key = queried_key.to_string();
6792        let mut current_valid_until: Option<u64> = None;
6793
6794        loop {
6795            // valid_from: the most recent commit where current_key was assigned to this
6796            // identity.  For aliases (valid_until = Some(vu)), find the last start event
6797            // for the key strictly before vu — this is where the alias's occupancy by
6798            // this identity began, correctly excluding prior identities that reused the key.
6799            let valid_from = if let Some(vu) = current_valid_until {
6800                key_starts
6801                    .get(&current_key)
6802                    .and_then(|starts| starts.iter().rev().find(|&&s| s < vu).copied())
6803                    .unwrap_or(self.wal_horizon_floor)
6804            } else {
6805                // Queried key — no upper bound; may have been introduced at any commit.
6806                self.wal_horizon_floor
6807            };
6808
6809            result.push((current_key.clone(), valid_from, current_valid_until));
6810
6811            match reverse_rename.get(&current_key) {
6812                Some((old_key, rename_commit)) => {
6813                    current_valid_until = Some(*rename_commit);
6814                    current_key = old_key.clone();
6815                }
6816                None => break,
6817            }
6818        }
6819
6820        result
6821    }
6822
6823    /// Returns true if `record_key` matches any alias interval that covers `commit`.
6824    fn aliases_match(
6825        intervals: &[(String, u64, Option<u64>)],
6826        record_key: &str,
6827        commit: u64,
6828    ) -> bool {
6829        intervals
6830            .iter()
6831            .any(|(k, vf, vu)| k == record_key && commit >= *vf && vu.is_none_or(|u| commit < u))
6832    }
6833
6834    pub fn node_history(&self, key: &str) -> Result<Vec<crate::history::HistoryEntry>> {
6835        use crate::history::{HistoryChange, HistoryEntry};
6836        use core_storage::wal::WalRecord;
6837
6838        let (frames, _) = self.all_frames()?;
6839
6840        // Resolve commit-bounded alias intervals for `key` (handles renames in the WAL).
6841        let alias_intervals = self.build_key_alias_intervals(&frames, key);
6842
6843        let mut out: Vec<HistoryEntry> = Vec::new();
6844
6845        for (local_i, frame) in frames.iter().enumerate() {
6846            let commit = self.wal_horizon_floor + local_i as u64;
6847            // Collect the inner records to process — Batch is one commit, single records are one commit.
6848            let records: &[WalRecord] = match frame {
6849                WalRecord::Batch(inner) => inner.as_slice(),
6850                single => std::slice::from_ref(single),
6851            };
6852
6853            for rec in records {
6854                let change = match rec {
6855                    WalRecord::InsertNode { label, key: k, .. }
6856                        if Self::aliases_match(&alias_intervals, k, commit) =>
6857                    {
6858                        Some(HistoryChange::NodeInserted {
6859                            label: label.clone(),
6860                        })
6861                    }
6862                    WalRecord::InsertNodeId { label, key: k, .. }
6863                        if Self::aliases_match(&alias_intervals, k, commit) =>
6864                    {
6865                        let label_str = match self.syms.resolve(*label) {
6866                            Some(s) => s.to_string(),
6867                            None => continue,
6868                        };
6869                        Some(HistoryChange::NodeInserted { label: label_str })
6870                    }
6871                    WalRecord::SetProp {
6872                        key: k,
6873                        field,
6874                        value,
6875                    } if Self::aliases_match(&alias_intervals, k, commit) => {
6876                        Some(HistoryChange::PropSet {
6877                            field: field.clone(),
6878                            value: value.clone(),
6879                        })
6880                    }
6881                    WalRecord::SetPropId { id, field, value } => match self.ids.key_of(*id) {
6882                        // key_of returns the current (post-rename) key; compare to queried key.
6883                        Some(resolved) if resolved == key => {
6884                            let field_str = match self.syms.resolve(*field) {
6885                                Some(s) => s.to_string(),
6886                                None => continue,
6887                            };
6888                            Some(HistoryChange::PropSet {
6889                                field: field_str,
6890                                value: value.clone(),
6891                            })
6892                        }
6893                        _ => None,
6894                    },
6895                    WalRecord::RemoveProp { key: k, field }
6896                        if Self::aliases_match(&alias_intervals, k, commit) =>
6897                    {
6898                        Some(HistoryChange::PropRemoved {
6899                            field: field.clone(),
6900                        })
6901                    }
6902                    WalRecord::InsertEdge {
6903                        edge_type,
6904                        src_key,
6905                        dst_key,
6906                    } => {
6907                        if Self::aliases_match(&alias_intervals, src_key, commit) {
6908                            Some(HistoryChange::EdgeAdded {
6909                                edge_type: edge_type.clone(),
6910                                other: dst_key.clone(),
6911                                outgoing: true,
6912                            })
6913                        } else if Self::aliases_match(&alias_intervals, dst_key, commit) {
6914                            Some(HistoryChange::EdgeAdded {
6915                                edge_type: edge_type.clone(),
6916                                other: src_key.clone(),
6917                                outgoing: false,
6918                            })
6919                        } else {
6920                            None
6921                        }
6922                    }
6923                    WalRecord::InsertEdgeId { etype, src, dst } => {
6924                        let etype_str = match self.syms.resolve(*etype) {
6925                            Some(s) => s.to_string(),
6926                            None => continue,
6927                        };
6928                        let src_key = self.ids.key_of(*src);
6929                        let dst_key = self.ids.key_of(*dst);
6930                        if src_key == Some(key) {
6931                            let other = match dst_key {
6932                                Some(s) => s.to_string(),
6933                                None => continue,
6934                            };
6935                            Some(HistoryChange::EdgeAdded {
6936                                edge_type: etype_str,
6937                                other,
6938                                outgoing: true,
6939                            })
6940                        } else if dst_key == Some(key) {
6941                            let other = match src_key {
6942                                Some(s) => s.to_string(),
6943                                None => continue,
6944                            };
6945                            Some(HistoryChange::EdgeAdded {
6946                                edge_type: etype_str,
6947                                other,
6948                                outgoing: false,
6949                            })
6950                        } else {
6951                            None
6952                        }
6953                    }
6954                    WalRecord::DeleteEdge {
6955                        edge_type,
6956                        src_key,
6957                        dst_key,
6958                    } => {
6959                        if Self::aliases_match(&alias_intervals, src_key, commit) {
6960                            Some(HistoryChange::EdgeRemoved {
6961                                edge_type: edge_type.clone(),
6962                                other: dst_key.clone(),
6963                                outgoing: true,
6964                            })
6965                        } else if Self::aliases_match(&alias_intervals, dst_key, commit) {
6966                            Some(HistoryChange::EdgeRemoved {
6967                                edge_type: edge_type.clone(),
6968                                other: src_key.clone(),
6969                                outgoing: false,
6970                            })
6971                        } else {
6972                            None
6973                        }
6974                    }
6975                    WalRecord::DeleteNode { key: k }
6976                        if Self::aliases_match(&alias_intervals, k, commit) =>
6977                    {
6978                        Some(HistoryChange::NodeDeleted)
6979                    }
6980                    // Skip: rule/view/fulltext/intern metadata; Batch wrapper handled above.
6981                    _ => None,
6982                };
6983
6984                if let Some(change) = change {
6985                    out.push(HistoryEntry { commit, change });
6986                }
6987            }
6988        }
6989
6990        Ok(out)
6991    }
6992
6993    /// Return the per-edge change history between nodes `a` and `b` by scanning
6994    /// the on-disk WAL.
6995    ///
6996    /// ## Horizon
6997    ///
6998    /// History reaches back only to the last WAL-truncating snapshot, exactly
6999    /// like `node_history` and `open_at`. The returned [`HistoryResult`] carries
7000    /// `total_commits` (= number of WAL frames), which is the exclusive upper
7001    /// bound for valid commit indices.
7002    ///
7003    /// ## Derived edges
7004    ///
7005    /// Rule-derived edges appear via `DerivedEdgeAdded` / `DerivedEdgeRetracted`
7006    /// WAL markers written by `log_then_apply_with` after each rule-firing
7007    /// mutation. The `rule` field of those events carries the rule name.
7008    ///
7009    /// ## DeleteNode
7010    ///
7011    /// When a node is deleted, its manual incident edges are swept inline without
7012    /// individual `DeleteEdge` WAL records. `edge_history` detects `DeleteNode`
7013    /// events for either endpoint and synthesises `Retracted(rule:None)` events
7014    /// for each manual edge that was active at that point. Derived edges active at
7015    /// the time of deletion are handled by the `DerivedEdgeRetracted` marker that
7016    /// the engine appends immediately after the `DeleteNode` record; those events
7017    /// carry correct rule attribution and are emitted by the marker arm, not the
7018    /// synthetic sweep.
7019    ///
7020    /// ## Masks
7021    ///
7022    /// Like `node_history`, this method has no mask parameter and returns WAL
7023    /// history regardless of any role mask. For masked history semantics, apply
7024    /// the mask at the caller level.
7025    pub fn edge_history(
7026        &self,
7027        a: &str,
7028        b: &str,
7029    ) -> Result<crate::history::HistoryResult<crate::history::EdgeHistoryEvent>> {
7030        use crate::history::{EdgeEvent, EdgeHistoryEvent, HistoryResult};
7031        use core_storage::wal::WalRecord;
7032
7033        let (frames, _) = self.all_frames()?;
7034        let total_commits = self.wal_horizon_floor + frames.len() as u64;
7035
7036        // Resolve all historical names for a and b (handles RenameNode in the WAL).
7037        // Intervals are commit-bounded so recycled keys don't contaminate histories.
7038        let alias_a = self.build_key_alias_intervals(&frames, a);
7039        let alias_b = self.build_key_alias_intervals(&frames, b);
7040
7041        // Active edges between a and b tracked as (edge_type, src_key, dst_key, is_derived).
7042        // The is_derived flag is used by the DeleteNode sweep: manual edges are
7043        // swept with a synthetic Retracted(rule:None); derived edges are skipped
7044        // because the engine writes a DerivedEdgeRetracted marker immediately after
7045        // the DeleteNode record, which carries the correct rule attribution.
7046        let mut active: Vec<(String, String, String, bool)> = Vec::new();
7047        let mut out: Vec<EdgeHistoryEvent> = Vec::new();
7048
7049        for (local_i, frame) in frames.iter().enumerate() {
7050            let commit = self.wal_horizon_floor + local_i as u64;
7051            let records: &[WalRecord] = match frame {
7052                WalRecord::Batch(inner) => inner.as_slice(),
7053                single => std::slice::from_ref(single),
7054            };
7055
7056            for rec in records {
7057                match rec {
7058                    WalRecord::InsertEdge {
7059                        edge_type,
7060                        src_key,
7061                        dst_key,
7062                    } => {
7063                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
7064                            && Self::aliases_match(&alias_b, dst_key, commit);
7065                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
7066                            && Self::aliases_match(&alias_a, dst_key, commit);
7067                        if is_ab || is_ba {
7068                            active.push((
7069                                edge_type.clone(),
7070                                src_key.clone(),
7071                                dst_key.clone(),
7072                                false,
7073                            ));
7074                            out.push(EdgeHistoryEvent {
7075                                edge_type: edge_type.clone(),
7076                                commit,
7077                                event: EdgeEvent::Added,
7078                                rule: None,
7079                            });
7080                        }
7081                    }
7082                    WalRecord::InsertEdgeId { etype, src, dst } => {
7083                        let etype_str = match self.syms.resolve(*etype) {
7084                            Some(s) => s.to_string(),
7085                            None => continue,
7086                        };
7087                        // Use key_of_historical so tombstoned nodes (deleted
7088                        // later in the WAL) still resolve during the scan.
7089                        let src_key = self.ids.key_of_historical(*src);
7090                        let dst_key = self.ids.key_of_historical(*dst);
7091                        let is_ab = src_key == Some(a) && dst_key == Some(b);
7092                        let is_ba = src_key == Some(b) && dst_key == Some(a);
7093                        if is_ab || is_ba {
7094                            let src_str = src_key.unwrap().to_string();
7095                            let dst_str = dst_key.unwrap().to_string();
7096                            active.push((etype_str.clone(), src_str, dst_str, false));
7097                            out.push(EdgeHistoryEvent {
7098                                edge_type: etype_str,
7099                                commit,
7100                                event: EdgeEvent::Added,
7101                                rule: None,
7102                            });
7103                        }
7104                    }
7105                    WalRecord::DeleteEdge {
7106                        edge_type,
7107                        src_key,
7108                        dst_key,
7109                    } => {
7110                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
7111                            && Self::aliases_match(&alias_b, dst_key, commit);
7112                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
7113                            && Self::aliases_match(&alias_a, dst_key, commit);
7114                        if is_ab || is_ba {
7115                            // Remove the first matching active entry (flag ignored).
7116                            if let Some(pos) = active.iter().position(|(et, s, d, _)| {
7117                                et == edge_type && s == src_key && d == dst_key
7118                            }) {
7119                                active.remove(pos);
7120                            }
7121                            out.push(EdgeHistoryEvent {
7122                                edge_type: edge_type.clone(),
7123                                commit,
7124                                event: EdgeEvent::Retracted,
7125                                rule: None,
7126                            });
7127                        }
7128                    }
7129                    WalRecord::DeleteNode { key: k }
7130                        if Self::aliases_match(&alias_a, k, commit)
7131                            || Self::aliases_match(&alias_b, k, commit) =>
7132                    {
7133                        // Sweep: implicitly retract only MANUAL active edges.
7134                        // Derived active edges are skipped here because the rule
7135                        // engine appends a DerivedEdgeRetracted marker immediately
7136                        // after this DeleteNode record; that marker produces the
7137                        // single correctly-attributed Retracted event.  Derived
7138                        // entries are dropped from `active` (the marker arm's
7139                        // idempotent retain finds nothing to remove).
7140                        for (et, _, _, is_derived) in active.drain(..) {
7141                            if !is_derived {
7142                                out.push(EdgeHistoryEvent {
7143                                    edge_type: et,
7144                                    commit,
7145                                    event: EdgeEvent::Retracted,
7146                                    rule: None,
7147                                });
7148                            }
7149                            // Derived: drop silently; marker carries the Retracted event.
7150                        }
7151                    }
7152                    WalRecord::DerivedEdgeAdded {
7153                        rule,
7154                        edge_type: et,
7155                        src_key,
7156                        dst_key,
7157                    } => {
7158                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
7159                            && Self::aliases_match(&alias_b, dst_key, commit);
7160                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
7161                            && Self::aliases_match(&alias_a, dst_key, commit);
7162                        if is_ab || is_ba {
7163                            active.push((et.clone(), src_key.clone(), dst_key.clone(), true));
7164                            out.push(EdgeHistoryEvent {
7165                                edge_type: et.clone(),
7166                                commit,
7167                                event: EdgeEvent::Added,
7168                                rule: Some(rule.clone()),
7169                            });
7170                        }
7171                    }
7172                    WalRecord::DerivedEdgeRetracted {
7173                        rule,
7174                        edge_type: et,
7175                        src_key,
7176                        dst_key,
7177                    } => {
7178                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
7179                            && Self::aliases_match(&alias_b, dst_key, commit);
7180                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
7181                            && Self::aliases_match(&alias_a, dst_key, commit);
7182                        if is_ab || is_ba {
7183                            // Push unconditionally: a derived edge whose Added marker
7184                            // predates the history horizon has no `active` entry, but
7185                            // the retraction is still a real in-window event.
7186                            // Remove from active idempotently if present.
7187                            active.retain(|(aet, s, d, _)| {
7188                                !(aet == et && s == src_key && d == dst_key)
7189                            });
7190                            out.push(EdgeHistoryEvent {
7191                                edge_type: et.clone(),
7192                                commit,
7193                                event: EdgeEvent::Retracted,
7194                                rule: Some(rule.clone()),
7195                            });
7196                        }
7197                    }
7198                    // All other records (InsertNode, SetProp, CreateRule, etc.)
7199                    // do not affect edges between a and b.
7200                    _ => {}
7201                }
7202            }
7203        }
7204
7205        Ok(HistoryResult {
7206            items: out,
7207            total_commits,
7208        })
7209    }
7210
7211    /// Return `true` iff an edge of `edge_type` existed between `a` and `b`
7212    /// (in either direction) at the WAL commit `at_commit`.
7213    ///
7214    /// ## Horizon
7215    ///
7216    /// Valid commit indices are `0..total_commits` where `total_commits` is the
7217    /// number of WAL frames. An `at_commit >= total_commits` is outside the
7218    /// visible horizon and returns [`GraphError::CommitOutOfRange`].
7219    ///
7220    /// ## Derived edges
7221    ///
7222    /// Rule-derived edges are tracked via `DerivedEdgeAdded` / `DerivedEdgeRetracted`
7223    /// WAL markers appended at firing time (Task 1). `was_linked` reads these markers
7224    /// and therefore includes derived edges in its point-in-time evaluation,
7225    /// matching `edge_history`'s fidelity.
7226    pub fn was_linked(&self, a: &str, b: &str, edge_type: &str, at_commit: u64) -> Result<bool> {
7227        use core_storage::wal::WalRecord;
7228
7229        let (frames, _) = self.all_frames()?;
7230        let total_commits = self.wal_horizon_floor + frames.len() as u64;
7231
7232        // Horizon floor: commits in pruned archives are unreachable.
7233        if at_commit < self.wal_horizon_floor {
7234            return Err(GraphError::CommitOutOfRange {
7235                commit: at_commit,
7236                total: total_commits,
7237            });
7238        }
7239        if at_commit >= total_commits {
7240            return Err(GraphError::CommitOutOfRange {
7241                commit: at_commit,
7242                total: total_commits,
7243            });
7244        }
7245
7246        // Resolve all historical names for a and b (handles RenameNode in the WAL).
7247        // Intervals are commit-bounded so recycled keys don't contaminate point-in-time reads.
7248        let alias_a = self.build_key_alias_intervals(&frames, a);
7249        let alias_b = self.build_key_alias_intervals(&frames, b);
7250
7251        // Local index into surviving frames (0 = first frame of oldest archive).
7252        let local_commit = at_commit - self.wal_horizon_floor;
7253
7254        // Replay local frames 0..=local_commit, tracking active edges.
7255        let mut active: BTreeSet<(String, String, String)> = BTreeSet::new();
7256
7257        for (local_i, frame) in frames.iter().enumerate().take((local_commit + 1) as usize) {
7258            let commit = self.wal_horizon_floor + local_i as u64;
7259            let records: &[WalRecord] = match frame {
7260                WalRecord::Batch(inner) => inner.as_slice(),
7261                single => std::slice::from_ref(single),
7262            };
7263
7264            for rec in records {
7265                match rec {
7266                    WalRecord::InsertEdge {
7267                        edge_type: et,
7268                        src_key,
7269                        dst_key,
7270                    } => {
7271                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
7272                            && Self::aliases_match(&alias_b, dst_key, commit);
7273                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
7274                            && Self::aliases_match(&alias_a, dst_key, commit);
7275                        if is_ab || is_ba {
7276                            active.insert((et.clone(), src_key.clone(), dst_key.clone()));
7277                        }
7278                    }
7279                    WalRecord::InsertEdgeId { etype, src, dst } => {
7280                        let etype_str = match self.syms.resolve(*etype) {
7281                            Some(s) => s.to_string(),
7282                            None => continue,
7283                        };
7284                        // Use key_of_historical so tombstoned nodes resolve.
7285                        let src_key = self.ids.key_of_historical(*src);
7286                        let dst_key = self.ids.key_of_historical(*dst);
7287                        let is_ab = src_key == Some(a) && dst_key == Some(b);
7288                        let is_ba = src_key == Some(b) && dst_key == Some(a);
7289                        if is_ab || is_ba {
7290                            active.insert((
7291                                etype_str,
7292                                src_key.unwrap().to_string(),
7293                                dst_key.unwrap().to_string(),
7294                            ));
7295                        }
7296                    }
7297                    WalRecord::DeleteEdge {
7298                        edge_type: et,
7299                        src_key,
7300                        dst_key,
7301                    } => {
7302                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
7303                            && Self::aliases_match(&alias_b, dst_key, commit);
7304                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
7305                            && Self::aliases_match(&alias_a, dst_key, commit);
7306                        if is_ab || is_ba {
7307                            active.remove(&(et.clone(), src_key.clone(), dst_key.clone()));
7308                        }
7309                    }
7310                    WalRecord::DeleteNode { key: k }
7311                        if Self::aliases_match(&alias_a, k, commit)
7312                            || Self::aliases_match(&alias_b, k, commit) =>
7313                    {
7314                        // All edges touching the deleted node are gone.
7315                        active.retain(|(_, s, d)| s != k && d != k);
7316                    }
7317                    WalRecord::DerivedEdgeAdded {
7318                        edge_type: et,
7319                        src_key,
7320                        dst_key,
7321                        ..
7322                    } => {
7323                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
7324                            && Self::aliases_match(&alias_b, dst_key, commit);
7325                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
7326                            && Self::aliases_match(&alias_a, dst_key, commit);
7327                        if is_ab || is_ba {
7328                            active.insert((et.clone(), src_key.clone(), dst_key.clone()));
7329                        }
7330                    }
7331                    WalRecord::DerivedEdgeRetracted {
7332                        edge_type: et,
7333                        src_key,
7334                        dst_key,
7335                        ..
7336                    } => {
7337                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
7338                            && Self::aliases_match(&alias_b, dst_key, commit);
7339                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
7340                            && Self::aliases_match(&alias_a, dst_key, commit);
7341                        if is_ab || is_ba {
7342                            active.remove(&(et.clone(), src_key.clone(), dst_key.clone()));
7343                        }
7344                    }
7345                    _ => {}
7346                }
7347            }
7348        }
7349
7350        Ok(active.iter().any(|(et, _, _)| et == edge_type))
7351    }
7352
7353    pub fn edge_count(&self) -> u64 {
7354        self.topo_view().edge_count()
7355    }
7356
7357    /// Live/tombstone/edge counts plus per-rule provenance size, trip latch,
7358    /// and fire counter (includes rebuild evaluations). Rules are sorted by name.
7359    pub fn stats(&self) -> Stats {
7360        self.ensure_v8_base_sections_loaded();
7361        let rules: Vec<RuleStats> = self
7362            .engine
7363            .rules()
7364            .map(|r| RuleStats {
7365                name: r.name.clone(),
7366                edges: self
7367                    .engine
7368                    .provenance()
7369                    .get(&r.name)
7370                    .map(|s| s.len() as u64)
7371                    .unwrap_or(0),
7372                tripped: self.engine.is_tripped(&r.name),
7373                fires: self.engine.fire_count(&r.name),
7374                approximate: r.approximate,
7375            })
7376            .collect();
7377        Stats {
7378            nodes_live: self.ids.live_len(),
7379            nodes_tombstoned: self.ids.len() - self.ids.live_len(),
7380            edges: self.topo_view().edge_count(),
7381            rules,
7382        }
7383    }
7384
7385    /// On-disk snapshot format version this binary writes and reads.
7386    pub fn format_version() -> u16 {
7387        core_storage::snapshot::VERSION
7388    }
7389
7390    /// Test-support: total bytes appended (SimFs only usage).
7391    pub fn fs_total_appended(&self) -> usize
7392    where
7393        F: FsIntrospect,
7394    {
7395        self.fs.total_appended()
7396    }
7397
7398    /// Test-support: successful `Fs::sync` calls (SimFs / counting fs).
7399    pub fn fs_sync_count(&self) -> usize
7400    where
7401        F: FsIntrospect,
7402    {
7403        self.fs.sync_count()
7404    }
7405
7406    /// Consume the db, returning its fs (for crash simulation).
7407    pub fn into_fs(self) -> F {
7408        self.fs
7409    }
7410
7411    pub fn snapshot(&mut self) -> Result<()> {
7412        self.snapshot_with(SnapshotOptions::default())
7413    }
7414
7415    /// Snapshot with explicit options.
7416    ///
7417    /// # `keep_wal`
7418    ///
7419    /// When `keep_wal` is `false` (the default, same as [`snapshot`]):
7420    ///   - The WAL is replaced with a minimal baseline containing one
7421    ///     `EnableFulltext` record per active declaration.  All pre-snapshot
7422    ///     history is discarded; `open_at` can only reach post-snapshot commits.
7423    ///
7424    /// When `keep_wal` is `true`:
7425    ///   - The WAL is left intact.  All pre-snapshot commits remain reachable
7426    ///     via `open_at`.  The existing WAL already contains the original
7427    ///     `EnableFulltext` records, so no baseline re-write is needed; the
7428    ///     recovery guards in `apply()` silently skip any duplicate records on
7429    ///     replay.
7430    ///   - Crash window: a crash after the snapshot write but before the next
7431    ///     WAL write leaves the full pre-snapshot WAL intact.  On reopen the
7432    ///     snapshot is loaded and the WAL replayed idempotently over it — safe
7433    ///     because every `apply()` arm is idempotent when replayed over an
7434    ///     already-current snapshot.
7435    pub fn snapshot_with(&mut self, opts: SnapshotOptions) -> Result<()> {
7436        if self.read_only {
7437            return Err(GraphError::ReadOnly);
7438        }
7439        // Capture whether snapshot.bin already existed BEFORE this snapshot write.
7440        // Used by the archive path's conservative genesis-chain check: if a prior
7441        // snapshot exists but wal.truncated does not, we cannot distinguish a
7442        // legacy store (may have been truncated in an older code version) from a
7443        // new store that only used keep_wal=true.  Conservative: refuse genesis in
7444        // both cases.  Must be sampled here, before the snapshot write below.
7445        let had_prior_snapshot = self.fs.snapshot_path().map(|p| p.exists()).unwrap_or(false);
7446        self.ensure_v8_base_sections_loaded();
7447        // Ensure provenance is decoded before to_persist() clones it.
7448        self.engine.ensure_provenance_loaded_mut();
7449        let (rule_defs_typed, provenance, rule_tripped, rule_fires) = self.engine.to_persist();
7450        let rule_defs = rule_defs_typed
7451            .iter()
7452            .map(|r| bincode::serialize(r).expect("RuleDef serialize cannot fail"))
7453            .collect();
7454        // Collect HNSW state and IVF state.  When indexes are not yet
7455        // populated (clean open, no mutation since open), pass the retained
7456        // raw bytes through directly so that migrate/snapshot does not
7457        // silently discard fitted approximate-rule indexes.
7458        let hnsw_state = self.engine.export_hnsw_state_passthrough();
7459        let ivf_bytes = if !self.engine.indexes_populated() {
7460            // Pass retained IVF bytes through unchanged (no re-encode).
7461            self.engine.retained_ivf_bytes_clone().unwrap_or_default()
7462        } else {
7463            // Indexes live: encode from current state.
7464            let raw_ivf = self.engine.export_ivf_state();
7465            let ivf_state_map: BTreeMap<String, core_storage::snapshot::PerRuleIvfState> = raw_ivf
7466                .into_iter()
7467                .map(|(name, ((sc, sa, sd), (dc, da, dd)))| {
7468                    (
7469                        name,
7470                        core_storage::snapshot::PerRuleIvfState {
7471                            src: core_storage::snapshot::SideIvfState {
7472                                centroids: sc,
7473                                clusters: sa,
7474                                drift: sd,
7475                            },
7476                            dst: core_storage::snapshot::SideIvfState {
7477                                centroids: dc,
7478                                clusters: da,
7479                                drift: dd,
7480                            },
7481                        },
7482                    )
7483                })
7484                .collect();
7485            if ivf_state_map.is_empty() {
7486                Vec::new()
7487            } else {
7488                bincode::serialize(&ivf_state_map).expect("IVF state serialize cannot fail")
7489            }
7490        };
7491        let view_defs: Vec<Vec<u8>> = self
7492            .view_store
7493            .views()
7494            .map(|v| bincode::serialize(v).expect("ViewDef serialize cannot fail"))
7495            .collect();
7496        if self.base.is_some() {
7497            // V8 merge-snapshot path: encode base+overlay into a new V8 snapshot,
7498            // write it atomically, remap it as the new base, then clear the overlay.
7499            let meta = V8Meta {
7500                labels: self.labels.clone(),
7501                edge_props: self.edge_props.clone(),
7502                rule_defs,
7503                provenance,
7504                rule_tripped,
7505                rule_fires,
7506                ivf_bytes,
7507                view_defs,
7508                wal_truncated: !opts.keep_wal,
7509                hnsw: hnsw_state,
7510                last_change: self.last_change.clone(),
7511            };
7512            let mut buf: Vec<u8> = Vec::new();
7513            {
7514                // Clone the Arc so the old base stays alive while we encode.
7515                // The borrow of archived_csr (into old_base's mmap) is released
7516                // at the end of this block, before we replace self.base.
7517                let old_base = self.base.clone().expect("is_some checked above");
7518                let archived_csr = old_base.topology().map_err(|e| GraphError::Corrupt {
7519                    detail: format!("v8 snapshot: topology section: {e:?}"),
7520                })?;
7521                let archived_cols = old_base.columns().map_err(|e| GraphError::Corrupt {
7522                    detail: format!("v8 snapshot: columns section: {e:?}"),
7523                })?;
7524                let archived_edge_props =
7525                    old_base
7526                        .edge_props_section()
7527                        .map_err(|e| GraphError::Corrupt {
7528                            detail: format!("v8 snapshot: edge_props section: {e:?}"),
7529                        })?;
7530                let edge_props_raw =
7531                    old_base
7532                        .edge_props_raw_bytes()
7533                        .map_err(|e| GraphError::Corrupt {
7534                            detail: format!("v8 snapshot: edge_props raw bytes: {e:?}"),
7535                        })?;
7536                let prov_raw =
7537                    old_base
7538                        .provenance_raw_bytes()
7539                        .map_err(|e| GraphError::Corrupt {
7540                            detail: format!("v8 snapshot: provenance raw bytes: {e:?}"),
7541                        })?;
7542                encode_v8(
7543                    Some(archived_csr),
7544                    Some(archived_cols),
7545                    Some((archived_edge_props, edge_props_raw)),
7546                    Some(prov_raw),
7547                    &self.topo,
7548                    &self.props,
7549                    &self.ids,
7550                    &self.syms,
7551                    &meta,
7552                    &mut buf,
7553                )?;
7554            }
7555            self.fs.write_atomic(FileId::Snapshot, &buf)?;
7556            // Remap the freshly-written snapshot as the new base.
7557            // C2: use file mmap on RealFs; fall back to from_bytes on SimFs.
7558            let new_base = if let Some(snap_path) = self.fs.snapshot_path() {
7559                core_storage::v8::MappedBase::map(&snap_path)
7560            } else {
7561                core_storage::v8::MappedBase::from_bytes(buf)
7562            }
7563            .map_err(|e| GraphError::Corrupt {
7564                detail: format!("v8 snapshot: remap new base: {e:?}"),
7565            })?;
7566            self.base = Some(Arc::new(new_base));
7567            // Clear the overlay and prop tombstones — all data is now in the new base.
7568            self.topo = Topology::new();
7569            self.props = core_storage::columns::ColumnStore::new();
7570        } else {
7571            // Legacy path (V5–V7 stores without a V8 base).
7572            //
7573            // Memory-diet path: build V8Meta directly from &self — no SnapshotState
7574            // clone and no encode_v8_from_state intermediate clones.  The big
7575            // structures (self.topo, self.props) are borrowed, not cloned.
7576            // self.edge_props is moved (not cloned) because we immediately clear it
7577            // when we remap the new V8 snapshot as self.base (see below).
7578            //
7579            // Eliminates from peak RSS vs. the old SnapshotState path:
7580            //   • self.topo.clone()      (~topology HashMap footprint)
7581            //   • self.props.clone()     (~column-store footprint)
7582            //   • encode_v8_from_state V8Meta secondary clones (labels, edge_props, …)
7583            let meta = V8Meta {
7584                labels: self.labels.clone(),
7585                wal_truncated: !opts.keep_wal,
7586                // Move edge_props out so the large overlay is freed when meta
7587                // drops at end of this block (self.edge_props is now empty; reads
7588                // after base assignment go through the mmap'd base section).
7589                edge_props: std::mem::take(&mut self.edge_props),
7590                rule_defs,
7591                provenance,
7592                rule_tripped,
7593                rule_fires,
7594                ivf_bytes,
7595                view_defs,
7596                hnsw: hnsw_state,
7597                last_change: self.last_change.clone(),
7598            };
7599            let mut buf = Vec::new();
7600            encode_v8(
7601                None,
7602                None,
7603                None,
7604                None,
7605                &self.topo,
7606                &self.props,
7607                &self.ids,
7608                &self.syms,
7609                &meta,
7610                &mut buf,
7611            )?;
7612            // meta (and the moved edge_props inside it) is no longer needed;
7613            // drop it before the write to keep the peak window narrow.
7614            drop(meta);
7615            self.fs.write_atomic(FileId::Snapshot, &buf)?;
7616            // Remap the freshly-written V8 snapshot as self.base.
7617            // On RealFs: drop the encode buffer before mmap to recover ~1.9 GiB.
7618            // On SimFs (tests): pass buf to from_bytes.
7619            let new_base = if let Some(snap_path) = self.fs.snapshot_path() {
7620                drop(buf);
7621                core_storage::v8::MappedBase::map(&snap_path)
7622            } else {
7623                core_storage::v8::MappedBase::from_bytes(buf)
7624            }
7625            .map_err(|e| GraphError::Corrupt {
7626                detail: format!("v8 snapshot: remap new base (legacy path): {e:?}"),
7627            })?;
7628            self.base = Some(Arc::new(new_base));
7629            // Free the large heap-allocated decoded state — all data is now in the
7630            // mmap'd base.  Mirrors the V8 merge-snapshot path (see above).
7631            // self.edge_props was already moved into meta and is effectively empty.
7632            self.topo = Topology::new();
7633            self.props = core_storage::columns::ColumnStore::new();
7634        }
7635
7636        if opts.archive_wal {
7637            // History-preserving snapshot (Task 4):
7638            //   1. Snapshot already written above (write_atomic → fsynced).
7639            //   2. Rename WAL → wal.<commit_seq>.archive  (atomic, same fs).
7640            //      Crash window B: crash here leaves archive present, WAL
7641            //      absent.  Reopen: snapshot loaded (full state), no WAL
7642            //      replay.  Archive is NOT replayed into live state — it is
7643            //      pre-snapshot by construction.  Safe.
7644            //   3. Optionally write genesis marker (first archive only, no
7645            //      prior WAL truncation).
7646            //   4. Prune old archives (retention), update horizon floor.
7647            //      Pruning invalidates the genesis chain; delete marker.
7648            //   5. Write new minimal baseline WAL (write_atomic).
7649            //      Crash window C: crash here leaves new archive plus no live
7650            //      WAL.  Same as window B — handled above.
7651            //
7652            // Sample existing archives BEFORE the rename so we can detect
7653            // whether this is the first archive.
7654            let existing_archives = self.fs.list_archives()?;
7655            let is_first_archive = existing_archives.is_empty();
7656
7657            // Compute a globally-monotonic archive name: the name equals the
7658            // cumulative end-frame index of the archive in global commit space.
7659            //
7660            // Using `commit_seq` directly is UNSOUND across sessions: on reopen
7661            // commit_seq is seeded from max(last_change), which underestimates
7662            // the WAL depth when trailing commits (e.g. insert_edge) do not
7663            // update last_change.  A session-2 archive could then receive a name
7664            // ≤ the session-1 archive, causing incorrect sort order or collision.
7665            //
7666            // Instead: read and decode the live WAL here (before the rename) to
7667            // get its exact frame count, then add it to the last known global
7668            // end-frame index (the name of the most recent existing archive, or
7669            // wal_horizon_floor if no archives exist).  This is O(WAL size) but
7670            // snapshot is already serialising the full graph state, so the cost
7671            // is dominated.
7672            let live_wal_bytes_for_name = self.fs.read(FileId::Wal)?;
7673            let (live_frames_for_name, _) = decode_all(&live_wal_bytes_for_name);
7674            let archive_n = existing_archives
7675                .last()
7676                .copied()
7677                .unwrap_or(self.wal_horizon_floor)
7678                + live_frames_for_name.len() as u64;
7679            self.fs.archive_wal(archive_n)?;
7680
7681            // Genesis marker: written once when the first archive is taken
7682            // from a store that has never undergone a WAL-truncating snapshot.
7683            // When present, `open_at` may replay archive-resident commits from
7684            // empty state (the archive chain covers from global index 0).
7685            //
7686            // Two conditions must ALL hold:
7687            //   1. This is the first archive (existing_archives was empty).
7688            //   2. No snapshot.bin existed before this operation (had_prior_snapshot=false).
7689            //      A WAL-truncating snapshot (keep_wal=false) always writes snapshot.bin
7690            //      before truncating the WAL, so if any prior truncating snapshot was taken
7691            //      — even in a previous session — snapshot.bin is present and this condition
7692            //      is false.  This subsumes the cross-session truncation case without
7693            //      requiring a separate wal.truncated sidecar file.
7694            //      For legacy stores (snapshot.bin written by an older code version that
7695            //      may have truncated the WAL), the same conservative refusal applies:
7696            //      we cannot prove the chain is complete, so we refuse genesis (cost =
7697            //      no as-of-through-archives; never silent wrong data).
7698            //      On SimFs (snapshot_path() == None) had_prior_snapshot is always false,
7699            //      so SimFs always passes this check.
7700            if is_first_archive && !had_prior_snapshot {
7701                self.fs.write_genesis_marker()?;
7702                self.archive_genesis_chain = true;
7703            }
7704
7705            // Retention pruning: keep newest `keep` archives; delete oldest.
7706            // Pruning is the ONLY deletion site for archives.
7707            //
7708            // Crash-safety ordering (C1 fix):
7709            //   1. Count frames in surplus archives (reads only — no mutation).
7710            //   2. Advance and PERSIST the horizon floor FIRST via write-then-
7711            //      rename (atomic).  A crash after this point leaves orphaned
7712            //      archives on disk, but the floor is correct.  The opening
7713            //      cleanup sweep (`cleanup_orphaned_archives`) removes them on
7714            //      the next open, so the store is always safe to reopen.
7715            //   3. Delete the genesis marker (floor > 0 already blocks open_at
7716            //      via the conjunctive gate; marker cleanup is belt-and-suspenders).
7717            //   4. Delete surplus archives.  A crash between any two deletes
7718            //      leaves the floor committed and orphaned archives cleaned at
7719            //      next open — never a stale floor with a missing archive prefix.
7720            if let Some(keep) = self.wal_archive_retention {
7721                if keep > 0 {
7722                    let archives = self.fs.list_archives()?;
7723                    // archives is sorted ascending (oldest first)
7724                    if archives.len() as u32 > keep {
7725                        let surplus = archives.len() - keep as usize;
7726                        // Step 1: count pruned frames (reads, no mutation).
7727                        let mut pruned_frames = 0u64;
7728                        for &n in &archives[..surplus] {
7729                            let bytes = self.fs.read_archive(n)?;
7730                            let (frames, _) = decode_all(&bytes);
7731                            pruned_frames += frames.len() as u64;
7732                        }
7733                        // Step 2: advance and persist floor FIRST.
7734                        self.wal_horizon_floor += pruned_frames;
7735                        self.fs.write_horizon_floor(self.wal_horizon_floor)?;
7736                        // Step 3: delete genesis marker (floor > 0 already
7737                        // blocks open_at; this is belt-and-suspenders cleanup).
7738                        if pruned_frames > 0 && self.archive_genesis_chain {
7739                            self.fs.delete_genesis_marker()?;
7740                            self.archive_genesis_chain = false;
7741                        }
7742                        // Step 4: delete surplus archives.  Crash here →
7743                        // orphaned archives; cleaned at next open.
7744                        for &n in &archives[..surplus] {
7745                            self.fs.delete_archive(n)?;
7746                        }
7747                    }
7748                }
7749            }
7750
7751            // Write new minimal baseline WAL (mirrors the keep_wal=false path).
7752            let mut baseline_wal: Vec<u8> = Vec::new();
7753            for (label, field) in self.fulltext.enabled_pairs() {
7754                let rec = WalRecord::EnableFulltext {
7755                    label: label.clone(),
7756                    field: field.clone(),
7757                };
7758                baseline_wal.extend_from_slice(&encode_record(&rec));
7759            }
7760            self.fs.write_atomic(FileId::Wal, &baseline_wal)?;
7761        } else if opts.keep_wal {
7762            // keep_wal=true: WAL is left untouched.  The existing WAL already
7763            // contains the EnableFulltext records from the original enable calls;
7764            // replay is idempotent (guards in apply() skip already-live entries).
7765            // No baseline re-write is needed or safe here — the full WAL history
7766            // must remain intact for open_at to reach pre-snapshot commits.
7767        } else {
7768            // keep_wal=false (default): truncate by replacing the WAL with a
7769            // minimal baseline of one EnableFulltext record per active pair.
7770            //
7771            // Crash-ordering: write_atomic is atomic.
7772            //   • Crash before snapshot write  → WAL unchanged.  Safe.
7773            //   • Crash after snapshot write but before this WAL write → full
7774            //     pre-snapshot WAL still present; open_with replays idempotently.
7775            //   • Crash after both writes → normal post-snapshot state.
7776            //
7777            // Genesis chain: a WAL-truncating snapshot breaks the archive chain
7778            // for any archives taken AFTER this point (their WAL slices would
7779            // not start at genesis).  Delete any existing genesis marker so that
7780            // open_at refuses archive-resident commits.  Future sessions are
7781            // covered by had_prior_snapshot: snapshot.bin written here persists
7782            // across sessions and prevents a later archiving session from
7783            // incorrectly claiming a complete genesis chain.
7784            if self.archive_genesis_chain {
7785                self.fs.delete_genesis_marker()?;
7786                self.archive_genesis_chain = false;
7787            }
7788            let mut baseline_wal: Vec<u8> = Vec::new();
7789            for (label, field) in self.fulltext.enabled_pairs() {
7790                let rec = WalRecord::EnableFulltext {
7791                    label: label.clone(),
7792                    field: field.clone(),
7793                };
7794                baseline_wal.extend_from_slice(&encode_record(&rec));
7795            }
7796            self.fs.write_atomic(FileId::Wal, &baseline_wal)?;
7797        }
7798        // After snapshot the overlay may have changed (V8 merge path clears
7799        // self.topo and self.props). Refresh the MVCC fold so future readers
7800        // see the post-snapshot state rather than stale overlay data.
7801        self.fold_now();
7802        Ok(())
7803    }
7804}
7805
7806/// Queued mutation for a [`BatchBuilder`] or [`GraphDb::commit_group`].
7807///
7808/// The `submit_batch` / `commit_group` APIs accept `Vec<BatchOp>` so that
7809/// callers can build a set of mutations without holding `&mut GraphDb` and
7810/// hand them off to the group-committing writer for durable, batched I/O.
7811pub enum BatchOp {
7812    InsertNode {
7813        label: String,
7814        key: String,
7815        props: Vec<(String, Value)>,
7816    },
7817    InsertEdge {
7818        edge_type: String,
7819        src_key: String,
7820        dst_key: String,
7821    },
7822    SetProp {
7823        key: String,
7824        field: String,
7825        value: Value,
7826    },
7827    RemoveProp {
7828        key: String,
7829        field: String,
7830    },
7831    DeleteEdge {
7832        edge_type: String,
7833        src_key: String,
7834        dst_key: String,
7835    },
7836    DeleteNode {
7837        key: String,
7838    },
7839    CreateRule(RuleDef),
7840    DeleteRule {
7841        name: String,
7842    },
7843    /// Rename a node's key. Validated: old must exist, new must not.
7844    RenameNode {
7845        old_key: String,
7846        new_key: String,
7847    },
7848    /// Insert an edge, auto-creating any missing endpoint as a plain node with
7849    /// `placeholder_label` and no props. Rules fire and last-change is updated
7850    /// for each created endpoint (normal InsertNode semantics in the batch frame).
7851    InsertEdgeUpsert {
7852        edge_type: String,
7853        src_key: String,
7854        dst_key: String,
7855        placeholder_label: String,
7856    },
7857}
7858
7859/// Overlay of ops already accepted earlier in the same batch. Never written
7860/// back to the database — validation only.
7861#[derive(Default)]
7862struct Overlay {
7863    extra_keys: BTreeSet<String>,
7864    deleted_keys: BTreeSet<String>,
7865    extra_props: BTreeMap<(String, String), Value>,
7866    removed_props: BTreeSet<(String, String)>,
7867    extra_edges: BTreeSet<(String, String, String)>,
7868    deleted_edges: BTreeSet<(String, String, String)>,
7869    extra_rules: BTreeSet<String>,
7870    deleted_rules: BTreeSet<String>,
7871}
7872
7873/// Read-only view of live db state plus a batch overlay. Shared by single-op
7874/// public methods (empty overlay) and `commit_batch`.
7875struct MutPreview<'a, F: Fs> {
7876    db: &'a GraphDb<F>,
7877    overlay: Overlay,
7878}
7879
7880impl<'a, F: Fs> MutPreview<'a, F> {
7881    fn new(db: &'a GraphDb<F>) -> Self {
7882        Self {
7883            db,
7884            overlay: Overlay::default(),
7885        }
7886    }
7887
7888    fn has_key(&self, key: &str) -> bool {
7889        if self.overlay.extra_keys.contains(key) {
7890            return true;
7891        }
7892        if self.overlay.deleted_keys.contains(key) {
7893            return false;
7894        }
7895        self.db.ids.get(key).is_some()
7896    }
7897
7898    fn has_prop(&self, key: &str, field: &str) -> bool {
7899        if !self.has_key(key) {
7900            return false;
7901        }
7902        let k = (key.to_string(), field.to_string());
7903        if self.overlay.removed_props.contains(&k) {
7904            return false;
7905        }
7906        if self.overlay.extra_props.contains_key(&k) {
7907            return true;
7908        }
7909        // Fresh identity (first insert in this batch, or delete+reinsert):
7910        // ignore props still sitting on the soon-to-be-tombstoned slot.
7911        if self.overlay.extra_keys.contains(key) {
7912            return false;
7913        }
7914        self.db.get_prop(key, field).is_some()
7915    }
7916
7917    fn has_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
7918        let k = (
7919            edge_type.to_string(),
7920            src_key.to_string(),
7921            dst_key.to_string(),
7922        );
7923        if self.overlay.deleted_edges.contains(&k) {
7924            return false;
7925        }
7926        if self.overlay.extra_edges.contains(&k) {
7927            return true;
7928        }
7929        // A key created in this batch (including reinsert) has no db edges.
7930        if self.overlay.extra_keys.contains(src_key) || self.overlay.extra_keys.contains(dst_key) {
7931            return false;
7932        }
7933        if self.overlay.deleted_keys.contains(src_key)
7934            || self.overlay.deleted_keys.contains(dst_key)
7935        {
7936            return false;
7937        }
7938        let Some(src) = self.db.ids.get(src_key) else {
7939            return false;
7940        };
7941        let Some(dst) = self.db.ids.get(dst_key) else {
7942            return false;
7943        };
7944        let Some(sym) = self.db.syms.get(edge_type) else {
7945            return false;
7946        };
7947        self.db
7948            .topo_view()
7949            .neighbors(sym, Direction::Out, src)
7950            .binary_search(&dst)
7951            .is_ok()
7952    }
7953
7954    fn has_rule(&self, name: &str) -> bool {
7955        if self.overlay.extra_rules.contains(name) {
7956            return true;
7957        }
7958        if self.overlay.deleted_rules.contains(name) {
7959            return false;
7960        }
7961        self.db.engine.rules().any(|r| r.name == name)
7962    }
7963
7964    fn is_rule_owned(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
7965        if self.overlay.extra_keys.contains(src_key) || self.overlay.extra_keys.contains(dst_key) {
7966            return false;
7967        }
7968        if self.overlay.deleted_keys.contains(src_key)
7969            || self.overlay.deleted_keys.contains(dst_key)
7970        {
7971            return false;
7972        }
7973        let Some(src) = self.db.ids.get(src_key) else {
7974            return false;
7975        };
7976        let Some(dst) = self.db.ids.get(dst_key) else {
7977            return false;
7978        };
7979        let Some(et) = self.db.syms.get(edge_type) else {
7980            return false;
7981        };
7982        // extra_rules is deliberately not consulted: a CreateRule earlier in
7983        // this batch has not fired, so it contributes no provenance. That is
7984        // the documented rule-window gap (see GraphDb::batch).
7985        if self.overlay.deleted_rules.is_empty() {
7986            return self.db.engine.is_owned(et, src, dst);
7987        }
7988        for (rule, triples) in self.db.engine.provenance() {
7989            if self.overlay.deleted_rules.contains(rule) {
7990                continue;
7991            }
7992            if triples.contains(&(et, src, dst)) {
7993                return true;
7994            }
7995        }
7996        false
7997    }
7998
7999    fn check_insert_node(&self, key: &str) -> Result<()> {
8000        if self.has_key(key) {
8001            Err(GraphError::DuplicateKey { key: key.into() })
8002        } else {
8003            Ok(())
8004        }
8005    }
8006
8007    fn check_live_key(&self, key: &str) -> Result<()> {
8008        if self.has_key(key) {
8009            Ok(())
8010        } else {
8011            Err(GraphError::KeyNotFound { key: key.into() })
8012        }
8013    }
8014
8015    fn prepare_insert_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
8016        for k in [src_key, dst_key] {
8017            if !self.has_key(k) {
8018                return Err(GraphError::KeyNotFound { key: k.into() });
8019            }
8020        }
8021        if self.is_rule_owned(edge_type, src_key, dst_key) {
8022            return Err(GraphError::RuleOwned {
8023                detail: format!("edge {edge_type} {src_key}→{dst_key} is rule-owned"),
8024            });
8025        }
8026        Ok(!self.has_edge(edge_type, src_key, dst_key))
8027    }
8028
8029    fn prepare_remove_prop(&self, key: &str, field: &str) -> Result<bool> {
8030        self.check_live_key(key)?;
8031        Ok(self.has_prop(key, field))
8032    }
8033
8034    fn prepare_delete_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
8035        for k in [src_key, dst_key] {
8036            if !self.has_key(k) {
8037                return Err(GraphError::KeyNotFound { key: k.into() });
8038            }
8039        }
8040        // Provenance-owned OR a live rule would derive this pair. User-first
8041        // edges that a later rule matches are not in `owned`, but deleting
8042        // them would leave a hole `rebuild_rule` immediately fills.
8043        if self.is_rule_owned(edge_type, src_key, dst_key) {
8044            return Err(GraphError::RuleOwned {
8045                detail: format!(
8046                    "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
8047                     delete or change the owning rule"
8048                ),
8049            });
8050        }
8051        if self.would_derive(edge_type, src_key, dst_key) {
8052            return Err(GraphError::RuleOwned {
8053                detail: format!(
8054                    "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
8055                     delete or change the owning rule, or a live rule would re-derive it"
8056                ),
8057            });
8058        }
8059        Ok(self.has_edge(edge_type, src_key, dst_key))
8060    }
8061
8062    /// True if any live rule (minus overlay-deleted names) would derive
8063    /// `(edge_type, src, dst)` from current overlay-visible props/labels.
8064    /// CreateRule names in `extra_rules` are ignored — same documented
8065    /// same-batch rule-window as [`Self::is_rule_owned`].
8066    fn would_derive(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
8067        if src_key == dst_key {
8068            return false;
8069        }
8070        let Some(src_label) = self.label_of(src_key) else {
8071            return false;
8072        };
8073        let Some(dst_label) = self.label_of(dst_key) else {
8074            return false;
8075        };
8076        for rule in self.db.engine.rules() {
8077            if self.overlay.deleted_rules.contains(&rule.name) {
8078                continue;
8079            }
8080            if rule.edge_type != edge_type {
8081                continue;
8082            }
8083            if rule.src_label != src_label || rule.dst_label != dst_label {
8084                continue;
8085            }
8086            let src_props = |f: &str| self.prop_value(src_key, f);
8087            let dst_props = |f: &str| self.prop_value(dst_key, f);
8088            let src_view = NodeView {
8089                key: src_key,
8090                props: &src_props,
8091            };
8092            let dst_view = NodeView {
8093                key: dst_key,
8094                props: &dst_props,
8095            };
8096            if evaluate(&rule.predicate, &src_view, &dst_view).is_some() {
8097                return true;
8098            }
8099        }
8100        false
8101    }
8102
8103    fn label_of(&self, key: &str) -> Option<String> {
8104        if self.overlay.deleted_keys.contains(key) {
8105            return None;
8106        }
8107        // Fresh identities created in this batch have no stored label in the
8108        // overlay; they cannot be provenance-owned yet either.
8109        let id = self.db.ids.get(key)?;
8110        let sym = self.db.labels.get(id as usize).copied()?;
8111        if sym == u32::MAX {
8112            return None;
8113        }
8114        self.db.syms.resolve(sym).map(str::to_string)
8115    }
8116
8117    fn prop_value(&self, key: &str, field: &str) -> Option<Value> {
8118        if !self.has_key(key) {
8119            return None;
8120        }
8121        let k = (key.to_string(), field.to_string());
8122        if self.overlay.removed_props.contains(&k) {
8123            return None;
8124        }
8125        if let Some(v) = self.overlay.extra_props.get(&k) {
8126            return Some(v.clone());
8127        }
8128        if self.overlay.extra_keys.contains(key) {
8129            return None;
8130        }
8131        self.db.get_prop(key, field)
8132    }
8133
8134    fn check_create_rule(&self, def: &RuleDef) -> Result<()> {
8135        def.validate()
8136            .map_err(|e| GraphError::RuleInvalid { detail: e })?;
8137        if self.has_rule(&def.name) {
8138            return Err(GraphError::RuleInvalid {
8139                detail: format!("rule {:?} already exists", def.name),
8140            });
8141        }
8142        Ok(())
8143    }
8144
8145    fn check_delete_rule(&self, name: &str) -> Result<()> {
8146        if self.has_rule(name) {
8147            Ok(())
8148        } else {
8149            Err(GraphError::RuleNotFound { name: name.into() })
8150        }
8151    }
8152
8153    fn note_insert_node(&mut self, key: &str, props: &[(String, Value)]) {
8154        self.overlay.deleted_keys.remove(key);
8155        self.overlay.extra_keys.insert(key.to_string());
8156        self.overlay.extra_props.retain(|(k, _), _| k != key);
8157        self.overlay.removed_props.retain(|(k, _)| k != key);
8158        for (field, value) in props {
8159            self.overlay
8160                .extra_props
8161                .insert((key.to_string(), field.clone()), value.clone());
8162        }
8163    }
8164
8165    fn note_insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) {
8166        let k = (
8167            edge_type.to_string(),
8168            src_key.to_string(),
8169            dst_key.to_string(),
8170        );
8171        self.overlay.deleted_edges.remove(&k);
8172        self.overlay.extra_edges.insert(k);
8173    }
8174
8175    fn note_set_prop(&mut self, key: &str, field: &str, value: &Value) {
8176        let k = (key.to_string(), field.to_string());
8177        self.overlay.removed_props.remove(&k);
8178        self.overlay.extra_props.insert(k, value.clone());
8179    }
8180
8181    fn note_remove_prop(&mut self, key: &str, field: &str) {
8182        let k = (key.to_string(), field.to_string());
8183        self.overlay.extra_props.remove(&k);
8184        self.overlay.removed_props.insert(k);
8185    }
8186
8187    fn note_delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) {
8188        let k = (
8189            edge_type.to_string(),
8190            src_key.to_string(),
8191            dst_key.to_string(),
8192        );
8193        self.overlay.extra_edges.remove(&k);
8194        self.overlay.deleted_edges.insert(k);
8195    }
8196
8197    fn note_delete_node(&mut self, key: &str) {
8198        self.overlay.extra_keys.remove(key);
8199        self.overlay.deleted_keys.insert(key.to_string());
8200        self.overlay.extra_props.retain(|(k, _), _| k != key);
8201        self.overlay.removed_props.retain(|(k, _)| k != key);
8202        self.overlay
8203            .extra_edges
8204            .retain(|(_, s, d)| s != key && d != key);
8205        self.overlay
8206            .deleted_edges
8207            .retain(|(_, s, d)| s != key && d != key);
8208    }
8209
8210    fn note_create_rule(&mut self, name: &str) {
8211        self.overlay.deleted_rules.remove(name);
8212        self.overlay.extra_rules.insert(name.to_string());
8213    }
8214
8215    fn check_rename_node(&self, old: &str, new: &str) -> Result<()> {
8216        if !self.has_key(old) {
8217            return Err(GraphError::KeyNotFound { key: old.into() });
8218        }
8219        if self.has_key(new) {
8220            return Err(GraphError::DuplicateKey { key: new.into() });
8221        }
8222        Ok(())
8223    }
8224
8225    fn note_rename_node(&mut self, old: &str, new: &str) {
8226        // Mark old as deleted so subsequent batch ops cannot reference it.
8227        self.overlay.extra_keys.remove(old);
8228        self.overlay.deleted_keys.insert(old.to_string());
8229        // Mark new as extra so subsequent batch ops can reference it.
8230        self.overlay.deleted_keys.remove(new);
8231        self.overlay.extra_keys.insert(new.to_string());
8232        // Migrate any overlay props from old key to new key.
8233        let new_str = new.to_string();
8234        let transferred: Vec<((String, String), Value)> = self
8235            .overlay
8236            .extra_props
8237            .iter()
8238            .filter(|((k, _), _)| k.as_str() == old)
8239            .map(|((_, f), v)| ((new_str.clone(), f.clone()), v.clone()))
8240            .collect();
8241        self.overlay
8242            .extra_props
8243            .retain(|(k, _), _| k.as_str() != old);
8244        for (k, v) in transferred {
8245            self.overlay.extra_props.insert(k, v);
8246        }
8247        // Migrate removed_props.
8248        let transferred_removed: Vec<(String, String)> = self
8249            .overlay
8250            .removed_props
8251            .iter()
8252            .filter(|(k, _)| k.as_str() == old)
8253            .map(|(_, f)| (new_str.clone(), f.clone()))
8254            .collect();
8255        self.overlay
8256            .removed_props
8257            .retain(|(k, _)| k.as_str() != old);
8258        for k in transferred_removed {
8259            self.overlay.removed_props.insert(k);
8260        }
8261    }
8262
8263    fn note_delete_rule(&mut self, name: &str) {
8264        self.overlay.extra_rules.remove(name);
8265        self.overlay.deleted_rules.insert(name.to_string());
8266        // Treat the deleted rule's current provenance as gone so a later
8267        // delete_edge of those triples is a no-op (matches sequential).
8268        if let Some(triples) = self.db.engine.provenance().get(name) {
8269            for &(et, s, d) in triples {
8270                let Some(etype) = self.db.syms.resolve(et) else {
8271                    continue;
8272                };
8273                let Some(src) = self.db.ids.key_of(s) else {
8274                    continue;
8275                };
8276                let Some(dst) = self.db.ids.key_of(d) else {
8277                    continue;
8278                };
8279                let k = (etype.to_string(), src.to_string(), dst.to_string());
8280                self.overlay.extra_edges.remove(&k);
8281                self.overlay.deleted_edges.insert(k);
8282            }
8283        }
8284    }
8285}
8286
8287/// Collects mutations and commits them as one WAL `Batch` frame.
8288///
8289/// Holds `&mut GraphDb` for its lifetime. Queue with the same method names
8290/// as [`GraphDb`]; call [`commit`](Self::commit) to validate, log, and apply.
8291/// See [`GraphDb::batch`] for validation and atomicity rules.
8292pub struct BatchBuilder<'a, F: Fs> {
8293    db: &'a mut GraphDb<F>,
8294    ops: Vec<BatchOp>,
8295}
8296
8297impl<'a, F: Fs> BatchBuilder<'a, F> {
8298    pub fn insert_node(
8299        &mut self,
8300        label: &str,
8301        key: &str,
8302        props: Vec<(String, Value)>,
8303    ) -> &mut Self {
8304        self.ops.push(BatchOp::InsertNode {
8305            label: label.into(),
8306            key: key.into(),
8307            props,
8308        });
8309        self
8310    }
8311
8312    pub fn insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> &mut Self {
8313        self.ops.push(BatchOp::InsertEdge {
8314            edge_type: edge_type.into(),
8315            src_key: src_key.into(),
8316            dst_key: dst_key.into(),
8317        });
8318        self
8319    }
8320
8321    pub fn set_prop(&mut self, key: &str, field: &str, value: Value) -> &mut Self {
8322        self.ops.push(BatchOp::SetProp {
8323            key: key.into(),
8324            field: field.into(),
8325            value,
8326        });
8327        self
8328    }
8329
8330    pub fn remove_prop(&mut self, key: &str, field: &str) -> &mut Self {
8331        self.ops.push(BatchOp::RemoveProp {
8332            key: key.into(),
8333            field: field.into(),
8334        });
8335        self
8336    }
8337
8338    pub fn delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> &mut Self {
8339        self.ops.push(BatchOp::DeleteEdge {
8340            edge_type: edge_type.into(),
8341            src_key: src_key.into(),
8342            dst_key: dst_key.into(),
8343        });
8344        self
8345    }
8346
8347    pub fn delete_node(&mut self, key: &str) -> &mut Self {
8348        self.ops.push(BatchOp::DeleteNode { key: key.into() });
8349        self
8350    }
8351
8352    pub fn create_rule(&mut self, def: RuleDef) -> &mut Self {
8353        self.ops.push(BatchOp::CreateRule(def));
8354        self
8355    }
8356
8357    pub fn delete_rule(&mut self, name: &str) -> &mut Self {
8358        self.ops.push(BatchOp::DeleteRule { name: name.into() });
8359        self
8360    }
8361
8362    /// Queue a node-rename in this batch.
8363    ///
8364    /// Validation (old exists, new not taken) runs at commit time.
8365    pub fn rename_node(&mut self, old_key: &str, new_key: &str) -> &mut Self {
8366        self.ops.push(BatchOp::RenameNode {
8367            old_key: old_key.into(),
8368            new_key: new_key.into(),
8369        });
8370        self
8371    }
8372
8373    /// Queue an edge insert with endpoint auto-creation.
8374    ///
8375    /// Any missing endpoint is created as a plain node `{key, label:
8376    /// placeholder_label, no props}` inside this batch frame. Rules fire and
8377    /// last-change is updated for each auto-created node.
8378    pub fn insert_edge_upsert(
8379        &mut self,
8380        edge_type: &str,
8381        src_key: &str,
8382        dst_key: &str,
8383        placeholder_label: &str,
8384    ) -> &mut Self {
8385        self.ops.push(BatchOp::InsertEdgeUpsert {
8386            edge_type: edge_type.into(),
8387            src_key: src_key.into(),
8388            dst_key: dst_key.into(),
8389            placeholder_label: placeholder_label.into(),
8390        });
8391        self
8392    }
8393
8394    /// Validate every queued op, then log one `Batch` frame and apply.
8395    /// Empty / all-noop batches return `Ok(())` without writing the WAL.
8396    /// A second `commit()` after a successful one is an empty-batch no-op
8397    /// (queued ops were taken).
8398    /// Takes `&mut self` so it chains after the queue methods (`b.insert_node(..).commit()`)
8399    /// and also works as `let mut b = db.batch(); b.insert_node(..); b.commit()`.
8400    ///
8401    /// **Rule-window limitation:** batch validation cannot see edges that a
8402    /// rule created earlier in the *same* batch will derive at apply time, so
8403    /// a `delete_edge` / `insert_edge` in that window is silently no-oped
8404    /// where sequential calls would return `Err(RuleOwned)`. State integrity
8405    /// is unaffected (idempotent apply, provenance intact). Create rules in
8406    /// their own batch, or sequentially, when later ops may touch derived
8407    /// edges.
8408    /// Validate every queued op and commit atomically.
8409    ///
8410    /// Returns `(nodes_inserted, edges_inserted)` — the counts of node and edge
8411    /// WAL records actually written (duplicate edges are silent no-ops and are
8412    /// NOT counted). Both are 0 when the batch is empty or all-noop.
8413    pub fn commit(&mut self) -> Result<(usize, usize)> {
8414        let ops = std::mem::take(&mut self.ops);
8415        self.db.commit_batch(ops)
8416    }
8417
8418    /// Same as [`commit`](Self::commit) but tail the inner events with
8419    /// [`MutationEvent::Ingested`] instead of [`MutationEvent::BatchApplied`].
8420    pub(crate) fn commit_ingest(&mut self, label: &str, inserted: usize) -> Result<(usize, usize)> {
8421        let ops = std::mem::take(&mut self.ops);
8422        self.db
8423            .commit_logged_batch(ops, Some((label.to_string(), inserted)))
8424    }
8425}
8426
8427pub struct NodeRef<'a, F: Fs> {
8428    db: &'a GraphDb<F>,
8429    id: u32,
8430}
8431
8432impl<'a, F: Fs> NodeRef<'a, F> {
8433    pub fn key(&self) -> &str {
8434        self.db.ids.key_of(self.id).expect("dense ids")
8435    }
8436
8437    pub fn label(&self) -> &str {
8438        let sym = self
8439            .db
8440            .labels
8441            .get(self.id as usize)
8442            .copied()
8443            .filter(|&s| s != u32::MAX)
8444            .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
8445        self.db.syms.resolve(sym).expect("interned label symbol")
8446    }
8447
8448    pub fn prop(&self, field: &str) -> Option<Value> {
8449        self.db
8450            .props_view()
8451            .get(self.id, field)
8452            .map(|vr| vr.into_value())
8453    }
8454
8455    /// All stored fields for this node, sorted by field name.
8456    ///
8457    /// Reads from the full base+overlay view so that props stored only in the
8458    /// V8 snapshot base (i.e. before any post-snapshot WAL writes) are visible.
8459    pub fn props(&self) -> BTreeMap<String, Value> {
8460        let mut out = BTreeMap::new();
8461        let pv = self.db.props_view();
8462        for field in pv.field_names() {
8463            if let Some(vr) = pv.get(self.id, &field) {
8464                out.insert(field, vr.into_value());
8465            }
8466        }
8467        out
8468    }
8469
8470    /// depth-N BFS as a ResultSet: columns ["key","label","depth"], BFS order.
8471    pub fn neighborhood(&self, depth: u32, edge_types: Option<&[&str]>, dir: Dir) -> ResultSet {
8472        let view = self.db.view();
8473        let resolved: Option<Vec<u32>> = edge_types.map(|names| {
8474            names
8475                .iter()
8476                .filter_map(|name| view.syms.get(name))
8477                .collect()
8478        });
8479        let nb = neighborhood(&view, self.id, depth, resolved.as_deref(), dir);
8480        let mut rs = ResultSet::new(vec!["key".into(), "label".into(), "depth".into()]);
8481        for (nid, d) in nb.nodes {
8482            let key = view.key_of(nid);
8483            let label = view
8484                .label_of(nid)
8485                .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
8486            rs.push_row(vec![
8487                Some(Value::Str(key.to_string())),
8488                Some(Value::Str(label.to_string())),
8489                Some(Value::Int(d as i64)),
8490            ]);
8491        }
8492        rs
8493    }
8494
8495    /// 1-hop, Both directions: edge-type name → sorted unique neighbor keys.
8496    pub fn grouped_by_edge_type(&self) -> BTreeMap<String, Vec<String>> {
8497        let view = self.db.view();
8498        let mut groups: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
8499        for e in expand(&view, self.id, None, Dir::Both) {
8500            // Skip edges with unknown etypes (only possible from corrupt large
8501            // TOPOLOGY section; function returns BTreeMap not Result).
8502            let Some(etype) = view.syms.resolve(e.etype) else {
8503                continue;
8504            };
8505            let etype = etype.to_string();
8506            let nbr = if e.src == self.id { e.dst } else { e.src };
8507            groups
8508                .entry(etype)
8509                .or_default()
8510                .insert(view.key_of(nbr).to_string());
8511        }
8512        groups
8513            .into_iter()
8514            .map(|(k, v)| (k, v.into_iter().collect()))
8515            .collect()
8516    }
8517}
8518
8519#[cfg(test)]
8520mod tests {
8521    use super::*;
8522    use core_rules::Predicate;
8523
8524    fn tmp_dir(name: &str) -> std::path::PathBuf {
8525        let d =
8526            std::env::temp_dir().join(format!("graphdb-db-unit-{}-{}", name, std::process::id()));
8527        let _ = std::fs::remove_dir_all(&d);
8528        d
8529    }
8530
8531    fn fk_rule() -> RuleDef {
8532        RuleDef {
8533            name: "works_at".into(),
8534            src_label: "Person".into(),
8535            dst_label: "Org".into(),
8536            predicate: Predicate::KeyMatch {
8537                field: "org_id".into(),
8538            },
8539            edge_type: "WORKS_AT".into(),
8540            weight_prop: None,
8541            max_edges: None,
8542            approximate: false,
8543            via_label: None,
8544            via_edge: None,
8545            via_dir: None,
8546        }
8547    }
8548
8549    /// Regression guard for the no-views delta-copy fast path.
8550    ///
8551    /// When no views are defined, `pending_deltas_since().to_vec()` must never
8552    /// be called — even during a large CreateRule backfill. The DELTA_COPY_COUNT
8553    /// thread-local is incremented inside every `if !view_store.is_empty()` block;
8554    /// a count of 0 after the entire sequence proves the guard fires correctly.
8555    #[test]
8556    fn no_delta_copy_when_no_views() {
8557        DELTA_COPY_COUNT.with(|c| c.set(0));
8558        let dir = tmp_dir("no-delta-copy");
8559        {
8560            let mut db = GraphDb::open(&dir).unwrap();
8561            // Insert 50 Org + 50 Person nodes with FK links.
8562            for i in 0..50u32 {
8563                db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
8564            }
8565            for i in 0..50u32 {
8566                db.insert_node(
8567                    "Person",
8568                    &format!("p{i}"),
8569                    vec![("org_id".into(), Value::Str(format!("o{i}")))],
8570                )
8571                .unwrap();
8572            }
8573            // CreateRule backfill should NOT invoke to_vec() when no views are defined.
8574            db.create_rule(fk_rule()).unwrap();
8575
8576            // Counter must stay 0 — no views, no copies.
8577            let copies = DELTA_COPY_COUNT.with(|c| c.get());
8578            assert_eq!(
8579                copies, 0,
8580                "pending_deltas_since().to_vec() called despite no views"
8581            );
8582
8583            // Derived edges must still be correct (the guard skips only the
8584            // empty delta propagation loop, not the rule application itself).
8585            let nbrs = db.neighbors("p0", "WORKS_AT", Direction::Out).unwrap();
8586            assert_eq!(
8587                nbrs,
8588                vec!["o0"],
8589                "rule must derive edges even with no views"
8590            );
8591        }
8592        let _ = std::fs::remove_dir_all(&dir);
8593    }
8594
8595    /// Gating regression: subscribe AFTER a backfill must see no stale events.
8596    /// subscribe BEFORE a backfill must see every edge-fire event.
8597    #[test]
8598    fn subscribe_after_backfill_no_stale_events() {
8599        let dir = tmp_dir("sub-after-backfill");
8600        {
8601            let mut db = GraphDb::open(&dir).unwrap();
8602            for i in 0..10u32 {
8603                db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
8604                db.insert_node(
8605                    "Person",
8606                    &format!("p{i}"),
8607                    vec![("org_id".into(), Value::Str(format!("o{i}")))],
8608                )
8609                .unwrap();
8610            }
8611            // Create rule BEFORE subscribing — emit_deltas is false during backfill.
8612            db.create_rule(fk_rule()).unwrap();
8613
8614            // Subscribe AFTER the backfill — queue must be empty (no stale events).
8615            let sub = db.subscribe_all_rules().unwrap();
8616            // No events should have queued for the prior backfill.
8617            assert!(
8618                sub.try_recv().is_none(),
8619                "subscribe after backfill must see no stale events"
8620            );
8621
8622            // Inserting a new node now should fire an event (emit_deltas is now true).
8623            db.insert_node("Org", "o_new", vec![]).unwrap();
8624            db.insert_node(
8625                "Person",
8626                "p_new",
8627                vec![("org_id".into(), Value::Str("o_new".into()))],
8628            )
8629            .unwrap();
8630            let ev = sub.recv_timeout(std::time::Duration::from_millis(200));
8631            assert!(
8632                ev.is_some(),
8633                "edge-fire event must arrive after subscribe (emit_deltas=true)"
8634            );
8635        }
8636        let _ = std::fs::remove_dir_all(&dir);
8637    }
8638
8639    /// Gating regression: subscribe BEFORE a backfill → events flow.
8640    #[test]
8641    fn subscribe_before_backfill_events_flow() {
8642        let dir = tmp_dir("sub-before-backfill");
8643        {
8644            let mut db = GraphDb::open(&dir).unwrap();
8645            // Subscribe FIRST — emit_deltas becomes true.
8646            let sub = db.subscribe_all_rules().unwrap();
8647
8648            for i in 0..5u32 {
8649                db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
8650                db.insert_node(
8651                    "Person",
8652                    &format!("p{i}"),
8653                    vec![("org_id".into(), Value::Str(format!("o{i}")))],
8654                )
8655                .unwrap();
8656            }
8657            // Backfill fires with emit_deltas=true → events queued.
8658            db.create_rule(fk_rule()).unwrap();
8659
8660            // Should receive at least one edge-fired event from the backfill.
8661            let mut received = 0usize;
8662            while sub.try_recv().is_some() {
8663                received += 1;
8664            }
8665            assert!(
8666                received > 0,
8667                "subscribe before backfill must receive edge-fire events (got 0)"
8668            );
8669        }
8670        let _ = std::fs::remove_dir_all(&dir);
8671    }
8672
8673    /// Companion: when a view IS defined, the delta path fires and view values update.
8674    #[test]
8675    fn delta_copy_fires_when_view_exists() {
8676        use core_rules::ViewSource;
8677        DELTA_COPY_COUNT.with(|c| c.set(0));
8678        let dir = tmp_dir("delta-copy-with-view");
8679        {
8680            let mut db = GraphDb::open(&dir).unwrap();
8681            db.insert_node("Org", "o1", vec![]).unwrap();
8682            db.insert_node(
8683                "Person",
8684                "p1",
8685                vec![("org_id".into(), Value::Str("o1".into()))],
8686            )
8687            .unwrap();
8688            // Declare a Degree view so is_empty() returns false.
8689            db.create_view(ViewDef {
8690                name: "degree_out".into(),
8691                label: "Person".into(),
8692                view_prop: "degree_out".into(),
8693                source: ViewSource::Degree {
8694                    edge_type: "WORKS_AT".into(),
8695                    direction: Direction::Out,
8696                },
8697            })
8698            .unwrap();
8699            db.create_rule(fk_rule()).unwrap();
8700
8701            // At least one delta copy should have happened (CreateRule backfill).
8702            let copies = DELTA_COPY_COUNT.with(|c| c.get());
8703            assert!(
8704                copies > 0,
8705                "expected delta copy to fire when a view is defined"
8706            );
8707
8708            // View value should be computed: p1 has one WORKS_AT out-edge.
8709            let info = db.node_info("p1").unwrap();
8710            let degree = info.props.get("degree_out");
8711            assert!(
8712                degree.is_some(),
8713                "view prop should be written to node props"
8714            );
8715        }
8716        let _ = std::fs::remove_dir_all(&dir);
8717    }
8718
8719    /// Regression: `open_at_with` must call `rebuild_all` after WAL replay so
8720    /// derived-edge-driven view values reflect the as-of state rather than just
8721    /// the initial backfill written at `CreateView` time.
8722    ///
8723    /// Base WAL frames (indices 0..=5 before history markers):
8724    ///   0: insert Org "o1"
8725    ///   1: create_view "employee_count" (Degree / WORKS_AT / In) on Org
8726    ///   2: create_rule fk_rule (WORKS_AT, Person→Org via org_id)
8727    ///   3: insert Person "p1" → rule fires WORKS_AT p1→o1 (degree = 1)  ← mid
8728    ///   4: insert Person "p2" → rule fires WORKS_AT p2→o1 (degree = 2)
8729    ///   5: insert Person "p3" → rule fires WORKS_AT p3→o1 (degree = 3)  ← latest
8730    ///
8731    /// Each rule-fire also appends a DerivedEdgeAdded history-marker frame (state
8732    /// no-op), so the total commit count is higher than the base frame count.
8733    /// The "latest" open_at commit is computed dynamically via `wal_commit_count_at`.
8734    ///
8735    /// Without `rebuild_all`, the as-of instance's "emp" view stays at the
8736    /// initial backfill value (0) instead of reflecting the replayed derived edges.
8737    #[test]
8738    fn open_at_derived_edge_view_values_correct() {
8739        use core_rules::ViewSource;
8740        let dir = tmp_dir("open-at-view-rebuild");
8741        {
8742            let mut db = GraphDb::open(&dir).unwrap();
8743            // frame 0
8744            db.insert_node("Org", "o1", vec![]).unwrap();
8745            // frame 1: create view — initial backfill sees 0 derived edges (none fired yet)
8746            db.create_view(ViewDef {
8747                name: "employee_count".into(),
8748                label: "Org".into(),
8749                view_prop: "emp".into(),
8750                source: ViewSource::Degree {
8751                    edge_type: "WORKS_AT".into(),
8752                    direction: Direction::In,
8753                },
8754            })
8755            .unwrap();
8756            // frame 2: create rule — no Persons yet; backfill is a no-op
8757            db.create_rule(fk_rule()).unwrap();
8758            // frame 3: p1 — rule fires WORKS_AT p1→o1; degree = 1
8759            db.insert_node(
8760                "Person",
8761                "p1",
8762                vec![("org_id".into(), Value::Str("o1".into()))],
8763            )
8764            .unwrap();
8765            // frame 4: p2 — degree = 2
8766            db.insert_node(
8767                "Person",
8768                "p2",
8769                vec![("org_id".into(), Value::Str("o1".into()))],
8770            )
8771            .unwrap();
8772            // frame 5: p3 — degree = 3
8773            db.insert_node(
8774                "Person",
8775                "p3",
8776                vec![("org_id".into(), Value::Str("o1".into()))],
8777            )
8778            .unwrap();
8779            // Sanity: normal open sees degree = 3.
8780            assert_eq!(
8781                db.get_view_prop("o1", "emp"),
8782                Some(Value::Int(3)),
8783                "normal db must show degree 3 after 3 derived edges"
8784            );
8785        } // WAL flushed
8786
8787        // Re-open normally to get the authoritative reference value.
8788        let normal_db = GraphDb::open(&dir).unwrap();
8789        let normal_emp = normal_db.get_view_prop("o1", "emp");
8790        assert_eq!(
8791            normal_emp,
8792            Some(Value::Int(3)),
8793            "re-opened normal db must show degree 3"
8794        );
8795
8796        // Latest as-of (last WAL commit): must match the normal open.
8797        // History-marker frames are appended after each rule-fire, so the total
8798        // commit count is computed dynamically rather than hardcoded.
8799        let total = crate::wal_commit_count_at(&dir).unwrap();
8800        let aof_latest = GraphDb::open_at(&dir, total - 1).unwrap();
8801        assert_eq!(
8802            aof_latest.get_view_prop("o1", "emp"),
8803            normal_emp,
8804            "open_at latest: derived-edge view must equal normal open (rebuild_all required)"
8805        );
8806
8807        // Mid-history as-of (commit 3 = p1 insert Batch frame): only p1; degree = 1.
8808        // The DerivedEdgeAdded marker for p1 is at frame 4 (state no-op on replay),
8809        // so replaying 0..=3 correctly re-derives only the p1→o1 edge.
8810        let aof_mid = GraphDb::open_at(&dir, 3).unwrap();
8811        assert_eq!(
8812            aof_mid.get_view_prop("o1", "emp"),
8813            Some(Value::Int(1)),
8814            "open_at mid-history: only p1 exists at frame 3, degree must be 1"
8815        );
8816
8817        let _ = std::fs::remove_dir_all(&dir);
8818    }
8819
8820    /// Pin: subscribe_* on an as-of instance must return Err(ReadOnly) —
8821    /// as-of instances never commit, so distribute_events never runs and any
8822    /// subscription would wait forever.
8823    #[test]
8824    fn subscribe_on_as_of_returns_read_only_error() {
8825        let dir = tmp_dir("sub-as-of-read-only");
8826        {
8827            let mut db = GraphDb::open(&dir).unwrap();
8828            db.insert_node("Org", "o1", vec![]).unwrap();
8829            db.create_rule(fk_rule()).unwrap();
8830        }
8831        let mut aof = GraphDb::open_at(&dir, 0).unwrap();
8832
8833        assert!(
8834            matches!(
8835                aof.subscribe_all_rules(),
8836                Err(core_storage::GraphError::ReadOnly)
8837            ),
8838            "subscribe_all_rules on as-of must return ReadOnly"
8839        );
8840        assert!(
8841            matches!(
8842                aof.subscribe_writes(),
8843                Err(core_storage::GraphError::ReadOnly)
8844            ),
8845            "subscribe_writes on as-of must return ReadOnly"
8846        );
8847        assert!(
8848            matches!(
8849                aof.subscribe_rule("works_at"),
8850                Err(core_storage::GraphError::ReadOnly)
8851            ),
8852            "subscribe_rule on as-of must return ReadOnly"
8853        );
8854        let _ = std::fs::remove_dir_all(&dir);
8855    }
8856
8857    /// Regression: a failed dense WAL rewrite must not leave speculative
8858    /// interns in `syms`. If it does, the next successful mutation logs an
8859    /// `Intern` record with an inflated id; replay (which never saw the
8860    /// orphans) assigns a smaller id and the WAL becomes unreplayable.
8861    #[test]
8862    fn dense_rewrite_error_rolls_back_speculative_interns() {
8863        let dir = tmp_dir("dense-rewrite-rollback");
8864        {
8865            let mut db = GraphDb::open(&dir).unwrap();
8866            db.insert_node("Person", "a", vec![]).unwrap();
8867
8868            // Bypass MutPreview validation to hit the rewrite's own error path
8869            // (same shape as an id-exhaustion failure mid-rewrite). The
8870            // InsertEdge arm interns the edge type before it resolves keys.
8871            let err = db.rewrite_wal_dense(vec![WalRecord::InsertEdge {
8872                edge_type: "ORPHAN_TYPE".into(),
8873                src_key: "missing".into(),
8874                dst_key: "a".into(),
8875            }]);
8876            assert!(err.is_err(), "rewrite of a missing src key must fail");
8877            assert_eq!(
8878                db.syms.get("ORPHAN_TYPE"),
8879                None,
8880                "failed rewrite must roll back speculative interns"
8881            );
8882
8883            // A later successful mutation must produce a replayable WAL.
8884            db.set_prop("a", "later_field", Value::Int(2)).unwrap();
8885        }
8886        let db = GraphDb::open(&dir).expect("WAL must replay after failed rewrite");
8887        assert_eq!(db.get_prop("a", "later_field"), Some(Value::Int(2)));
8888        let _ = std::fs::remove_dir_all(&dir);
8889    }
8890}