Skip to main content

core_api/
db.rs

1use crate::ingest::{IngestOptions, IngestReport};
2use crate::subscription::{
3    event_matches, DbEvent, SubEntry, SubFilter, SubInner, Subscription, DEFAULT_SUB_CAPACITY,
4};
5use core_query::cypher::ast::ArithOp;
6use core_query::cypher::{
7    execute, is_subscribable, is_write_tokens, lex, parse, parse_write, plan, MatchDeleteNodeStmt,
8    NodePat, Operand, Params, Pattern, PlanOp, Query, RetItem, RetVal, WriteStatement,
9};
10use core_query::{eval_filter, expand, neighborhood, Dir, Filter, GraphView, ResultSet};
11use core_rules::{
12    evaluate, EngineEdgeDelta, GraphMut, NodeView, Predicate, RuleDef, RuleEngine, RuleIvfExport,
13    ViewDef, ViewStore,
14};
15use core_storage::fs::{FileId, Fs, FsIntrospect, RealFs};
16use core_storage::fulltext::FulltextIndex;
17use core_storage::wal::{decode_all, encode_record, WalRecord};
18use core_storage::{
19    ColumnStore, Direction, EdgeProps, GraphError, IdMap, Interner, Result, Topology, Value,
20};
21use serde::{Deserialize, Serialize};
22use std::collections::{BTreeMap, BTreeSet};
23
24// Test-only: counts how many times `pending_deltas_since().to_vec()` actually
25// executes (i.e., at least one view is defined). Used to verify the fast-path
26// guard skips the allocation when `view_store.is_empty()`.
27#[cfg(test)]
28thread_local! {
29    static DELTA_COPY_COUNT: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
30}
31
32/// Internal state for a single `subscribe_query` subscription.
33///
34/// On every commit, `distribute_events` re-executes `ops` against the current
35/// graph state, diffs the result against `prev_rows`, and pushes
36/// `DbEvent::QueryRowAdded` / `QueryRowRemoved` events to `inner`.
37///
38/// **Full re-run per commit; use LIMIT to bound execution cost.**
39/// (Differential evaluation is roadmap / Phase 5.)
40pub(crate) struct QuerySubEntry {
41    /// Compiled plan for the subscribed Cypher query.
42    ops: Vec<PlanOp>,
43    /// Column names from the first execution (fixed for the subscription lifetime).
44    columns: Vec<String>,
45    /// Serialized (JSON) row key → row data, representing the result set at
46    /// the end of the last commit. Used to diff against the new result.
47    prev_row_map: std::collections::HashMap<String, Vec<Option<Value>>>,
48    /// Weak pointer to the subscriber queue; dead Weak → subscription dropped.
49    inner: std::sync::Weak<SubInner>,
50}
51
52/// A post-commit mutation notification.
53///
54/// Emitted from `log_then_apply` after the WAL append, fsync, and
55/// in-memory `apply` all succeed. Never emitted for rejected operations
56/// (validation errors, [`GraphError::RuleOwned`], duplicate keys, no-op
57/// deletes/removes). Event payloads carry user keys and rule names, never
58/// internal ids.
59///
60/// **Replay:** [`GraphDb::open`] / [`GraphDb::open_with`] replay the WAL via
61/// `apply` only. Emission lives exclusively in `log_then_apply`, so
62/// recovery is silent even if a sink were installed (it cannot be: the
63/// sink is in-memory and set after open).
64///
65/// **Ordering:** a `Batch` WAL frame emits one event per inner record, then
66/// [`MutationEvent::BatchApplied`]. An ingest commit emits those same inner
67/// events, then [`MutationEvent::Ingested`] (not `BatchApplied`). An empty
68/// or all-noop batch writes no WAL and emits nothing (including no summary).
69///
70/// **Derived edges:** rule-created or retracted edges are not individually
71/// evented — they are recoverable from the triggering mutation plus the live
72/// rule set. Only the triggering record is emitted.
73///
74/// **Wire form:** externally tagged snake_case JSON
75/// (`{"node_inserted":{"label":"A","key":"k"}}`).
76#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
77#[serde(rename_all = "snake_case")]
78pub enum MutationEvent {
79    NodeInserted {
80        label: String,
81        key: String,
82    },
83    PropSet {
84        key: String,
85        field: String,
86    },
87    PropRemoved {
88        key: String,
89        field: String,
90    },
91    EdgeInserted {
92        edge_type: String,
93        src: String,
94        dst: String,
95    },
96    EdgeDeleted {
97        edge_type: String,
98        src: String,
99        dst: String,
100    },
101    NodeDeleted {
102        key: String,
103    },
104    RuleCreated {
105        name: String,
106    },
107    RuleDeleted {
108        name: String,
109    },
110    RuleRebuilt {
111        name: String,
112    },
113    BatchApplied {
114        ops: usize,
115    },
116    Ingested {
117        label: String,
118        inserted: usize,
119    },
120}
121
122fn event_from_record(rec: &WalRecord, intern: &Interner, ids: &IdMap) -> Option<MutationEvent> {
123    match rec {
124        WalRecord::InsertNode { label, key, .. } => Some(MutationEvent::NodeInserted {
125            label: label.clone(),
126            key: key.clone(),
127        }),
128        WalRecord::InsertNodeId { label, key, .. } => Some(MutationEvent::NodeInserted {
129            label: intern.resolve(*label)?.to_string(),
130            key: key.clone(),
131        }),
132        WalRecord::SetProp { key, field, .. } => Some(MutationEvent::PropSet {
133            key: key.clone(),
134            field: field.clone(),
135        }),
136        WalRecord::SetPropId { id, field, .. } => Some(MutationEvent::PropSet {
137            key: ids.key_of(*id)?.to_string(),
138            field: intern.resolve(*field)?.to_string(),
139        }),
140        WalRecord::RemoveProp { key, field } => Some(MutationEvent::PropRemoved {
141            key: key.clone(),
142            field: field.clone(),
143        }),
144        WalRecord::InsertEdge {
145            edge_type,
146            src_key,
147            dst_key,
148        } => Some(MutationEvent::EdgeInserted {
149            edge_type: edge_type.clone(),
150            src: src_key.clone(),
151            dst: dst_key.clone(),
152        }),
153        WalRecord::InsertEdgeId { etype, src, dst } => Some(MutationEvent::EdgeInserted {
154            edge_type: intern.resolve(*etype)?.to_string(),
155            src: ids.key_of(*src)?.to_string(),
156            dst: ids.key_of(*dst)?.to_string(),
157        }),
158        WalRecord::DeleteEdge {
159            edge_type,
160            src_key,
161            dst_key,
162        } => Some(MutationEvent::EdgeDeleted {
163            edge_type: edge_type.clone(),
164            src: src_key.clone(),
165            dst: dst_key.clone(),
166        }),
167        WalRecord::DeleteNode { key } => Some(MutationEvent::NodeDeleted { key: key.clone() }),
168        WalRecord::CreateRule { def_bytes } => {
169            let def: RuleDef = bincode::deserialize(def_bytes).ok()?;
170            Some(MutationEvent::RuleCreated { name: def.name })
171        }
172        WalRecord::DeleteRule { name } => Some(MutationEvent::RuleDeleted { name: name.clone() }),
173        WalRecord::RebuildRule { name } => Some(MutationEvent::RuleRebuilt { name: name.clone() }),
174        WalRecord::Batch(_)
175        | WalRecord::CreateView { .. }
176        | WalRecord::DeleteView { .. }
177        | WalRecord::EnableFulltext { .. }
178        | WalRecord::DisableFulltext { .. }
179        | WalRecord::Intern { .. } => None,
180    }
181}
182
183/// Database-wide counters plus per-rule budget/fire stats.
184#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
185pub struct Stats {
186    pub nodes_live: usize,
187    pub nodes_tombstoned: usize,
188    pub edges: u64,
189    pub rules: Vec<RuleStats>,
190}
191
192/// One rule's provenance size, trip latch, and fire counter.
193///
194/// `tripped` is a one-way latch: once set, the engine adds no new edges for
195/// that rule until [`GraphDb::rebuild_rule`] (and only if the full desired
196/// set then fits). `fires` counts `on_node_changed` evaluations plus
197/// backfill/rebuild participant ticks (rebuild counts even when it is a
198/// provenance no-op).
199#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
200pub struct RuleStats {
201    pub name: String,
202    pub edges: u64,
203    pub tripped: bool,
204    pub fires: u64,
205    /// Whether this rule uses the approximate IVF-Flat candidate path.
206    pub approximate: bool,
207}
208
209/// Wire summary of a [`Predicate`]. JSON only — `Explanation` is never
210/// bincode-persisted (WAL/snapshots store `RuleDef` bytes, not this type).
211#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
212pub struct PredicateSummary {
213    pub kind: String,
214    pub fields: Vec<String>,
215    pub min: Option<f64>,
216    pub tolerance: Option<f64>,
217    pub km: Option<f64>,
218    pub parts: Option<Vec<PredicateSummary>>,
219    /// True when the owning rule has `approximate=true` (IVF-Flat candidate path).
220    /// Always false for predicates reported without rule context (sub-predicates in `parts`).
221    #[serde(default)]
222    pub approximate: bool,
223}
224
225impl From<&Predicate> for PredicateSummary {
226    fn from(p: &Predicate) -> Self {
227        match p {
228            Predicate::KeyMatch { field } => PredicateSummary {
229                kind: "key_match".into(),
230                fields: vec![field.clone()],
231                min: None,
232                tolerance: None,
233                km: None,
234                parts: None,
235                approximate: false,
236            },
237            Predicate::FieldEqual { field } => PredicateSummary {
238                kind: "field_equal".into(),
239                fields: vec![field.clone()],
240                min: None,
241                tolerance: None,
242                km: None,
243                parts: None,
244                approximate: false,
245            },
246            Predicate::Overlap { field, min } => PredicateSummary {
247                kind: "overlap".into(),
248                fields: vec![field.clone()],
249                min: Some(*min),
250                tolerance: None,
251                km: None,
252                parts: None,
253                approximate: false,
254            },
255            Predicate::NumericWithin { field, tolerance } => PredicateSummary {
256                kind: "numeric_within".into(),
257                fields: vec![field.clone()],
258                min: None,
259                tolerance: Some(*tolerance),
260                km: None,
261                parts: None,
262                approximate: false,
263            },
264            Predicate::GeoRadius { field, km } => PredicateSummary {
265                kind: "geo_radius".into(),
266                fields: vec![field.clone()],
267                min: None,
268                tolerance: None,
269                km: Some(*km),
270                parts: None,
271                approximate: false,
272            },
273            Predicate::VectorSimilar { field, min } => PredicateSummary {
274                kind: "vector_similar".into(),
275                fields: vec![field.clone()],
276                min: Some(*min),
277                tolerance: None,
278                km: None,
279                parts: None,
280                approximate: false,
281            },
282            Predicate::All(inner) => {
283                let parts: Vec<PredicateSummary> = inner.iter().map(Self::from).collect();
284                let mut fields = Vec::new();
285                for part in &parts {
286                    for f in &part.fields {
287                        if !fields.contains(f) {
288                            fields.push(f.clone());
289                        }
290                    }
291                }
292                PredicateSummary {
293                    kind: "all".into(),
294                    fields,
295                    min: None,
296                    tolerance: None,
297                    km: None,
298                    parts: Some(parts),
299                    approximate: false,
300                }
301            }
302            Predicate::Any(inner) => {
303                let parts: Vec<PredicateSummary> = inner.iter().map(Self::from).collect();
304                let mut fields = Vec::new();
305                for part in &parts {
306                    for f in &part.fields {
307                        if !fields.contains(f) {
308                            fields.push(f.clone());
309                        }
310                    }
311                }
312                PredicateSummary {
313                    kind: "any".into(),
314                    fields,
315                    min: None,
316                    tolerance: None,
317                    km: None,
318                    parts: Some(parts),
319                    approximate: false,
320                }
321            }
322        }
323    }
324}
325
326/// Snapshot of a live node's key, label, and columnar properties.
327///
328/// `props` is a [`BTreeMap`] so field order is deterministic (sorted by name)
329/// regardless of insert order or the columnar store's `HashMap` iteration.
330///
331/// Deliberately does not derive `Serialize`: `Value`'s serde form is
332/// internally tagged. Wire JSON is built by `value_to_json` in the server.
333#[derive(Debug, Clone, PartialEq)]
334pub struct NodeInfo {
335    pub key: String,
336    pub label: String,
337    pub props: BTreeMap<String, Value>,
338}
339
340/// Counts returned by [`GraphDb::delete_node`].
341#[derive(Debug, Clone, PartialEq, Eq, Default)]
342pub struct DeleteReport {
343    /// Number of manual (user-inserted) edges removed.
344    pub manual_edges: u64,
345    /// Number of derived (rule-owned) edges retracted.
346    pub derived_edges: u64,
347}
348
349/// One directed edge incident on a node, with provenance membership.
350///
351/// `derived` is true iff `(edge_type, src, dst)` is in the rule engine's
352/// Plan-8 `by_node` provenance index.
353#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
354pub struct EdgeInfo {
355    pub edge_type: String,
356    pub src_key: String,
357    pub dst_key: String,
358    pub derived: bool,
359}
360
361/// One rule-owned edge between two nodes, with the rule name, edge type,
362/// direction (src_key → dst_key), and weight if the rule stores one.
363#[derive(Debug, Clone, PartialEq, Serialize)]
364pub struct Explanation {
365    pub rule: String,
366    pub edge_type: String,
367    pub src_key: String,
368    pub dst_key: String,
369    pub weight: Option<f64>,
370    pub predicate: PredicateSummary,
371}
372
373/// Construct the standard write-query result set (columns: created, properties_set, deleted).
374fn write_result_set() -> ResultSet {
375    ResultSet::new(vec![
376        "created".into(),
377        "properties_set".into(),
378        "deleted".into(),
379    ])
380}
381
382fn resolve_merge_set_value(op: &Operand, params: &BTreeMap<String, Value>) -> Result<Value> {
383    match op {
384        Operand::Lit(v) => Ok(v.clone()),
385        Operand::Param(name) => params
386            .get(name)
387            .cloned()
388            .ok_or_else(|| GraphError::QueryError {
389                detail: format!("missing parameter `{name}`"),
390            }),
391        _ => Err(GraphError::QueryError {
392            detail: "ON CREATE/ON MATCH SET value must be a literal or $parameter".into(),
393        }),
394    }
395}
396
397fn operand_node_vars(op: &Operand, out: &mut Vec<String>) {
398    match op {
399        Operand::Prop { var, .. } | Operand::Var(var) => {
400            if !out.contains(var) {
401                out.push(var.clone());
402            }
403        }
404        Operand::FuncCall { args, .. } => {
405            for arg in args {
406                operand_node_vars(arg, out);
407            }
408        }
409        Operand::BinArith { left, right, .. } => {
410            operand_node_vars(left, out);
411            operand_node_vars(right, out);
412        }
413        Operand::Lit(_) | Operand::Param(_) => {}
414    }
415}
416
417fn ret_node_vars(items: &[RetItem]) -> Vec<String> {
418    let mut out = Vec::new();
419    for item in items {
420        match &item.value {
421            RetVal::Var(v) | RetVal::Prop { var: v, .. } => {
422                if !out.contains(v) {
423                    out.push(v.clone());
424                }
425            }
426            RetVal::FuncCall { args, .. } => {
427                for arg in args {
428                    operand_node_vars(arg, &mut out);
429                }
430            }
431            RetVal::ScalarExpr(op) => operand_node_vars(op, &mut out),
432            RetVal::Agg { .. } => {}
433        }
434    }
435    out
436}
437
438fn add_var(out: &mut Vec<String>, v: &str) {
439    if !out.iter().any(|x| x == v) {
440        out.push(v.to_string());
441    }
442}
443
444fn pattern_node_vars(pats: &[Pattern]) -> Vec<String> {
445    let mut out = Vec::new();
446    for p in pats {
447        if let Some(v) = &p.start.var {
448            add_var(&mut out, v);
449        }
450        for (_, dest) in &p.chain {
451            if let Some(v) = &dest.var {
452                add_var(&mut out, v);
453            }
454        }
455    }
456    out
457}
458
459fn pattern_rel_vars(pats: &[Pattern]) -> Vec<String> {
460    let mut out = Vec::new();
461    for p in pats {
462        for (rel, _) in &p.chain {
463            if rel.hops.is_none() {
464                if let Some(v) = &rel.var {
465                    add_var(&mut out, v);
466                }
467            }
468        }
469    }
470    out
471}
472
473fn rel_type_alias(var: &str) -> String {
474    format!("__rt_{var}")
475}
476
477fn ret_column_name(item: &RetItem) -> String {
478    if let Some(alias) = &item.alias {
479        return alias.clone();
480    }
481    match &item.value {
482        RetVal::Var(v) => v.clone(),
483        RetVal::Prop { var, field } => format!("{var}.{field}"),
484        RetVal::FuncCall { name, args } => {
485            let arg_strs: Vec<String> = args
486                .iter()
487                .map(|a| match a {
488                    Operand::Var(v) => v.clone(),
489                    Operand::Prop { var, field } => format!("{var}.{field}"),
490                    Operand::Lit(_) => "<lit>".to_string(),
491                    Operand::Param(p) => format!("${p}"),
492                    Operand::FuncCall { name: n, .. } => format!("{n}(...)"),
493                    Operand::BinArith { .. } => "<arith>".to_string(),
494                })
495                .collect();
496            format!("{name}({})", arg_strs.join(", "))
497        }
498        RetVal::ScalarExpr(_) => "<expr>".to_string(),
499        RetVal::Agg { .. } => "<agg>".to_string(),
500    }
501}
502
503fn eval_set_return_operand<F: Fs>(
504    db: &GraphDb<F>,
505    match_rs: &ResultSet,
506    row: usize,
507    rel_vars: &[String],
508    op: &Operand,
509    params: &BTreeMap<String, Value>,
510) -> Result<Option<Value>> {
511    match op {
512        Operand::Lit(v) => Ok(Some(v.clone())),
513        Operand::Param(name) => params.get(name).cloned().ok_or_else(|| GraphError::QueryError {
514            detail: format!("missing parameter `{name}`"),
515        }).map(Some),
516        Operand::Var(name) if rel_vars.iter().any(|r| r == name) => Err(GraphError::QueryError {
517            detail: format!(
518                "cannot return relationship variable '{name}' bare; return its properties ({name}.field) instead"
519            ),
520        }),
521        Operand::Var(name) => Ok(match_rs.get(row, name).cloned()),
522        Operand::Prop { var, field } => {
523            if rel_vars.iter().any(|r| r == var) {
524                return Ok(None);
525            }
526            let Some(Value::Str(key)) = match_rs.get(row, var) else {
527                return Ok(None);
528            };
529            Ok(db.get_prop(key, field).cloned())
530        }
531        Operand::FuncCall { name, args } => {
532            eval_set_return_func(db, match_rs, row, rel_vars, name, args, params)
533        }
534        Operand::BinArith { op, left, right } => {
535            let lv = eval_set_return_operand(db, match_rs, row, rel_vars, left, params)?;
536            let rv = eval_set_return_operand(db, match_rs, row, rel_vars, right, params)?;
537            eval_set_return_arith(op, lv, rv)
538        }
539    }
540}
541
542fn eval_set_return_arith(
543    op: &ArithOp,
544    lv: Option<Value>,
545    rv: Option<Value>,
546) -> Result<Option<Value>> {
547    match (lv, rv) {
548        (None, _) | (_, None) => Ok(None),
549        (Some(Value::Int(a)), Some(Value::Int(b))) => {
550            let result = match op {
551                ArithOp::Sub => a.saturating_sub(b),
552                ArithOp::Mul => a.saturating_mul(b),
553                ArithOp::Add => a.saturating_add(b),
554                ArithOp::Div => {
555                    if b == 0 {
556                        return Err(GraphError::QueryError {
557                            detail: "division by zero".into(),
558                        });
559                    }
560                    a.checked_div(b).unwrap_or(i64::MAX)
561                }
562            };
563            Ok(Some(Value::Int(result)))
564        }
565        (Some(lv), Some(rv)) => {
566            let a = match &lv {
567                Value::Float(f) => *f,
568                Value::Int(i) => *i as f64,
569                _ => {
570                    return Err(GraphError::QueryError {
571                        detail: format!("arithmetic operand must be numeric, got {lv:?}"),
572                    })
573                }
574            };
575            let b = match &rv {
576                Value::Float(f) => *f,
577                Value::Int(i) => *i as f64,
578                _ => {
579                    return Err(GraphError::QueryError {
580                        detail: format!("arithmetic operand must be numeric, got {rv:?}"),
581                    })
582                }
583            };
584            let result = match op {
585                ArithOp::Sub => a - b,
586                ArithOp::Mul => a * b,
587                ArithOp::Add => a + b,
588                ArithOp::Div => {
589                    if b == 0.0 {
590                        return Err(GraphError::QueryError {
591                            detail: "division by zero".into(),
592                        });
593                    }
594                    a / b
595                }
596            };
597            Ok(Some(Value::Float(result)))
598        }
599    }
600}
601
602fn eval_set_return_func<F: Fs>(
603    db: &GraphDb<F>,
604    match_rs: &ResultSet,
605    row: usize,
606    rel_vars: &[String],
607    name: &str,
608    args: &[Operand],
609    params: &BTreeMap<String, Value>,
610) -> Result<Option<Value>> {
611    let norm = name.to_ascii_lowercase();
612    if norm == "type" {
613        if args.len() != 1 {
614            return Err(GraphError::QueryError {
615                detail: format!("type() requires exactly 1 argument, got {}", args.len()),
616            });
617        }
618        let Operand::Var(rel) = &args[0] else {
619            return Err(GraphError::QueryError {
620                detail: "type() argument must be a relationship variable (e.g. type(r))".into(),
621            });
622        };
623        return Ok(match_rs.get(row, &rel_type_alias(rel)).cloned());
624    }
625    let mut vals = Vec::with_capacity(args.len());
626    for arg in args {
627        vals.push(eval_set_return_operand(
628            db, match_rs, row, rel_vars, arg, params,
629        )?);
630    }
631    match norm.as_str() {
632        "tolower" => {
633            if vals.len() != 1 {
634                return Err(GraphError::QueryError {
635                    detail: format!("toLower() requires exactly 1 argument, got {}", vals.len()),
636                });
637            }
638            Ok(vals[0].clone().map(|val| match val {
639                Value::Str(s) => Value::Str(s.to_ascii_lowercase()),
640                other => other,
641            }))
642        }
643        "toupper" => {
644            if vals.len() != 1 {
645                return Err(GraphError::QueryError {
646                    detail: format!("toUpper() requires exactly 1 argument, got {}", vals.len()),
647                });
648            }
649            Ok(vals[0].clone().map(|val| match val {
650                Value::Str(s) => Value::Str(s.to_ascii_uppercase()),
651                other => other,
652            }))
653        }
654        "size" => match vals.first().cloned().flatten() {
655            None => Ok(None),
656            Some(Value::Str(s)) => Ok(Some(Value::Int(s.len() as i64))),
657            Some(Value::List(items)) => Ok(Some(Value::Int(items.len() as i64))),
658            Some(_) => Ok(None),
659        },
660        "coalesce" => Ok(vals.into_iter().flatten().next()),
661        "abs" => match vals.first().cloned().flatten() {
662            None => Ok(None),
663            Some(Value::Int(n)) => Ok(Some(Value::Int(n.saturating_abs()))),
664            Some(Value::Float(f)) => Ok(Some(Value::Float(f.abs()))),
665            Some(_) => Ok(None),
666        },
667        "round" => match vals.first().cloned().flatten() {
668            None => Ok(None),
669            Some(Value::Float(f)) => Ok(Some(Value::Float(f.round()))),
670            Some(Value::Int(n)) => Ok(Some(Value::Int(n))),
671            Some(_) => Ok(None),
672        },
673        _ => Err(GraphError::QueryError {
674            detail: format!(
675                "unknown function `{name}`; supported: toLower, toUpper, size, coalesce, type, abs, round, textMatches"
676            ),
677        }),
678    }
679}
680
681fn eval_set_return_item<F: Fs>(
682    db: &GraphDb<F>,
683    match_rs: &ResultSet,
684    row: usize,
685    rel_vars: &[String],
686    item: &RetItem,
687    params: &BTreeMap<String, Value>,
688) -> Result<Option<Value>> {
689    match &item.value {
690        RetVal::Var(v) => eval_set_return_operand(
691            db,
692            match_rs,
693            row,
694            rel_vars,
695            &Operand::Var(v.clone()),
696            params,
697        ),
698        RetVal::Prop { var, field } => eval_set_return_operand(
699            db,
700            match_rs,
701            row,
702            rel_vars,
703            &Operand::Prop {
704                var: var.clone(),
705                field: field.clone(),
706            },
707            params,
708        ),
709        RetVal::FuncCall { name, args } => {
710            eval_set_return_func(db, match_rs, row, rel_vars, name, args, params)
711        }
712        RetVal::ScalarExpr(op) => eval_set_return_operand(db, match_rs, row, rel_vars, op, params),
713        RetVal::Agg { .. } => Err(GraphError::QueryError {
714            detail: "aggregates are not supported in MATCH … SET … RETURN".into(),
715        }),
716    }
717}
718
719/// Project user RETURN from original MATCH rows after SET. No rematch.
720fn project_set_return_rows<F: Fs>(
721    db: &GraphDb<F>,
722    rel_vars: &[String],
723    match_rs: &ResultSet,
724    returns: &[RetItem],
725    params: &BTreeMap<String, Value>,
726) -> Result<ResultSet> {
727    let columns: Vec<String> = returns.iter().map(ret_column_name).collect();
728    let mut out = ResultSet::new(columns);
729    for row in 0..match_rs.len() {
730        let mut cells = Vec::with_capacity(returns.len());
731        for item in returns {
732            cells.push(eval_set_return_item(
733                db, match_rs, row, rel_vars, item, params,
734            )?);
735        }
736        out.push_row(cells);
737    }
738    Ok(out)
739}
740
741/// Single construction point for a `GraphMut` view over the split-borrowed graph fields.
742/// Callers use `std::mem::take` on the engine before calling this, then restore it after.
743/// Extract a `Vec<f64>` from a `Value::List` whose items are all numeric.
744/// Returns `None` for non-list values or lists with non-numeric elements.
745fn value_as_float_list(v: &Value) -> Option<Vec<f64>> {
746    match v {
747        Value::List(items) => items
748            .iter()
749            .map(|item| match item {
750                Value::Float(f) => Some(*f),
751                Value::Int(i) => Some(*i as f64),
752                _ => None,
753            })
754            .collect(),
755        _ => None,
756    }
757}
758
759fn make_graph_mut<'a>(
760    ids: &'a IdMap,
761    syms: &'a mut Interner,
762    labels: &'a [u32],
763    props: &'a ColumnStore,
764    topo: &'a mut Topology,
765    edge_props: &'a mut EdgeProps,
766) -> GraphMut<'a> {
767    GraphMut {
768        ids,
769        syms,
770        labels,
771        props,
772        topo,
773        edge_props,
774    }
775}
776
777/// When [`GraphDb`] calls `Fs::sync` after a WAL append.
778///
779/// Default is [`Strict`](FsyncPolicy::Strict): every `log_then_apply_with`
780/// fsyncs (single `insert_node` / `set_prop`). Ingest and `write_batch`
781/// emit one `WalRecord::Batch` and fsync once at that frame (Batched).
782/// [`Relaxed`](FsyncPolicy::Relaxed) skips WAL sync; [`GraphDb::snapshot`]
783/// is still durable via `write_atomic`. Crash-recovery DST stays Strict.
784#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
785pub enum FsyncPolicy {
786    /// Every WAL commit calls `fs.sync` (today's behavior).
787    #[default]
788    Strict,
789    /// Sync only at a `Batch` frame end. Single-op path stays Strict unless
790    /// this policy is set on the database.
791    Batched,
792    /// Never call `fs.sync`. [`GraphDb::snapshot`] still syncs via `write_atomic`.
793    Relaxed,
794}
795
796pub struct GraphDb<F: Fs> {
797    fs: F,
798    ids: IdMap,
799    syms: Interner,
800    topo: Topology,
801    props: ColumnStore,
802    labels: Vec<u32>, // node id -> label symbol
803    edge_props: EdgeProps,
804    engine: RuleEngine,
805    view_store: ViewStore,
806    /// Incremental inverted index for full-text-lite search.
807    /// Rebuild-on-open: populated from WAL replay + rebuild_all at open end.
808    fulltext: FulltextIndex,
809    event_sink: Option<Box<dyn Fn(MutationEvent) + Send + Sync>>,
810    /// WAL fsync cadence. Default [`FsyncPolicy::Strict`].
811    fsync: FsyncPolicy,
812    /// Monotonically increasing per-commit counter.  A single `log_then_apply_with`
813    /// call increments this once; all events emitted from that call share the same
814    /// `commit_seq` value.
815    commit_seq: u64,
816    /// Live subscriptions.  Entries with a dead `Weak` are pruned on the next
817    /// distribute_events call.
818    subscriptions: Vec<SubEntry>,
819    /// Live query subscriptions. Re-executed on every commit when non-empty.
820    /// Dead `Weak` entries are pruned inside `distribute_events`.
821    query_subscriptions: Vec<QuerySubEntry>,
822    /// Queue capacity for new subscriptions created by this db.  Default is
823    /// [`DEFAULT_SUB_CAPACITY`]; can be overridden via [`set_sub_capacity`]
824    /// to test Lagged behaviour with small queues.
825    sub_capacity: usize,
826    /// True for as-of instances opened via [`GraphDb::open_at`].
827    /// Every mutation method and `snapshot()` returns [`GraphError::ReadOnly`]
828    /// when this flag is set.
829    read_only: bool,
830    /// Total WAL commit count at the time [`open_at`] was called.
831    /// 0 for normal (non-as-of) instances.
832    total_wal_commits: u64,
833}
834
835/// Options for [`GraphDb::snapshot_with`].
836#[derive(Debug, Clone, Default)]
837pub struct SnapshotOptions {
838    /// When `true`, the WAL is preserved after the snapshot write.
839    /// Pre-snapshot commits remain reachable via [`GraphDb::open_at`].
840    /// When `false` (the default), the WAL is truncated to a minimal
841    /// baseline so cold-start replay stays fast.
842    pub keep_wal: bool,
843}
844
845impl GraphDb<RealFs> {
846    pub fn open(dir: &std::path::Path) -> Result<Self> {
847        Self::open_with(RealFs::new(dir)?)
848    }
849
850    /// Open a read-only view of the database as it existed after `commit`.
851    ///
852    /// Commit indices are 0-based over the current WAL: commit 0 is the state
853    /// after the first WAL frame, commit N-1 is the state after the N-th (most
854    /// recent) frame.  Call [`GraphDb::open`] to read the full current state.
855    ///
856    /// **Replay base.** [`GraphDb::snapshot`] truncates the WAL when it runs,
857    /// so as-of can only reach commits recorded in the current WAL (those
858    /// written after the most recent snapshot, or all commits if no snapshot
859    /// was ever taken).  Commit 0 in `open_at` always refers to the first
860    /// frame in the WAL that exists on disk, not the first ever write to the
861    /// database.  When the on-disk snapshot recorded that it truncated the
862    /// WAL (V7, default `keep_wal: false`), it is loaded as the base state
863    /// before frame replay, so the as-of view includes all pre-snapshot data.
864    /// Snapshots written with `keep_wal: true` (and legacy V5/V6 snapshots)
865    /// are ignored and replay is WAL-only, as before.
866    ///
867    /// **Read-only.** Every mutation method and `snapshot()` on the returned
868    /// instance returns [`GraphError::ReadOnly`].  Queries, `explain()`, and
869    /// `stats()` work normally.
870    ///
871    /// # Errors
872    /// - [`GraphError::CommitOutOfRange`] if `commit >= wal_commit_count` (including
873    ///   when the WAL is empty after a snapshot).
874    pub fn open_at(dir: &std::path::Path, commit: u64) -> Result<Self> {
875        Self::open_at_with(RealFs::new(dir)?, commit)
876    }
877}
878
879impl<F: Fs> GraphDb<F> {
880    pub fn open_with(fs: F) -> Result<Self> {
881        let mut db = Self {
882            fs,
883            ids: IdMap::new(),
884            syms: Interner::new(),
885            topo: Topology::new(),
886            props: ColumnStore::new(),
887            labels: Vec::new(),
888            edge_props: EdgeProps::new(),
889            engine: RuleEngine::new(),
890            view_store: ViewStore::new(),
891            fulltext: FulltextIndex::new(),
892            event_sink: None,
893            fsync: FsyncPolicy::Strict,
894            commit_seq: 0,
895            subscriptions: Vec::new(),
896            query_subscriptions: Vec::new(),
897            sub_capacity: DEFAULT_SUB_CAPACITY,
898            read_only: false,
899            total_wal_commits: 0,
900        };
901        let snap_bytes = db.fs.read(FileId::Snapshot)?;
902        if let Some(state) = core_storage::snapshot::decode(&snap_bytes)? {
903            db.restore_snapshot_state(state)?;
904        }
905        let bytes = db.fs.read(FileId::Wal)?;
906        let (records, valid_len) = decode_all(&bytes);
907        if valid_len < bytes.len() {
908            db.fs.write_atomic(FileId::Wal, &bytes[..valid_len])?;
909        }
910        for rec in records {
911            db.apply(&rec)?;
912            // Drain per-frame to keep pending_deltas O(1) during replay (I-2).
913            // No subscriber exists yet; discard is correct.
914            let _ = db.engine.drain_deltas();
915        }
916        // Enforce I-2: if the per-frame drain above is ever removed or skipped,
917        // this assert catches the regression in debug builds immediately.
918        debug_assert_eq!(
919            db.engine.pending_delta_count(),
920            0,
921            "pending_deltas non-empty after replay — \
922             per-frame drain must run inside the loop to keep memory O(1)"
923        );
924        // T2 note: the per-frame drain IS the suppression seam for replay.
925        // Any future as-of replay path (Plan-15 T2) must drain here to feed
926        // replaying subscribers; the mechanism is already in place.
927        let _ = db.engine.drain_deltas(); // belt-and-braces no-op after loop drain
928                                          // Rebuild view values after WAL replay so values are consistent with
929                                          // final topo+props state.  This is a full recompute that corrects any
930                                          // incremental drift accumulated during apply() replay.
931        db.view_store
932            .rebuild_all(&mut db.props, &db.topo, &db.ids, &db.syms, &db.labels);
933        // Rebuild full-text index after WAL replay.  Corrects drift from
934        // per-record incremental apply during replay.
935        db.fulltext
936            .rebuild_all(&db.ids, &db.labels, &db.syms, &db.props);
937        Ok(db)
938    }
939
940    /// As-of replay for [`GraphDb::open_at`]: snapshot base (only when the
941    /// snapshot truncated the WAL) plus the first `commit + 1` WAL frames;
942    /// see [`GraphDb::open_at`] for the semantics.  The per-frame drain
943    /// mirrors `open_with` exactly so pending_delta_count is 0 on exit.
944    /// Restore all persisted state from a decoded snapshot. Shared by
945    /// `open_with` and (when the snapshot truncated the WAL) `open_at_with`.
946    fn restore_snapshot_state(
947        &mut self,
948        state: core_storage::snapshot::SnapshotState,
949    ) -> Result<()> {
950        self.ids = state.ids;
951        self.syms = state.syms;
952        self.topo = state.topo;
953        self.props = state.props;
954        self.labels = state.labels;
955        self.edge_props = state.edge_props;
956        let defs: Vec<RuleDef> = state
957            .rule_defs
958            .iter()
959            .map(|b| {
960                bincode::deserialize(b).map_err(|e| GraphError::Corrupt {
961                    detail: format!("snapshot rule_def deserialize: {e}"),
962                })
963            })
964            .collect::<Result<Vec<_>>>()?;
965        self.engine =
966            RuleEngine::from_persist(defs, state.provenance, state.rule_tripped, state.rule_fires);
967        // V5 snapshot carries IVF state: restore it instead of re-fitting.
968        // This turns the cold-start multi-minute re-fit into microseconds.
969        let ivf_state: BTreeMap<String, RuleIvfExport> = state
970            .ivf_state
971            .into_iter()
972            .map(|(name, ps)| {
973                (
974                    name,
975                    (
976                        (ps.src.centroids, ps.src.clusters, ps.src.drift),
977                        (ps.dst.centroids, ps.dst.clusters, ps.dst.drift),
978                    ),
979                )
980            })
981            .collect();
982        self.engine.reindex_all_load_ivf(
983            &self.ids,
984            &self.syms,
985            &self.labels,
986            &self.props,
987            ivf_state,
988        );
989        // Restore HNSW graphs from snapshot (V7).
990        self.engine.load_hnsw_state(state.hnsw_state);
991        // Restore view defs from snapshot (V5).
992        // The ColumnStore already contains view values from the snapshot;
993        // use restore_view (no collision check, no backfill) so the store
994        // is aware of the definitions.  rebuild_all runs after WAL replay.
995        for def_bytes in &state.view_defs {
996            let def: ViewDef =
997                bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
998                    detail: format!("snapshot view_def deserialize: {e}"),
999                })?;
1000            self.view_store
1001                .restore_view(def)
1002                .map_err(|e| GraphError::Corrupt {
1003                    detail: format!("snapshot view restore: {e}"),
1004                })?;
1005        }
1006        Ok(())
1007    }
1008
1009    fn open_at_with(fs: F, commit: u64) -> Result<Self> {
1010        let mut db = Self {
1011            fs,
1012            ids: IdMap::new(),
1013            syms: Interner::new(),
1014            topo: Topology::new(),
1015            props: ColumnStore::new(),
1016            labels: Vec::new(),
1017            edge_props: EdgeProps::new(),
1018            engine: RuleEngine::new(),
1019            view_store: ViewStore::new(),
1020            fulltext: FulltextIndex::new(),
1021            event_sink: None,
1022            fsync: FsyncPolicy::Strict,
1023            commit_seq: 0,
1024            subscriptions: Vec::new(),
1025            query_subscriptions: Vec::new(),
1026            sub_capacity: DEFAULT_SUB_CAPACITY,
1027            read_only: false, // set to true after replay
1028            total_wal_commits: 0,
1029        };
1030        // Base state: a truncating snapshot compacts all pre-truncation
1031        // commits, so the on-disk WAL head coincides with the snapshot and
1032        // frame replay must start from it — dense-id records (`Intern`,
1033        // `*Id`) embed the live intern/id numbering, which only a snapshot
1034        // base reproduces. `keep_wal` and legacy V5/V6 snapshots leave a WAL
1035        // that reaches further back; for those the historical WAL-only
1036        // replay applies (`wal_truncated` defaults to false on decode).
1037        let snap_bytes = db.fs.read(FileId::Snapshot)?;
1038        if let Some(state) = core_storage::snapshot::decode(&snap_bytes)? {
1039            if state.wal_truncated {
1040                db.restore_snapshot_state(state)?;
1041            }
1042        }
1043        let bytes = db.fs.read(FileId::Wal)?;
1044        let (records, _valid_len) = decode_all(&bytes);
1045        let total = records.len() as u64;
1046        if commit >= total {
1047            return Err(GraphError::CommitOutOfRange { commit, total });
1048        }
1049        // Replay frames 0..=commit — identical drain pattern to open_with so
1050        // the pending_delta_count == 0 invariant holds.
1051        for rec in records.into_iter().take((commit + 1) as usize) {
1052            db.apply(&rec)?;
1053            // Drain per-frame: no subscriber exists; discard is correct.
1054            // This keeps memory O(1) and mirrors the open_with seam exactly.
1055            let _ = db.engine.drain_deltas();
1056        }
1057        // Pin: pending_delta_count must be 0 after as-of replay, mirroring T1's
1058        // post-loop assert in open_with.
1059        debug_assert_eq!(
1060            db.engine.pending_delta_count(),
1061            0,
1062            "pending_deltas non-empty after open_at replay — \
1063             per-frame drain must run inside the loop to keep memory O(1)"
1064        );
1065        let _ = db.engine.drain_deltas(); // belt-and-braces no-op
1066                                          // Rebuild view values after WAL replay so derived-edge-driven views
1067                                          // reflect the as-of state, not just the initial backfill at CreateView.
1068                                          // Mirrors the open_with rebuild_all call at db.rs:528.
1069        db.view_store
1070            .rebuild_all(&mut db.props, &db.topo, &db.ids, &db.syms, &db.labels);
1071        // Rebuild full-text index for as-of view (mirrors open_with pattern).
1072        db.fulltext
1073            .rebuild_all(&db.ids, &db.labels, &db.syms, &db.props);
1074        db.read_only = true;
1075        db.total_wal_commits = total;
1076        Ok(db)
1077    }
1078
1079    /// Whether this instance is a read-only as-of view.
1080    pub fn is_read_only(&self) -> bool {
1081        self.read_only
1082    }
1083
1084    /// Total number of WAL commits at the time [`open_at`] was called.
1085    /// Returns 0 for normal (non-as-of) instances.
1086    pub fn total_wal_commits(&self) -> u64 {
1087        self.total_wal_commits
1088    }
1089
1090    /// Apply a record to in-memory state. Used by both live writes and replay,
1091    /// so replay is definitionally identical to the original execution.
1092    fn apply(&mut self, rec: &WalRecord) -> Result<()> {
1093        match rec {
1094            WalRecord::InsertNode { label, key, props } => {
1095                let id = self.ids.try_insert(key)?;
1096                let sym = self.syms.intern(label);
1097                if self.labels.len() <= id as usize {
1098                    // gap slots are sentinels, never valid label symbols
1099                    self.labels.resize(id as usize + 1, u32::MAX);
1100                }
1101                self.labels[id as usize] = sym;
1102                for (field, value) in props {
1103                    self.props.set(id, field, value.clone());
1104                }
1105                // Initialize view values for the new node before the engine runs so
1106                // delta-based increments start from a known zero baseline.
1107                self.view_store
1108                    .init_node_views(id, &mut self.props, &self.syms, &self.labels);
1109                // Fire rules for the newly inserted node.
1110                let cursor = self.engine.pending_delta_count();
1111                let mut eng = std::mem::take(&mut self.engine);
1112                {
1113                    let mut gm = make_graph_mut(
1114                        &self.ids,
1115                        &mut self.syms,
1116                        &self.labels,
1117                        &self.props,
1118                        &mut self.topo,
1119                        &mut self.edge_props,
1120                    );
1121                    eng.on_node_changed(id, None, &mut gm);
1122                }
1123                self.engine = eng;
1124                // Process derived-edge deltas for view maintenance.
1125                // Fast path: skip the O(delta_count) allocation when no views exist.
1126                if !self.view_store.is_empty() {
1127                    #[cfg(test)]
1128                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
1129                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
1130                    for d in &new_deltas {
1131                        self.view_store.on_edge_changed(
1132                            d.etype_sym,
1133                            d.src_id,
1134                            d.dst_id,
1135                            d.fired,
1136                            &mut self.props,
1137                            &self.topo,
1138                            &self.ids,
1139                            &self.syms,
1140                            &self.labels,
1141                        );
1142                    }
1143                }
1144                // Full-text index maintenance: index enabled fields for this label.
1145                if self.fulltext.has_label(label) {
1146                    for (field, value) in props {
1147                        if self.fulltext.is_enabled(label, field) {
1148                            self.fulltext.add_tokens(id, field, value);
1149                        }
1150                    }
1151                }
1152            }
1153            WalRecord::InsertEdge {
1154                edge_type,
1155                src_key,
1156                dst_key,
1157            } => {
1158                let src = self.ids.get(src_key).ok_or_else(|| GraphError::Corrupt {
1159                    detail: format!("wal replay references unknown key {src_key}"),
1160                })?;
1161                let dst = self.ids.get(dst_key).ok_or_else(|| GraphError::Corrupt {
1162                    detail: format!("wal replay references unknown key {dst_key}"),
1163                })?;
1164                let etype = self.syms.intern(edge_type);
1165                self.topo.add_edge(etype, src, dst);
1166                // View maintenance for manual edge insert.
1167                self.view_store.on_edge_changed(
1168                    etype,
1169                    src,
1170                    dst,
1171                    true,
1172                    &mut self.props,
1173                    &self.topo,
1174                    &self.ids,
1175                    &self.syms,
1176                    &self.labels,
1177                );
1178                // Rule engine: via-hop rules must update when user edges change.
1179                let cursor = self.engine.pending_delta_count();
1180                let mut eng = std::mem::take(&mut self.engine);
1181                {
1182                    let mut gm = make_graph_mut(
1183                        &self.ids,
1184                        &mut self.syms,
1185                        &self.labels,
1186                        &self.props,
1187                        &mut self.topo,
1188                        &mut self.edge_props,
1189                    );
1190                    eng.on_edge_changed(edge_type, src, dst, &mut gm);
1191                }
1192                self.engine = eng;
1193                if !self.view_store.is_empty() {
1194                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
1195                    for d in &new_deltas {
1196                        self.view_store.on_edge_changed(
1197                            d.etype_sym,
1198                            d.src_id,
1199                            d.dst_id,
1200                            d.fired,
1201                            &mut self.props,
1202                            &self.topo,
1203                            &self.ids,
1204                            &self.syms,
1205                            &self.labels,
1206                        );
1207                    }
1208                }
1209            }
1210            WalRecord::SetProp { key, field, value } => {
1211                let id = self.ids.get(key).ok_or_else(|| GraphError::Corrupt {
1212                    detail: format!("wal replay references unknown key {key}"),
1213                })?;
1214                let old_value = self.props.get(id, field).cloned();
1215                self.props.set(id, field, value.clone());
1216                // Fire rules for the changed field.
1217                let cursor = self.engine.pending_delta_count();
1218                let mut eng = std::mem::take(&mut self.engine);
1219                {
1220                    let mut gm = make_graph_mut(
1221                        &self.ids,
1222                        &mut self.syms,
1223                        &self.labels,
1224                        &self.props,
1225                        &mut self.topo,
1226                        &mut self.edge_props,
1227                    );
1228                    eng.on_node_changed(id, Some((field, old_value)), &mut gm);
1229                }
1230                self.engine = eng;
1231                // Derived-edge deltas → view updates.
1232                if !self.view_store.is_empty() {
1233                    #[cfg(test)]
1234                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
1235                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
1236                    for d in &new_deltas {
1237                        self.view_store.on_edge_changed(
1238                            d.etype_sym,
1239                            d.src_id,
1240                            d.dst_id,
1241                            d.fired,
1242                            &mut self.props,
1243                            &self.topo,
1244                            &self.ids,
1245                            &self.syms,
1246                            &self.labels,
1247                        );
1248                    }
1249                }
1250                // Neighbor-aggregate views that read `field` must also update.
1251                self.view_store.on_prop_changed(
1252                    id,
1253                    field,
1254                    &mut self.props,
1255                    &self.topo,
1256                    &self.ids,
1257                    &self.syms,
1258                    &self.labels,
1259                );
1260                // Full-text index maintenance: update tokens for this field if indexed.
1261                if self.fulltext.field_indexed(field) {
1262                    let label_opt = self.labels.get(id as usize).and_then(|&sym| {
1263                        if sym == u32::MAX {
1264                            None
1265                        } else {
1266                            self.syms.resolve(sym)
1267                        }
1268                    });
1269                    if let Some(label) = label_opt {
1270                        if self.fulltext.is_enabled(label, field) {
1271                            self.fulltext.remove_node_field(id, field);
1272                            self.fulltext.add_tokens(id, field, value);
1273                        }
1274                    }
1275                }
1276            }
1277            WalRecord::Intern { id, text } => {
1278                if let Some(existing) = self.syms.get(text) {
1279                    if existing != *id {
1280                        return Err(GraphError::Corrupt {
1281                            detail: format!(
1282                                "wal intern mismatch for {text:?}: have {existing}, record {id}"
1283                            ),
1284                        });
1285                    }
1286                } else {
1287                    let got = self.syms.intern(text);
1288                    if got != *id {
1289                        return Err(GraphError::Corrupt {
1290                            detail: format!(
1291                                "wal intern assigned {got} for {text:?}, record wanted {id}"
1292                            ),
1293                        });
1294                    }
1295                }
1296            }
1297            WalRecord::InsertNodeId { label, key, props } => {
1298                let id = self.ids.try_insert(key)?;
1299                if self.labels.len() <= id as usize {
1300                    self.labels.resize(id as usize + 1, u32::MAX);
1301                }
1302                self.labels[id as usize] = *label;
1303                let label_str = self
1304                    .syms
1305                    .resolve(*label)
1306                    .ok_or_else(|| GraphError::Corrupt {
1307                        detail: format!("wal InsertNodeId unknown label intern {label}"),
1308                    })?
1309                    .to_string();
1310                for (field_sym, value) in props {
1311                    let field =
1312                        self.syms
1313                            .resolve(*field_sym)
1314                            .ok_or_else(|| GraphError::Corrupt {
1315                                detail: format!(
1316                                    "wal InsertNodeId unknown field intern {field_sym}"
1317                                ),
1318                            })?;
1319                    self.props.set(id, field, value.clone());
1320                }
1321                self.view_store
1322                    .init_node_views(id, &mut self.props, &self.syms, &self.labels);
1323                let cursor = self.engine.pending_delta_count();
1324                let mut eng = std::mem::take(&mut self.engine);
1325                {
1326                    let mut gm = make_graph_mut(
1327                        &self.ids,
1328                        &mut self.syms,
1329                        &self.labels,
1330                        &self.props,
1331                        &mut self.topo,
1332                        &mut self.edge_props,
1333                    );
1334                    eng.on_node_changed(id, None, &mut gm);
1335                }
1336                self.engine = eng;
1337                if !self.view_store.is_empty() {
1338                    #[cfg(test)]
1339                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
1340                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
1341                    for d in &new_deltas {
1342                        self.view_store.on_edge_changed(
1343                            d.etype_sym,
1344                            d.src_id,
1345                            d.dst_id,
1346                            d.fired,
1347                            &mut self.props,
1348                            &self.topo,
1349                            &self.ids,
1350                            &self.syms,
1351                            &self.labels,
1352                        );
1353                    }
1354                }
1355                if self.fulltext.has_label(&label_str) {
1356                    for (field_sym, value) in props {
1357                        let Some(field) = self.syms.resolve(*field_sym) else {
1358                            continue;
1359                        };
1360                        if self.fulltext.is_enabled(&label_str, field) {
1361                            self.fulltext.add_tokens(id, field, value);
1362                        }
1363                    }
1364                }
1365            }
1366            WalRecord::InsertEdgeId { etype, src, dst } => {
1367                // Replay-over-snapshot: dense ids in the pre-snapshot WAL may
1368                // already be tombstoned. Skip rather than attaching edges to
1369                // dead ids (DeleteNode keys the live re-insert, not the old id).
1370                if self.ids.is_tombstoned(*src)
1371                    || self.ids.is_tombstoned(*dst)
1372                    || self.ids.key_of(*src).is_none()
1373                    || self.ids.key_of(*dst).is_none()
1374                {
1375                    return Ok(());
1376                }
1377                self.topo.add_edge(*etype, *src, *dst);
1378                self.view_store.on_edge_changed(
1379                    *etype,
1380                    *src,
1381                    *dst,
1382                    true,
1383                    &mut self.props,
1384                    &self.topo,
1385                    &self.ids,
1386                    &self.syms,
1387                    &self.labels,
1388                );
1389                // Rule engine: via-hop rules fire when user via-edges are inserted.
1390                // Resolve etype back to string so on_edge_changed can match rules by name.
1391                if let Some(etype_str) = self.syms.resolve(*etype).map(|s| s.to_string()) {
1392                    let cursor = self.engine.pending_delta_count();
1393                    let mut eng = std::mem::take(&mut self.engine);
1394                    {
1395                        let mut gm = make_graph_mut(
1396                            &self.ids,
1397                            &mut self.syms,
1398                            &self.labels,
1399                            &self.props,
1400                            &mut self.topo,
1401                            &mut self.edge_props,
1402                        );
1403                        eng.on_edge_changed(&etype_str, *src, *dst, &mut gm);
1404                    }
1405                    self.engine = eng;
1406                    if !self.view_store.is_empty() {
1407                        let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
1408                        for d in &new_deltas {
1409                            self.view_store.on_edge_changed(
1410                                d.etype_sym,
1411                                d.src_id,
1412                                d.dst_id,
1413                                d.fired,
1414                                &mut self.props,
1415                                &self.topo,
1416                                &self.ids,
1417                                &self.syms,
1418                                &self.labels,
1419                            );
1420                        }
1421                    }
1422                }
1423            }
1424            WalRecord::SetPropId { id, field, value } => {
1425                if self.ids.is_tombstoned(*id) || self.ids.key_of(*id).is_none() {
1426                    return Ok(());
1427                }
1428                let field_str = self
1429                    .syms
1430                    .resolve(*field)
1431                    .ok_or_else(|| GraphError::Corrupt {
1432                        detail: format!("wal SetPropId unknown field intern {field}"),
1433                    })?
1434                    .to_string();
1435                let old_value = self.props.get(*id, &field_str).cloned();
1436                self.props.set(*id, &field_str, value.clone());
1437                let cursor = self.engine.pending_delta_count();
1438                let mut eng = std::mem::take(&mut self.engine);
1439                {
1440                    let mut gm = make_graph_mut(
1441                        &self.ids,
1442                        &mut self.syms,
1443                        &self.labels,
1444                        &self.props,
1445                        &mut self.topo,
1446                        &mut self.edge_props,
1447                    );
1448                    eng.on_node_changed(*id, Some((field_str.as_str(), old_value)), &mut gm);
1449                }
1450                self.engine = eng;
1451                if !self.view_store.is_empty() {
1452                    #[cfg(test)]
1453                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
1454                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
1455                    for d in &new_deltas {
1456                        self.view_store.on_edge_changed(
1457                            d.etype_sym,
1458                            d.src_id,
1459                            d.dst_id,
1460                            d.fired,
1461                            &mut self.props,
1462                            &self.topo,
1463                            &self.ids,
1464                            &self.syms,
1465                            &self.labels,
1466                        );
1467                    }
1468                }
1469                self.view_store.on_prop_changed(
1470                    *id,
1471                    &field_str,
1472                    &mut self.props,
1473                    &self.topo,
1474                    &self.ids,
1475                    &self.syms,
1476                    &self.labels,
1477                );
1478                if self.fulltext.field_indexed(&field_str) {
1479                    let label_opt = self.labels.get(*id as usize).and_then(|&sym| {
1480                        if sym == u32::MAX {
1481                            None
1482                        } else {
1483                            self.syms.resolve(sym)
1484                        }
1485                    });
1486                    if let Some(label) = label_opt {
1487                        if self.fulltext.is_enabled(label, &field_str) {
1488                            self.fulltext.remove_node_field(*id, &field_str);
1489                            self.fulltext.add_tokens(*id, &field_str, value);
1490                        }
1491                    }
1492                }
1493            }
1494            WalRecord::CreateRule { def_bytes } => {
1495                let def: RuleDef =
1496                    bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
1497                        detail: format!("CreateRule def_bytes deserialize failed: {e}"),
1498                    })?;
1499                // Replay-over-snapshot idempotency: the rule was captured in the snapshot
1500                // so the engine already has it; silently skip to avoid a spurious
1501                // RuleInvalid error in the crash window between snapshot write and WAL
1502                // truncation.
1503                if self.engine.rules().any(|r| r.name == def.name) {
1504                    return Ok(());
1505                }
1506                let cursor = self.engine.pending_delta_count();
1507                let mut eng = std::mem::take(&mut self.engine);
1508                let result = {
1509                    let mut gm = make_graph_mut(
1510                        &self.ids,
1511                        &mut self.syms,
1512                        &self.labels,
1513                        &self.props,
1514                        &mut self.topo,
1515                        &mut self.edge_props,
1516                    );
1517                    eng.create_rule(def, &mut gm)
1518                };
1519                self.engine = eng;
1520                result.map_err(|e| GraphError::RuleInvalid { detail: e })?;
1521                // Derived-edge fires from backfill → view updates.
1522                // Fast path: skip O(edge_count) allocation when no views exist.
1523                if !self.view_store.is_empty() {
1524                    #[cfg(test)]
1525                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
1526                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
1527                    for d in &new_deltas {
1528                        self.view_store.on_edge_changed(
1529                            d.etype_sym,
1530                            d.src_id,
1531                            d.dst_id,
1532                            d.fired,
1533                            &mut self.props,
1534                            &self.topo,
1535                            &self.ids,
1536                            &self.syms,
1537                            &self.labels,
1538                        );
1539                    }
1540                }
1541            }
1542            WalRecord::DeleteRule { name } => {
1543                // Replay-over-snapshot idempotency: the snapshot already captured the
1544                // post-delete state so the rule is absent; silently skip to avoid a
1545                // spurious RuleNotFound error in the crash window between snapshot write
1546                // and WAL truncation.
1547                if !self.engine.rules().any(|r| r.name == *name) {
1548                    return Ok(());
1549                }
1550                let cursor = self.engine.pending_delta_count();
1551                let mut eng = std::mem::take(&mut self.engine);
1552                let result = {
1553                    let mut gm = make_graph_mut(
1554                        &self.ids,
1555                        &mut self.syms,
1556                        &self.labels,
1557                        &self.props,
1558                        &mut self.topo,
1559                        &mut self.edge_props,
1560                    );
1561                    eng.delete_rule(name, &mut gm)
1562                };
1563                self.engine = eng;
1564                result.map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
1565                // Derived-edge retractions → view updates.
1566                if !self.view_store.is_empty() {
1567                    #[cfg(test)]
1568                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
1569                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
1570                    for d in &new_deltas {
1571                        self.view_store.on_edge_changed(
1572                            d.etype_sym,
1573                            d.src_id,
1574                            d.dst_id,
1575                            d.fired,
1576                            &mut self.props,
1577                            &self.topo,
1578                            &self.ids,
1579                            &self.syms,
1580                            &self.labels,
1581                        );
1582                    }
1583                }
1584            }
1585            WalRecord::RemoveProp { key, field } => {
1586                // Recovery-safe: unknown key or already-absent field is a
1587                // clean no-op. Crash-window replay over a snapshot that
1588                // already applied this record must not Err.
1589                let Some(id) = self.ids.get(key) else {
1590                    return Ok(());
1591                };
1592                let old = self.props.get(id, field).cloned();
1593                self.props.remove(id, field);
1594                let cursor = self.engine.pending_delta_count();
1595                let mut eng = std::mem::take(&mut self.engine);
1596                {
1597                    let mut gm = make_graph_mut(
1598                        &self.ids,
1599                        &mut self.syms,
1600                        &self.labels,
1601                        &self.props,
1602                        &mut self.topo,
1603                        &mut self.edge_props,
1604                    );
1605                    eng.on_node_changed(id, Some((field, old)), &mut gm);
1606                }
1607                self.engine = eng;
1608                // Derived-edge deltas → view updates.
1609                if !self.view_store.is_empty() {
1610                    #[cfg(test)]
1611                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
1612                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
1613                    for d in &new_deltas {
1614                        self.view_store.on_edge_changed(
1615                            d.etype_sym,
1616                            d.src_id,
1617                            d.dst_id,
1618                            d.fired,
1619                            &mut self.props,
1620                            &self.topo,
1621                            &self.ids,
1622                            &self.syms,
1623                            &self.labels,
1624                        );
1625                    }
1626                }
1627                // Neighbor-aggregate views that read `field` must also update.
1628                self.view_store.on_prop_changed(
1629                    id,
1630                    field,
1631                    &mut self.props,
1632                    &self.topo,
1633                    &self.ids,
1634                    &self.syms,
1635                    &self.labels,
1636                );
1637                // Full-text index maintenance: remove tokens for this field.
1638                if self.fulltext.field_indexed(field) {
1639                    self.fulltext.remove_node_field(id, field);
1640                }
1641            }
1642            WalRecord::DeleteEdge {
1643                edge_type,
1644                src_key,
1645                dst_key,
1646            } => {
1647                // Recovery-safe: unknown keys, unknown etype, or already-
1648                // absent edge is a clean no-op (remove_edge returns false).
1649                let Some(src) = self.ids.get(src_key) else {
1650                    return Ok(());
1651                };
1652                let Some(dst) = self.ids.get(dst_key) else {
1653                    return Ok(());
1654                };
1655                let Some(etype) = self.syms.get(edge_type) else {
1656                    return Ok(());
1657                };
1658                self.topo.remove_edge(etype, src, dst);
1659                self.edge_props.remove_edge(etype, src, dst);
1660                // View maintenance for manual edge delete (topo already updated above).
1661                self.view_store.on_edge_changed(
1662                    etype,
1663                    src,
1664                    dst,
1665                    false,
1666                    &mut self.props,
1667                    &self.topo,
1668                    &self.ids,
1669                    &self.syms,
1670                    &self.labels,
1671                );
1672                // Rule engine: via-hop rules must retract when user via-edges are deleted.
1673                let cursor = self.engine.pending_delta_count();
1674                let mut eng = std::mem::take(&mut self.engine);
1675                {
1676                    let mut gm = make_graph_mut(
1677                        &self.ids,
1678                        &mut self.syms,
1679                        &self.labels,
1680                        &self.props,
1681                        &mut self.topo,
1682                        &mut self.edge_props,
1683                    );
1684                    eng.on_edge_changed(edge_type, src, dst, &mut gm);
1685                }
1686                self.engine = eng;
1687                if !self.view_store.is_empty() {
1688                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
1689                    for d in &new_deltas {
1690                        self.view_store.on_edge_changed(
1691                            d.etype_sym,
1692                            d.src_id,
1693                            d.dst_id,
1694                            d.fired,
1695                            &mut self.props,
1696                            &self.topo,
1697                            &self.ids,
1698                            &self.syms,
1699                            &self.labels,
1700                        );
1701                    }
1702                }
1703            }
1704            WalRecord::DeleteNode { key } => {
1705                // Recovery-safe: already-tombstoned / unknown key is a clean
1706                // no-op. Crash-window replay over a snapshot that already
1707                // applied this record cannot recover the retired id from the
1708                // key (`IdMap::get` is None), so every subsequent step is
1709                // skipped. Each step is independently idempotent if invoked
1710                // twice on a still-live id: retraction is a no-op on empty
1711                // provenance, `remove_edge` returns false, `remove_all` is a
1712                // no-op, `ids.delete` returns None, label sentinel is sticky.
1713                let Some(n) = self.ids.get(key) else {
1714                    return Ok(());
1715                };
1716
1717                // (1) Retract derived edges + de-index while props/labels live.
1718                let cursor = self.engine.pending_delta_count();
1719                let mut eng = std::mem::take(&mut self.engine);
1720                {
1721                    let mut gm = make_graph_mut(
1722                        &self.ids,
1723                        &mut self.syms,
1724                        &self.labels,
1725                        &self.props,
1726                        &mut self.topo,
1727                        &mut self.edge_props,
1728                    );
1729                    eng.on_node_removed(n, &mut gm);
1730                }
1731                self.engine = eng;
1732                // Derived-edge retractions → view updates for neighbors.
1733                if !self.view_store.is_empty() {
1734                    #[cfg(test)]
1735                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
1736                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
1737                    for d in &new_deltas {
1738                        self.view_store.on_edge_changed(
1739                            d.etype_sym,
1740                            d.src_id,
1741                            d.dst_id,
1742                            d.fired,
1743                            &mut self.props,
1744                            &self.topo,
1745                            &self.ids,
1746                            &self.syms,
1747                            &self.labels,
1748                        );
1749                    }
1750                }
1751
1752                // (2) Sweep remaining user edges touching n, both directions,
1753                // every etype. Collect then remove so neighbor slices stay valid.
1754                // Remove from topo first, then call view maintenance so Avg/Min/Max
1755                // recompute sees the correct (reduced) neighbor set.
1756                let etypes: Vec<u32> = self.topo.etypes().collect();
1757                let mut doomed = Vec::new();
1758                for et in &etypes {
1759                    for &dst in self.topo.neighbors(*et, Direction::Out, n).as_ref() {
1760                        doomed.push((*et, n, dst));
1761                    }
1762                    for &src in self.topo.neighbors(*et, Direction::In, n).as_ref() {
1763                        doomed.push((*et, src, n));
1764                    }
1765                }
1766                for (et, s, d) in doomed {
1767                    self.topo.remove_edge(et, s, d);
1768                    self.edge_props.remove_edge(et, s, d);
1769                    // View maintenance: n's own view values will be cleared by
1770                    // remove_all below; only update surviving neighbors.
1771                    self.view_store.on_edge_changed(
1772                        et,
1773                        s,
1774                        d,
1775                        false,
1776                        &mut self.props,
1777                        &self.topo,
1778                        &self.ids,
1779                        &self.syms,
1780                        &self.labels,
1781                    );
1782                }
1783
1784                // (3) Drop every remaining prop (`ColumnStore::remove_all`).
1785                self.props.remove_all(n);
1786                // Full-text index maintenance: remove all tokens for this node.
1787                self.fulltext.remove_node(n);
1788
1789                // (4) Retire the dense id and stamp the label sentinel.
1790                self.ids.delete(key);
1791                if let Some(slot) = self.labels.get_mut(n as usize) {
1792                    *slot = u32::MAX;
1793                }
1794            }
1795            WalRecord::Batch(inner) => {
1796                // Apply each inner record in order through the same apply path.
1797                // Inner records are validated free of nested Batch by encode_record.
1798                for rec in inner {
1799                    self.apply(rec)?;
1800                }
1801            }
1802            WalRecord::RebuildRule { name } => {
1803                // Replay-over-snapshot idempotency: the snapshot may already
1804                // reflect a later delete_rule, so the rule is absent; skip.
1805                if !self.engine.rules().any(|r| r.name == *name) {
1806                    return Ok(());
1807                }
1808                let cursor = self.engine.pending_delta_count();
1809                let mut eng = std::mem::take(&mut self.engine);
1810                let result = {
1811                    let mut gm = make_graph_mut(
1812                        &self.ids,
1813                        &mut self.syms,
1814                        &self.labels,
1815                        &self.props,
1816                        &mut self.topo,
1817                        &mut self.edge_props,
1818                    );
1819                    eng.rebuild(name, &mut gm)
1820                };
1821                self.engine = eng;
1822                result.map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
1823                // Derived-edge delta changes → view updates.
1824                if !self.view_store.is_empty() {
1825                    #[cfg(test)]
1826                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
1827                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
1828                    for d in &new_deltas {
1829                        self.view_store.on_edge_changed(
1830                            d.etype_sym,
1831                            d.src_id,
1832                            d.dst_id,
1833                            d.fired,
1834                            &mut self.props,
1835                            &self.topo,
1836                            &self.ids,
1837                            &self.syms,
1838                            &self.labels,
1839                        );
1840                    }
1841                }
1842            }
1843            WalRecord::CreateView { def_bytes } => {
1844                let def: ViewDef =
1845                    bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
1846                        detail: format!("CreateView def_bytes deserialize failed: {e}"),
1847                    })?;
1848                // Replay-over-snapshot idempotency: view already present → skip.
1849                if self.view_store.has_view(&def.name) {
1850                    return Ok(());
1851                }
1852                self.view_store
1853                    .create_view(
1854                        def,
1855                        &mut self.props,
1856                        &self.topo,
1857                        &self.ids,
1858                        &self.syms,
1859                        &self.labels,
1860                    )
1861                    .map_err(|e| GraphError::RuleInvalid { detail: e })?;
1862            }
1863            WalRecord::DeleteView { name } => {
1864                // Replay-over-snapshot idempotency: view already absent → skip.
1865                if !self.view_store.has_view(name) {
1866                    return Ok(());
1867                }
1868                self.view_store
1869                    .delete_view(name, &mut self.props, &self.ids, &self.labels, &self.syms)
1870                    .map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
1871            }
1872            WalRecord::EnableFulltext { label, field } => {
1873                // Replay-over-snapshot idempotency: already enabled → skip.
1874                if self.fulltext.is_enabled(label, field) {
1875                    return Ok(());
1876                }
1877                self.fulltext.enable(label, field);
1878                // Backfill: index all live nodes of this label that have the field.
1879                let n = self.ids.len() as u32;
1880                for id in 0..n {
1881                    let Some(&sym) = self.labels.get(id as usize) else {
1882                        continue;
1883                    };
1884                    if sym == u32::MAX {
1885                        continue; // tombstoned
1886                    }
1887                    let Some(lbl) = self.syms.resolve(sym) else {
1888                        continue;
1889                    };
1890                    if lbl != label {
1891                        continue;
1892                    }
1893                    if let Some(value) = self.props.get(id, field) {
1894                        let value = value.clone();
1895                        self.fulltext.add_tokens(id, field, &value);
1896                    }
1897                }
1898            }
1899            WalRecord::DisableFulltext { label, field } => {
1900                // Replay-over-snapshot idempotency: already disabled → skip.
1901                if !self.fulltext.is_enabled(label, field) {
1902                    return Ok(());
1903                }
1904                // If another label still indexes this field, the postings column
1905                // is kept — but it must not contain node_ids from the now-disabled
1906                // label.  Remove them before calling disable() so the field_indexed
1907                // guard inside disable() sees the correct post-removal state.
1908                if self.fulltext.field_indexed_by_other(label, field) {
1909                    if let Some(label_sym) = self.syms.get(label) {
1910                        for (node_id, &lsym) in self.labels.iter().enumerate() {
1911                            if lsym == label_sym {
1912                                self.fulltext.remove_node_field(node_id as u32, field);
1913                            }
1914                        }
1915                    }
1916                }
1917                self.fulltext.disable(label, field);
1918            }
1919        }
1920        Ok(())
1921    }
1922
1923    /// Intern `s` in `syms` and emit a WAL `Intern` record so `*Id` records
1924    /// replay on WAL-only `open_at` (no snapshot intern table). Apply is
1925    /// idempotent when the string is already bound. Always emit: after
1926    /// `snapshot()` the WAL is truncated and live intern is not on disk.
1927    fn intern_wal(&mut self, s: &str) -> (u32, WalRecord) {
1928        let id = if let Some(id) = self.syms.get(s) {
1929            id
1930        } else {
1931            self.syms.intern(s)
1932        };
1933        (
1934            id,
1935            WalRecord::Intern {
1936                id,
1937                text: s.to_string(),
1938            },
1939        )
1940    }
1941
1942    /// Rewrite user-facing records into dense-id records. On `Err`, no live
1943    /// state is left mutated: speculative interns made while building the
1944    /// output are rolled back, so a later successful mutation cannot log an
1945    /// `Intern` record whose id replay would never reproduce.
1946    fn rewrite_wal_dense(&mut self, recs: Vec<WalRecord>) -> Result<Vec<WalRecord>> {
1947        let syms_checkpoint = self.syms.len();
1948        let result = self.rewrite_wal_dense_inner(recs);
1949        if result.is_err() {
1950            self.syms.truncate(syms_checkpoint);
1951        }
1952        result
1953    }
1954
1955    fn rewrite_wal_dense_inner(&mut self, recs: Vec<WalRecord>) -> Result<Vec<WalRecord>> {
1956        let mut out = Vec::with_capacity(recs.len());
1957        // Node ids allocated by later apply(InsertNodeId) in this same batch.
1958        let mut pending: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
1959        let mut interned = std::collections::HashSet::<u32>::new();
1960        let mut next = u32::try_from(self.ids.len()).map_err(|_| GraphError::Corrupt {
1961            detail: "id space exhausted".into(),
1962        })?;
1963        let lookup = |ids: &IdMap,
1964                      pending: &std::collections::HashMap<String, u32>,
1965                      key: &str|
1966         -> Option<u32> { ids.get(key).or_else(|| pending.get(key).copied()) };
1967        for rec in recs {
1968            match rec {
1969                WalRecord::InsertNode { label, key, props } => {
1970                    let (label_id, intern) = self.intern_wal(&label);
1971                    if interned.insert(label_id) {
1972                        out.push(intern);
1973                    }
1974                    let mut props_id = Vec::with_capacity(props.len());
1975                    for (field, value) in props {
1976                        let (field_id, intern) = self.intern_wal(&field);
1977                        if interned.insert(field_id) {
1978                            out.push(intern);
1979                        }
1980                        props_id.push((field_id, value));
1981                    }
1982                    if lookup(&self.ids, &pending, &key).is_none() {
1983                        pending.insert(key.clone(), next);
1984                        next = next.checked_add(1).ok_or_else(|| GraphError::Corrupt {
1985                            detail: "id space exhausted".into(),
1986                        })?;
1987                    }
1988                    out.push(WalRecord::InsertNodeId {
1989                        label: label_id,
1990                        key,
1991                        props: props_id,
1992                    });
1993                }
1994                WalRecord::SetProp { key, field, value } => {
1995                    let id =
1996                        lookup(&self.ids, &pending, &key).ok_or_else(|| GraphError::Corrupt {
1997                            detail: format!("dense WAL rewrite missing key {key}"),
1998                        })?;
1999                    let (field_id, intern) = self.intern_wal(&field);
2000                    if interned.insert(field_id) {
2001                        out.push(intern);
2002                    }
2003                    out.push(WalRecord::SetPropId {
2004                        id,
2005                        field: field_id,
2006                        value,
2007                    });
2008                }
2009                WalRecord::InsertEdge {
2010                    edge_type,
2011                    src_key,
2012                    dst_key,
2013                } => {
2014                    let (etype, intern) = self.intern_wal(&edge_type);
2015                    if interned.insert(etype) {
2016                        out.push(intern);
2017                    }
2018                    let src = lookup(&self.ids, &pending, &src_key).ok_or_else(|| {
2019                        GraphError::Corrupt {
2020                            detail: format!("dense WAL rewrite missing src {src_key}"),
2021                        }
2022                    })?;
2023                    let dst = lookup(&self.ids, &pending, &dst_key).ok_or_else(|| {
2024                        GraphError::Corrupt {
2025                            detail: format!("dense WAL rewrite missing dst {dst_key}"),
2026                        }
2027                    })?;
2028                    out.push(WalRecord::InsertEdgeId { etype, src, dst });
2029                }
2030                other => out.push(other),
2031            }
2032        }
2033        Ok(out)
2034    }
2035
2036    fn log_dense(&mut self, recs: Vec<WalRecord>) -> Result<()> {
2037        let recs = self.rewrite_wal_dense(recs)?;
2038        match recs.len() {
2039            0 => Ok(()),
2040            1 => self.log_then_apply(recs.into_iter().next().unwrap()),
2041            _ => self.log_then_apply(WalRecord::Batch(recs)),
2042        }
2043    }
2044
2045    /// Durable write, then notify the event sink. Replay (`apply` during
2046    /// `open`) never enters this function, so it is the replay-silent seam.
2047    fn log_then_apply(&mut self, rec: WalRecord) -> Result<()> {
2048        self.log_then_apply_with(rec, None, self.fsync)
2049    }
2050
2051    /// Whether this frame must fsync under `policy`.
2052    ///
2053    /// Batched contract: user-visible batches (>1 mutation) fsync; single
2054    /// mutations do not. The dense rewrite wraps a single mutation in a
2055    /// `Batch([Intern.., <one *Id record>])`, so `Intern` records are excluded
2056    /// from the count — removing that filter would make every single-op write
2057    /// fsync under Batched (or, if the threshold were raised instead, skip a
2058    /// needed fsync for real two-op batches).
2059    fn wal_needs_sync(policy: FsyncPolicy, rec: &WalRecord) -> bool {
2060        match policy {
2061            FsyncPolicy::Relaxed => false,
2062            FsyncPolicy::Strict => true,
2063            FsyncPolicy::Batched => match rec {
2064                // Intern + one mutation is the single-op rewrite, not a user batch.
2065                WalRecord::Batch(inner) => {
2066                    inner
2067                        .iter()
2068                        .filter(|r| !matches!(r, WalRecord::Intern { .. }))
2069                        .count()
2070                        > 1
2071                }
2072                _ => false,
2073            },
2074        }
2075    }
2076
2077    /// # Apply-infallibility invariant (load-bearing)
2078    ///
2079    /// The ordering is: WAL append → fsync → apply. If `apply` returned `Err`
2080    /// for a `Batch` frame after a successful WAL write, the WAL would contain
2081    /// the full frame while in-memory state would reflect only the ops before
2082    /// the failure. On reopen, WAL replay would then apply the entire batch —
2083    /// diverging permanently from what the pre-crash process had in memory.
2084    ///
2085    /// For `Batch` frames this situation cannot arise because:
2086    /// - All validation runs via `commit_logged_batch`/`MutPreview` **before**
2087    ///   the WAL write. `MutPreview` uses the same `&mut self` that apply will
2088    ///   use, with no concurrent mutation between validation exit and apply entry.
2089    /// - Every `apply` arm for a validated op is either infallible by construction
2090    ///   (`InsertNode`, `RemoveProp`, `DeleteEdge`, `DeleteNode`), has idempotency
2091    ///   guards that return `Ok(())` (`CreateRule`, `DeleteRule`), or is
2092    ///   guaranteed-present by validation (`InsertEdge`/`SetProp` key lookups).
2093    /// - `on_node_changed` and `on_node_removed` return `()` — never `Err`.
2094    ///
2095    /// A `debug_assert!` below fires in debug builds if `apply` ever returns
2096    /// `Err` for a `Batch` frame, making any future regression immediately visible
2097    /// in tests rather than silently diverging crash-recovery behaviour.
2098    fn log_then_apply_with(
2099        &mut self,
2100        rec: WalRecord,
2101        ingest: Option<(String, usize)>,
2102        policy: FsyncPolicy,
2103    ) -> Result<()> {
2104        // Read-only guard: as-of instances must never write the WAL.
2105        if self.read_only {
2106            return Err(GraphError::ReadOnly);
2107        }
2108        // Invariant (I-1): no stale deltas may enter from a previous apply.
2109        // If any engine method ever accumulates deltas before erroring, they would
2110        // contaminate the *next* commit's event stream. This assert fires in debug
2111        // builds, making any future regression visible at the earliest point.
2112        debug_assert_eq!(
2113            self.engine.pending_delta_count(),
2114            0,
2115            "stale engine deltas at log_then_apply_with entry — \
2116             a previous apply arm may have accumulated deltas before erroring; \
2117             the caller must drain_deltas() on any error path before returning"
2118        );
2119        self.fs.append(FileId::Wal, &encode_record(&rec))?;
2120        if Self::wal_needs_sync(policy, &rec) {
2121            self.fs.sync(FileId::Wal)?;
2122        }
2123        let apply_result = self.apply(&rec);
2124        // For Batch frames, post-validation apply must be infallible (see above).
2125        // A debug_assert here catches any future change that makes apply fallible
2126        // before the caller notices via silent WAL/memory divergence.
2127        if matches!(&rec, WalRecord::Batch(_)) {
2128            debug_assert!(
2129                apply_result.is_ok(),
2130                "Batch apply returned Err after successful WAL write — \
2131                 the validate-then-apply invariant has been violated; \
2132                 see log_then_apply_with invariant doc"
2133            );
2134        }
2135        if apply_result.is_err() {
2136            // Discard any partial deltas accumulated by the failed apply.
2137            // They must not ride the next commit's event stream (I-1).
2138            let _ = self.engine.drain_deltas();
2139            let _ = self.engine.take_rebuild_needed();
2140            apply_result?;
2141        }
2142        self.commit_seq += 1;
2143        let seq = self.commit_seq;
2144        // Drain engine deltas and distribute to subscribers before the existing
2145        // MutationEvent sink fires — both happen post-fsync, post-apply.
2146        let engine_deltas = self.engine.drain_deltas();
2147        self.distribute_events(&rec, &engine_deltas, seq);
2148        self.emit_committed(&rec, ingest);
2149        // Drift is only known after apply, so auto-rebuild cannot join the
2150        // triggering op's WAL frame. Issue RebuildRule as a second commit.
2151        // Skip when `rec` is itself RebuildRule: rebuild resets drift, so a
2152        // retrigger loop is impossible if the fit succeeded, but we still
2153        // drain the flag so a leftover cannot re-enter.
2154        let rebuilds = self.engine.take_rebuild_needed();
2155        if !matches!(&rec, WalRecord::RebuildRule { .. }) {
2156            let mut failed = Vec::new();
2157            for name in rebuilds {
2158                if self.engine.rules().any(|r| r.name == name) {
2159                    // User op is already durable. A failed second commit must
2160                    // not surface as the caller's error.
2161                    if let Err(e) =
2162                        self.log_then_apply(WalRecord::RebuildRule { name: name.clone() })
2163                    {
2164                        eprintln!(
2165                            "auto-rebuild of rule {name:?} failed after durable user commit: {e}"
2166                        );
2167                        failed.push(name);
2168                    }
2169                }
2170            }
2171            for name in failed {
2172                self.engine.queue_rebuild_needed(name);
2173            }
2174        }
2175        Ok(())
2176    }
2177
2178    /// Install a post-commit hook. Replaces any previous sink.
2179    ///
2180    /// The sink runs inside `log_then_apply` after a successful
2181    /// durable commit, while the caller still holds `&mut self`. When this
2182    /// database is behind a [`crate::SharedDb`], that means the **write
2183    /// guard is held**. The sink must never call `read` / `write` (or any
2184    /// other method) on the same `SharedDb` — the `RwLock` is not
2185    /// re-entrant and doing so deadlocks. The sink is `Send + Sync`;
2186    /// `std::sync::mpsc::Sender` is not `Sync` and will not type-check.
2187    /// Intended examples: `std::sync::mpsc::SyncSender`,
2188    /// `tokio::sync::mpsc::Sender`, `tokio::sync::broadcast::Sender`
2189    /// (non-blocking `send`), or `Arc<Mutex<Vec<MutationEvent>>>`.
2190    pub fn set_event_sink(&mut self, sink: Box<dyn Fn(MutationEvent) + Send + Sync>) {
2191        self.event_sink = Some(sink);
2192    }
2193
2194    /// Whether a post-commit event sink is currently installed.
2195    pub fn has_event_sink(&self) -> bool {
2196        self.event_sink.is_some()
2197    }
2198
2199    /// Set WAL fsync cadence. Default [`FsyncPolicy::Strict`].
2200    pub fn set_fsync_policy(&mut self, p: FsyncPolicy) {
2201        self.fsync = p;
2202    }
2203
2204    fn emit(&self, ev: MutationEvent) {
2205        if let Some(sink) = &self.event_sink {
2206            sink(ev);
2207        }
2208    }
2209
2210    fn emit_committed(&self, rec: &WalRecord, ingest: Option<(String, usize)>) {
2211        match rec {
2212            WalRecord::Batch(inner) => {
2213                for r in inner {
2214                    if let Some(ev) = event_from_record(r, &self.syms, &self.ids) {
2215                        self.emit(ev);
2216                    }
2217                }
2218                match ingest {
2219                    Some((label, inserted)) => {
2220                        self.emit(MutationEvent::Ingested { label, inserted })
2221                    }
2222                    None => {
2223                        let ops = inner
2224                            .iter()
2225                            .filter(|r| !matches!(r, WalRecord::Intern { .. }))
2226                            .count();
2227                        if ops > 1 {
2228                            self.emit(MutationEvent::BatchApplied { ops });
2229                        }
2230                    }
2231                }
2232            }
2233            other => {
2234                if let Some(ev) = event_from_record(other, &self.syms, &self.ids) {
2235                    self.emit(ev);
2236                }
2237            }
2238        }
2239    }
2240
2241    // -----------------------------------------------------------------------
2242    // Subscription API
2243    // -----------------------------------------------------------------------
2244
2245    /// Distribute post-commit events to all live subscribers.
2246    ///
2247    /// Called from `log_then_apply_with` after apply + fsync, before the
2248    /// legacy MutationEvent sink. Prunes dead `Weak` entries in-place.
2249    ///
2250    /// Query subscriptions (subscribe_query) re-execute their plan on every
2251    /// call and diff the result against the previous run. Zero overhead when
2252    /// no query subscriptions are active.
2253    fn distribute_events(&mut self, rec: &WalRecord, engine_deltas: &[EngineEdgeDelta], seq: u64) {
2254        if self.subscriptions.is_empty() && self.query_subscriptions.is_empty() {
2255            return;
2256        }
2257
2258        if !self.subscriptions.is_empty() {
2259            // Build write events from the WAL record.
2260            let write_events: Vec<DbEvent> =
2261                Self::write_events_from_record(rec, seq, &self.syms, &self.ids);
2262
2263            // Build edge events from engine deltas.  Weight is looked up from
2264            // edge_props at distribution time (after apply), so it's always fresh.
2265            let edge_events: Vec<DbEvent> = engine_deltas
2266                .iter()
2267                .map(|d| {
2268                    if d.fired {
2269                        let weight = self
2270                            .edge_props
2271                            .get(d.etype_sym, d.src_id, d.dst_id, "weight")
2272                            .and_then(|v| {
2273                                if let core_storage::Value::Float(f) = v {
2274                                    Some(*f)
2275                                } else {
2276                                    None
2277                                }
2278                            });
2279                        DbEvent::EdgeFired {
2280                            rule: d.rule.clone(),
2281                            src_key: d.src_key.clone(),
2282                            dst_key: d.dst_key.clone(),
2283                            edge_type: d.edge_type.clone(),
2284                            weight,
2285                            commit_seq: seq,
2286                        }
2287                    } else {
2288                        DbEvent::EdgeRetracted {
2289                            rule: d.rule.clone(),
2290                            src_key: d.src_key.clone(),
2291                            dst_key: d.dst_key.clone(),
2292                            edge_type: d.edge_type.clone(),
2293                            commit_seq: seq,
2294                        }
2295                    }
2296                })
2297                .collect();
2298
2299            // Prune dead entries; push matching events to live ones.
2300            self.subscriptions.retain(|entry| {
2301                let Some(inner) = entry.inner.upgrade() else {
2302                    return false;
2303                };
2304                for ev in &write_events {
2305                    if event_matches(ev, &entry.filter) {
2306                        inner.push(ev.clone());
2307                    }
2308                }
2309                for ev in &edge_events {
2310                    if event_matches(ev, &entry.filter) {
2311                        inner.push(ev.clone());
2312                    }
2313                }
2314                true
2315            });
2316
2317            // Turn off delta accumulation if all subscribers dropped and no views remain.
2318            if self.subscriptions.is_empty() && self.view_store.is_empty() {
2319                self.engine.set_emit_deltas(false);
2320            }
2321        }
2322
2323        // Query subscriptions: full re-run per commit, then diff rows.
2324        // IMPORTANT: full re-execution on every commit — use LIMIT to bound cost.
2325        // Differential evaluation is roadmap / Phase 5.
2326        if !self.query_subscriptions.is_empty() {
2327            // Take the list out so we can call self.view() without borrow conflict.
2328            let mut query_subs = std::mem::take(&mut self.query_subscriptions);
2329            let empty_params = BTreeMap::new();
2330            query_subs.retain_mut(|entry| {
2331                let Some(inner) = entry.inner.upgrade() else {
2332                    return false; // subscriber dropped — prune
2333                };
2334                let result = match execute(&self.view(), &entry.ops, &Params(&empty_params)) {
2335                    Ok(r) => r,
2336                    Err(_) => return true, // keep entry; skip diff on transient error
2337                };
2338                // Build new row map: serialized-key → row data.
2339                let new_row_map: std::collections::HashMap<String, Vec<Option<Value>>> = (0
2340                    ..result.len())
2341                    .map(|i| {
2342                        let row = result.row(i).to_vec();
2343                        let key =
2344                            serde_json::to_string(&row).unwrap_or_else(|_| format!("{row:?}"));
2345                        (key, row)
2346                    })
2347                    .collect();
2348                // Removed rows: in prev but not in new.
2349                for (key, row) in &entry.prev_row_map {
2350                    if !new_row_map.contains_key(key) {
2351                        inner.push(DbEvent::QueryRowRemoved {
2352                            columns: entry.columns.clone(),
2353                            row: row.clone(),
2354                        });
2355                    }
2356                }
2357                // Added rows: in new but not in prev.
2358                for (key, row) in &new_row_map {
2359                    if !entry.prev_row_map.contains_key(key) {
2360                        inner.push(DbEvent::QueryRowAdded {
2361                            columns: entry.columns.clone(),
2362                            row: row.clone(),
2363                        });
2364                    }
2365                }
2366                entry.prev_row_map = new_row_map;
2367                true
2368            });
2369            self.query_subscriptions = query_subs;
2370        }
2371    }
2372
2373    /// Returns `true` if any live subscriber or view definition requires delta
2374    /// accumulation. Used to set `engine.emit_deltas` on subscribe/view DDL.
2375    fn needs_emit_deltas(&self) -> bool {
2376        !self.view_store.is_empty()
2377            || self
2378                .subscriptions
2379                .iter()
2380                .any(|e| e.inner.upgrade().is_some())
2381    }
2382
2383    /// Convert a WAL record into `DbEvent` write events with the given seq.
2384    fn write_events_from_record(
2385        rec: &WalRecord,
2386        seq: u64,
2387        intern: &Interner,
2388        ids: &IdMap,
2389    ) -> Vec<DbEvent> {
2390        match rec {
2391            WalRecord::InsertNode { label, key, .. } => vec![DbEvent::NodeInserted {
2392                label: label.clone(),
2393                key: key.clone(),
2394                commit_seq: seq,
2395            }],
2396            // *Id arms run after a successful apply, so resolution can only
2397            // fail on a programming error. Skip the event rather than emit a
2398            // fabricated "" that clients can't tell from a real empty value
2399            // (mirrors event_from_record returning None).
2400            WalRecord::InsertNodeId { label, key, .. } => intern
2401                .resolve(*label)
2402                .map(|label| DbEvent::NodeInserted {
2403                    label: label.to_string(),
2404                    key: key.clone(),
2405                    commit_seq: seq,
2406                })
2407                .into_iter()
2408                .collect(),
2409            WalRecord::SetProp { key, field, .. } => vec![DbEvent::PropSet {
2410                key: key.clone(),
2411                field: field.clone(),
2412                commit_seq: seq,
2413            }],
2414            WalRecord::SetPropId { id, field, .. } => ids
2415                .key_of(*id)
2416                .zip(intern.resolve(*field))
2417                .map(|(key, field)| DbEvent::PropSet {
2418                    key: key.to_string(),
2419                    field: field.to_string(),
2420                    commit_seq: seq,
2421                })
2422                .into_iter()
2423                .collect(),
2424            WalRecord::RemoveProp { key, field } => vec![DbEvent::PropRemoved {
2425                key: key.clone(),
2426                field: field.clone(),
2427                commit_seq: seq,
2428            }],
2429            WalRecord::InsertEdge {
2430                edge_type,
2431                src_key,
2432                dst_key,
2433            } => vec![DbEvent::EdgeInserted {
2434                edge_type: edge_type.clone(),
2435                src: src_key.clone(),
2436                dst: dst_key.clone(),
2437                commit_seq: seq,
2438            }],
2439            WalRecord::InsertEdgeId { etype, src, dst } => (|| {
2440                Some(DbEvent::EdgeInserted {
2441                    edge_type: intern.resolve(*etype)?.to_string(),
2442                    src: ids.key_of(*src)?.to_string(),
2443                    dst: ids.key_of(*dst)?.to_string(),
2444                    commit_seq: seq,
2445                })
2446            })()
2447            .into_iter()
2448            .collect(),
2449            WalRecord::DeleteEdge {
2450                edge_type,
2451                src_key,
2452                dst_key,
2453            } => vec![DbEvent::EdgeDeleted {
2454                edge_type: edge_type.clone(),
2455                src: src_key.clone(),
2456                dst: dst_key.clone(),
2457                commit_seq: seq,
2458            }],
2459            WalRecord::DeleteNode { key } => vec![DbEvent::NodeDeleted {
2460                key: key.clone(),
2461                commit_seq: seq,
2462            }],
2463            WalRecord::Batch(inner) => inner
2464                .iter()
2465                .flat_map(|r| Self::write_events_from_record(r, seq, intern, ids))
2466                .collect(),
2467            WalRecord::CreateRule { .. }
2468            | WalRecord::DeleteRule { .. }
2469            | WalRecord::RebuildRule { .. }
2470            | WalRecord::CreateView { .. }
2471            | WalRecord::DeleteView { .. }
2472            | WalRecord::EnableFulltext { .. }
2473            | WalRecord::DisableFulltext { .. }
2474            | WalRecord::Intern { .. } => vec![],
2475        }
2476    }
2477
2478    /// Subscribe to edge-fire and edge-retract events for one named rule.
2479    ///
2480    /// Returns `Err(GraphError::RuleNotFound)` if `rule_name` is not
2481    /// currently registered. Dropping the returned [`Subscription`] handle
2482    /// unregisters the subscriber — no further events are queued, no
2483    /// resources leak.
2484    pub fn subscribe_rule(&mut self, rule_name: &str) -> core_storage::Result<Subscription> {
2485        if self.read_only {
2486            return Err(core_storage::GraphError::ReadOnly);
2487        }
2488        if !self.engine.rules().any(|r| r.name == rule_name) {
2489            return Err(core_storage::GraphError::RuleNotFound {
2490                name: rule_name.to_string(),
2491            });
2492        }
2493        let inner = SubInner::new(self.sub_capacity());
2494        self.subscriptions.push(SubEntry {
2495            filter: SubFilter::Rule(rule_name.to_string()),
2496            inner: std::sync::Arc::downgrade(&inner),
2497        });
2498        self.engine.set_emit_deltas(true);
2499        Ok(Subscription(inner))
2500    }
2501
2502    /// Subscribe to edge-fire and edge-retract events for **all** rules.
2503    ///
2504    /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
2505    /// as-of instances never commit, so `distribute_events` never runs and the
2506    /// subscription would never deliver events.
2507    pub fn subscribe_all_rules(&mut self) -> core_storage::Result<Subscription> {
2508        if self.read_only {
2509            return Err(core_storage::GraphError::ReadOnly);
2510        }
2511        let inner = SubInner::new(self.sub_capacity());
2512        self.subscriptions.push(SubEntry {
2513            filter: SubFilter::AllRules,
2514            inner: std::sync::Arc::downgrade(&inner),
2515        });
2516        self.engine.set_emit_deltas(true);
2517        Ok(Subscription(inner))
2518    }
2519
2520    /// Subscribe to write events: node insert/delete, prop set/remove.
2521    ///
2522    /// Does not include edge-fire / edge-retract (rule-derived edge events).
2523    ///
2524    /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
2525    /// as-of instances never commit, so `distribute_events` never runs and the
2526    /// subscription would never deliver events.
2527    pub fn subscribe_writes(&mut self) -> core_storage::Result<Subscription> {
2528        if self.read_only {
2529            return Err(core_storage::GraphError::ReadOnly);
2530        }
2531        let inner = SubInner::new(self.sub_capacity());
2532        self.subscriptions.push(SubEntry {
2533            filter: SubFilter::Writes,
2534            inner: std::sync::Arc::downgrade(&inner),
2535        });
2536        self.engine.set_emit_deltas(true);
2537        Ok(Subscription(inner))
2538    }
2539
2540    /// Subscribe to incremental Cypher query results.
2541    ///
2542    /// Parses and plans `cypher`; rejects the query if the plan is not in the
2543    /// allowlisted subset (see [`core_query::cypher::is_subscribable`]):
2544    ///   - `MATCH (n:Label) WHERE … RETURN … [LIMIT n]`
2545    ///   - `MATCH (a)-[r:TYPE]->(b) RETURN … [LIMIT n]`  (exactly one hop)
2546    ///
2547    /// SKIP is not supported — it shifts the result window on every commit,
2548    /// causing spurious Added/Removed churn for rows whose data never changed.
2549    /// Multi-hop Expand chains are not supported; each additional MATCH clause
2550    /// widens scope beyond the documented single-scan / single-hop subset.
2551    ///
2552    /// After each successful commit, the plan is **fully re-executed** and the
2553    /// result is diffed against the previous run. Added rows produce
2554    /// [`DbEvent::QueryRowAdded`]; removed rows produce
2555    /// [`DbEvent::QueryRowRemoved`].
2556    ///
2557    /// **Full re-run per commit; use LIMIT to bound execution cost.**
2558    /// The existing 1 M intermediate-row cap applies. Differential evaluation
2559    /// is roadmap / Phase 5.
2560    ///
2561    /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
2562    /// as-of instances never commit, so `distribute_events` never runs and the
2563    /// subscription would never deliver events.
2564    ///
2565    /// Returns `Err(GraphError::QueryError)` if the query fails to parse, plan,
2566    /// or if the plan shape is not in the allowlist.
2567    pub fn subscribe_query(&mut self, cypher: &str) -> Result<Subscription> {
2568        if self.read_only {
2569            return Err(GraphError::ReadOnly);
2570        }
2571        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
2572            detail: format!("lex: {e}"),
2573        })?;
2574        let ast = parse(&tokens).map_err(|e| GraphError::QueryError {
2575            detail: format!("parse: {e}"),
2576        })?;
2577        let ops = plan(&ast).map_err(|e| GraphError::QueryError {
2578            detail: format!("plan: {e}"),
2579        })?;
2580        if !is_subscribable(&ops) {
2581            return Err(GraphError::QueryError {
2582                detail: "subscribe_query only supports allowlisted plan shapes: \
2583                         MATCH (n:Label) WHERE … RETURN … [LIMIT n] or \
2584                         MATCH (a)-[r:TYPE]->(b) RETURN … [LIMIT n] (exactly one hop). \
2585                         Not supported: multi-hop Expand chains, SKIP (creates \
2586                         unstable offset windows), ORDER BY, DISTINCT, aggregates, \
2587                         variable-length paths, OPTIONAL MATCH, WITH, UNWIND. \
2588                         Use LIMIT to bound re-execution cost."
2589                    .to_string(),
2590            });
2591        }
2592        // Execute once to capture initial state (initial rows are not emitted as
2593        // events — the subscriber learns the baseline via the first query call).
2594        let empty_params = BTreeMap::new();
2595        let initial = execute(&self.view(), &ops, &Params(&empty_params)).map_err(|e| {
2596            GraphError::QueryError {
2597                detail: format!("execute: {e}"),
2598            }
2599        })?;
2600        let columns = initial.columns().to_vec();
2601        let prev_row_map: std::collections::HashMap<String, Vec<Option<Value>>> = (0..initial
2602            .len())
2603            .map(|i| {
2604                let row = initial.row(i).to_vec();
2605                let key = serde_json::to_string(&row).unwrap_or_else(|_| format!("{row:?}"));
2606                (key, row)
2607            })
2608            .collect();
2609        let inner = SubInner::new(self.sub_capacity());
2610        self.query_subscriptions.push(QuerySubEntry {
2611            ops,
2612            columns,
2613            prev_row_map,
2614            inner: std::sync::Arc::downgrade(&inner),
2615        });
2616        Ok(Subscription(inner))
2617    }
2618
2619    /// Queue capacity used for new subscriptions.
2620    fn sub_capacity(&self) -> usize {
2621        self.sub_capacity
2622    }
2623
2624    /// Override per-subscriber queue capacity for subsequently created
2625    /// subscriptions on this db instance.
2626    ///
2627    /// Default is [`DEFAULT_SUB_CAPACITY`] (65,536 events). Use a smaller
2628    /// value in tests to exercise the [`DbEvent::Lagged`] path without
2629    /// generating tens of thousands of events.
2630    ///
2631    /// This is a test-support escape hatch. Calling it in production reduces
2632    /// subscriber reliability (more Lagged events). It is hidden from rustdoc
2633    /// to discourage accidental production use.
2634    #[doc(hidden)]
2635    pub fn set_sub_capacity(&mut self, capacity: usize) {
2636        self.sub_capacity = capacity;
2637    }
2638
2639    // -----------------------------------------------------------------------
2640
2641    /// Start an atomic batch.
2642    ///
2643    /// The returned [`BatchBuilder`] borrows `self` mutably until
2644    /// [`BatchBuilder::commit`]. Builder methods queue ops only — no
2645    /// validation, no WAL I/O. `commit` validates every queued op against
2646    /// live state plus preceding ops in this batch (duplicate key inside
2647    /// the batch is `Err`; an edge between two nodes created earlier in
2648    /// the batch is valid; `delete_node` then insert of the same key is a
2649    /// fresh identity). Validation never mutates the database. Any failure
2650    /// leaves WAL bytes and in-memory state identical to before `commit`.
2651    /// On success, one `WalRecord::Batch` frame is appended (one fsync)
2652    /// and each inner record is applied in order so rules fire per record.
2653    /// An empty batch, or a batch of only no-ops, writes zero WAL bytes.
2654    ///
2655    /// **Rule-window limitation:** batch validation cannot see edges that a
2656    /// rule created earlier in the *same* batch will derive at apply time, so
2657    /// a `delete_edge` / `insert_edge` in that window is silently no-oped
2658    /// where sequential calls would return `Err(RuleOwned)`. State integrity
2659    /// is unaffected (idempotent apply, provenance intact). Create rules in
2660    /// their own batch, or sequentially, when later ops may touch derived
2661    /// edges.
2662    pub fn batch(&mut self) -> BatchBuilder<'_, F> {
2663        BatchBuilder {
2664            db: self,
2665            ops: Vec::new(),
2666        }
2667    }
2668
2669    /// Closure-style atomic write batch.
2670    ///
2671    /// Equivalent to calling [`GraphDb::batch`], invoking `build` to queue ops,
2672    /// then committing. All ops queued inside `build` are validated in order and
2673    /// committed as a single `WalRecord::Batch` frame (one fsync). Rules fire
2674    /// once per inner record, in order, after commit — semantically identical to
2675    /// sequential single-op writes.
2676    ///
2677    /// **Error semantics — validate-then-apply.** `build` queues ops without
2678    /// touching the database. [`BatchBuilder::commit`] validates every op against
2679    /// live state plus earlier ops in this batch before writing anything. If op N
2680    /// fails validation (duplicate key, unknown key, rule-owned edge, …) the
2681    /// entire batch is rejected: no WAL bytes are written and no in-memory state
2682    /// changes. The database is identical to its state before `write_batch` was
2683    /// called.
2684    ///
2685    /// **Atomicity is crash-level, NOT isolation-level.** On replay after a crash,
2686    /// a partial (torn) `Batch` frame applies NONE of its ops — the frame is
2687    /// either fully applied or not at all. However, while applying a committed
2688    /// batch, concurrent readers may observe intermediate states as ops are applied
2689    /// sequentially in memory. There is no interactive transaction isolation in v1.
2690    /// This is documented as "crash-atomic write batches; no interactive
2691    /// transactions or read isolation."
2692    ///
2693    /// **Returns** `(nodes_inserted, edges_inserted)`. An empty or all-noop batch
2694    /// writes zero WAL bytes and returns `(0, 0)`.
2695    ///
2696    /// # Example
2697    ///
2698    /// ```rust,ignore
2699    /// let (nodes, edges) = db.write_batch(|b| {
2700    ///     b.insert_node("Person", "alice", vec![("age".into(), Value::Int(30))]);
2701    ///     b.insert_node("Person", "bob", vec![]);
2702    ///     b.insert_edge("KNOWS", "alice", "bob");
2703    ///     b.set_prop("alice", "role", Value::Str("admin".into()));
2704    ///     b.delete_node("old_key");
2705    /// })?;
2706    /// // One fsync; on crash replay: all five ops land or none do.
2707    /// ```
2708    pub fn write_batch<C>(&mut self, build: C) -> Result<(usize, usize)>
2709    where
2710        C: FnOnce(&mut BatchBuilder<'_, F>),
2711    {
2712        let mut b = self.batch();
2713        build(&mut b);
2714        b.commit()
2715    }
2716
2717    /// Insert `rows` as nodes of `label`. One call is one atomic batch:
2718    /// auto-declared KeyMatch rules (if any) first, then the accepted node
2719    /// inserts, so incremental fire sees the new rules. Per-row key problems
2720    /// are collected in [`IngestReport::row_errors`] and skipped; a commit
2721    /// `Err` means nothing was applied.
2722    ///
2723    /// Auto-FK rule names are `auto_fk_<src_label_lowercase>_<field>` so
2724    /// distinct source labels sharing an FK field each get their own rule.
2725    pub fn ingest(
2726        &mut self,
2727        label: &str,
2728        rows: Vec<BTreeMap<String, Value>>,
2729        opts: &IngestOptions,
2730    ) -> Result<IngestReport> {
2731        self.ingest_with_edges(label, rows, opts, &[])
2732    }
2733
2734    /// [`ingest`] plus user edges in the **same** previewed WAL batch.
2735    /// A failing edge rejects the whole request; nothing is applied.
2736    pub fn ingest_with_edges(
2737        &mut self,
2738        label: &str,
2739        rows: Vec<BTreeMap<String, Value>>,
2740        opts: &IngestOptions,
2741        edges: &[(String, String, String)],
2742    ) -> Result<IngestReport> {
2743        crate::ingest::run(self, label, rows, opts, edges)
2744    }
2745
2746    /// Parse `json` as an array of objects and ingest via [`GraphDb::ingest`].
2747    ///
2748    /// JSON `null` fields are silently omitted (not stored, not a row error).
2749    /// Nested objects and arrays-of-objects are a per-row error (row skipped).
2750    /// Parse failures and a top-level value that is not an array of objects
2751    /// return [`GraphError::IngestError`].
2752    pub fn ingest_json(
2753        &mut self,
2754        label: &str,
2755        json: &str,
2756        opts: &IngestOptions,
2757    ) -> Result<IngestReport> {
2758        crate::ingest::run_json(self, label, json, opts)
2759    }
2760
2761    fn commit_logged_batch(
2762        &mut self,
2763        ops: Vec<BatchOp>,
2764        ingest: Option<(String, usize)>,
2765    ) -> Result<(usize, usize)> {
2766        // Read-only guard: catches empty-batch calls before the early-return
2767        // that skips log_then_apply_with, ensuring all mutation entry points fail.
2768        if self.read_only {
2769            return Err(GraphError::ReadOnly);
2770        }
2771        let recs = {
2772            let mut preview = MutPreview::new(self);
2773            let mut recs = Vec::with_capacity(ops.len());
2774            for op in ops {
2775                match op {
2776                    BatchOp::InsertNode { label, key, props } => {
2777                        preview.check_insert_node(&key)?;
2778                        preview.note_insert_node(&key, &props);
2779                        recs.push(WalRecord::InsertNode { label, key, props });
2780                    }
2781                    BatchOp::InsertEdge {
2782                        edge_type,
2783                        src_key,
2784                        dst_key,
2785                    } => {
2786                        if preview.prepare_insert_edge(&edge_type, &src_key, &dst_key)? {
2787                            preview.note_insert_edge(&edge_type, &src_key, &dst_key);
2788                            recs.push(WalRecord::InsertEdge {
2789                                edge_type,
2790                                src_key,
2791                                dst_key,
2792                            });
2793                        }
2794                    }
2795                    BatchOp::SetProp { key, field, value } => {
2796                        preview.check_live_key(&key)?;
2797                        preview.note_set_prop(&key, &field, &value);
2798                        recs.push(WalRecord::SetProp { key, field, value });
2799                    }
2800                    BatchOp::RemoveProp { key, field } => {
2801                        if preview.prepare_remove_prop(&key, &field)? {
2802                            preview.note_remove_prop(&key, &field);
2803                            recs.push(WalRecord::RemoveProp { key, field });
2804                        }
2805                    }
2806                    BatchOp::DeleteEdge {
2807                        edge_type,
2808                        src_key,
2809                        dst_key,
2810                    } => {
2811                        if preview.prepare_delete_edge(&edge_type, &src_key, &dst_key)? {
2812                            preview.note_delete_edge(&edge_type, &src_key, &dst_key);
2813                            recs.push(WalRecord::DeleteEdge {
2814                                edge_type,
2815                                src_key,
2816                                dst_key,
2817                            });
2818                        }
2819                    }
2820                    BatchOp::DeleteNode { key } => {
2821                        preview.check_live_key(&key)?;
2822                        preview.note_delete_node(&key);
2823                        recs.push(WalRecord::DeleteNode { key });
2824                    }
2825                    BatchOp::CreateRule(def) => {
2826                        preview.check_create_rule(&def)?;
2827                        let def_bytes =
2828                            bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
2829                                detail: format!("serialize rule: {e}"),
2830                            })?;
2831                        preview.note_create_rule(&def.name);
2832                        recs.push(WalRecord::CreateRule { def_bytes });
2833                    }
2834                    BatchOp::DeleteRule { name } => {
2835                        preview.check_delete_rule(&name)?;
2836                        preview.note_delete_rule(&name);
2837                        recs.push(WalRecord::DeleteRule { name });
2838                    }
2839                }
2840            }
2841            recs
2842        };
2843        if recs.is_empty() {
2844            return Ok((0, 0));
2845        }
2846        // rewrite_wal_dense converts every InsertNode/InsertEdge into its
2847        // *Id form, so only the dense variants can appear in `recs` here.
2848        let recs = self.rewrite_wal_dense(recs)?;
2849        let nodes_inserted = recs
2850            .iter()
2851            .filter(|r| matches!(r, WalRecord::InsertNodeId { .. }))
2852            .count();
2853        let edges_inserted = recs
2854            .iter()
2855            .filter(|r| matches!(r, WalRecord::InsertEdgeId { .. }))
2856            .count();
2857        // Ingest / write_batch / query_write: one Batch frame. Strict and
2858        // Batched both fsync once at frame end; Relaxed still skips.
2859        let policy = match self.fsync {
2860            FsyncPolicy::Relaxed => FsyncPolicy::Relaxed,
2861            FsyncPolicy::Strict | FsyncPolicy::Batched => FsyncPolicy::Batched,
2862        };
2863        self.log_then_apply_with(WalRecord::Batch(recs), ingest, policy)?;
2864        Ok((nodes_inserted, edges_inserted))
2865    }
2866
2867    fn commit_batch(&mut self, ops: Vec<BatchOp>) -> Result<(usize, usize)> {
2868        self.commit_logged_batch(ops, None)
2869    }
2870
2871    pub fn insert_node(
2872        &mut self,
2873        label: &str,
2874        key: &str,
2875        props: Vec<(String, Value)>,
2876    ) -> Result<()> {
2877        if self.read_only {
2878            return Err(GraphError::ReadOnly);
2879        }
2880        MutPreview::new(self).check_insert_node(key)?;
2881        self.log_dense(vec![WalRecord::InsertNode {
2882            label: label.into(),
2883            key: key.into(),
2884            props,
2885        }])
2886    }
2887
2888    pub fn insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
2889        if self.read_only {
2890            return Err(GraphError::ReadOnly);
2891        }
2892        if !MutPreview::new(self).prepare_insert_edge(edge_type, src_key, dst_key)? {
2893            return Ok(false);
2894        }
2895        self.log_dense(vec![WalRecord::InsertEdge {
2896            edge_type: edge_type.into(),
2897            src_key: src_key.into(),
2898            dst_key: dst_key.into(),
2899        }])?;
2900        Ok(true)
2901    }
2902
2903    pub fn set_prop(&mut self, key: &str, field: &str, value: Value) -> Result<()> {
2904        if self.read_only {
2905            return Err(GraphError::ReadOnly);
2906        }
2907        if let Some(view_name) = self.view_store.view_for_prop(field) {
2908            return Err(GraphError::ViewPropReadOnly {
2909                view_name: view_name.to_string(),
2910            });
2911        }
2912        MutPreview::new(self).check_live_key(key)?;
2913        self.log_dense(vec![WalRecord::SetProp {
2914            key: key.into(),
2915            field: field.into(),
2916            value,
2917        }])
2918    }
2919
2920    /// Remove a property. Returns `Ok(false)` (and does not log) if the field
2921    /// is already absent. Unknown or tombstoned keys are `Err(KeyNotFound)`.
2922    pub fn remove_prop(&mut self, key: &str, field: &str) -> Result<bool> {
2923        if self.read_only {
2924            return Err(GraphError::ReadOnly);
2925        }
2926        if let Some(view_name) = self.view_store.view_for_prop(field) {
2927            return Err(GraphError::ViewPropReadOnly {
2928                view_name: view_name.to_string(),
2929            });
2930        }
2931        if !MutPreview::new(self).prepare_remove_prop(key, field)? {
2932            return Ok(false);
2933        }
2934        self.log_then_apply(WalRecord::RemoveProp {
2935            key: key.into(),
2936            field: field.into(),
2937        })?;
2938        Ok(true)
2939    }
2940
2941    /// Delete a user edge. Returns `Ok(false)` (and does not log) if the edge
2942    /// is absent. Unknown keys are `Err(KeyNotFound)`. Rule-owned edges — in
2943    /// provenance, or a pair a live rule would derive — are `Err(RuleOwned)`
2944    /// (the rule would just put the edge back; delete or change the rule).
2945    pub fn delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
2946        if self.read_only {
2947            return Err(GraphError::ReadOnly);
2948        }
2949        if !MutPreview::new(self).prepare_delete_edge(edge_type, src_key, dst_key)? {
2950            return Ok(false);
2951        }
2952        self.log_then_apply(WalRecord::DeleteEdge {
2953            edge_type: edge_type.into(),
2954            src_key: src_key.into(),
2955            dst_key: dst_key.into(),
2956        })?;
2957        Ok(true)
2958    }
2959
2960    /// Delete a live node. Unknown or already-tombstoned keys are
2961    /// `Err(KeyNotFound)` and are not logged. Validation runs before the WAL
2962    /// write; `apply` of a logged `DeleteNode` for an already-tombstoned key
2963    /// (crash window) is a clean no-op.
2964    ///
2965    /// Returns a [`DeleteReport`] with counts of manual and derived edges
2966    /// removed (computed from live state before the deletion is applied).
2967    pub fn delete_node(&mut self, key: &str) -> Result<DeleteReport> {
2968        if self.read_only {
2969            return Err(GraphError::ReadOnly);
2970        }
2971        let id = self
2972            .ids
2973            .get(key)
2974            .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
2975
2976        // Count edges before the delete is applied so we can report counts.
2977        let derived_set: BTreeSet<(u32, u32, u32)> = self
2978            .engine
2979            .provenance_touching(id)
2980            .map(|(_, etype, src, dst)| (etype, src, dst))
2981            .collect();
2982        let derived_edges = derived_set.len() as u64;
2983
2984        let mut total_topo = 0u64;
2985        for et in self.topo.etypes() {
2986            total_topo += self.topo.neighbors(et, Direction::Out, id).len() as u64
2987                + self.topo.neighbors(et, Direction::In, id).len() as u64;
2988        }
2989        // For symmetric rules (e.g. Overlap), a→b and b→a are two separate directed
2990        // triples in both the topo scan (Out and In from id) and in provenance_touching.
2991        // The subtraction remains correct because both counts include both directions.
2992        let manual_edges = total_topo.saturating_sub(derived_edges);
2993
2994        self.log_then_apply(WalRecord::DeleteNode { key: key.into() })?;
2995        Ok(DeleteReport {
2996            manual_edges,
2997            derived_edges,
2998        })
2999    }
3000
3001    /// Return the IVF drift counter for the dst-side candidate index of `rule`.
3002    /// `None` if the rule does not exist or is not approximate.
3003    ///
3004    /// The drift counter increments on IVF insert/remove after the last fit.
3005    /// When dst-side drift exceeds [`core_rules::IVF_DRIFT_REBUILD`], apply
3006    /// WAL-logs `RebuildRule` as a second commit (rebuild resets the counter).
3007    pub fn ivf_dst_drift(&self, rule: &str) -> Option<u64> {
3008        // SideIvfExport = (centroids, node→cluster, drift)
3009        self.engine
3010            .export_ivf_state()
3011            .remove(rule)
3012            .map(|(_src, dst)| dst.2)
3013    }
3014
3015    /// Validate and WAL-log a new rule, then backfill derived edges inside apply.
3016    /// Validation and duplicate-name check run before logging so invalid rules
3017    /// never enter the WAL.
3018    pub fn create_rule(&mut self, def: RuleDef) -> Result<()> {
3019        if self.read_only {
3020            return Err(GraphError::ReadOnly);
3021        }
3022        MutPreview::new(self).check_create_rule(&def)?;
3023        let def_bytes = bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
3024            detail: format!("serialize rule: {e}"),
3025        })?;
3026        self.log_then_apply(WalRecord::CreateRule { def_bytes })
3027    }
3028
3029    /// WAL-log rule deletion. Returns RuleNotFound if the rule does not exist.
3030    pub fn delete_rule(&mut self, name: &str) -> Result<()> {
3031        if self.read_only {
3032            return Err(GraphError::ReadOnly);
3033        }
3034        MutPreview::new(self).check_delete_rule(name)?;
3035        self.log_then_apply(WalRecord::DeleteRule { name: name.into() })
3036    }
3037
3038    /// Return a snapshot of all registered rules.
3039    pub fn rules(&self) -> Vec<RuleDef> {
3040        self.engine.rules().cloned().collect()
3041    }
3042
3043    // -----------------------------------------------------------------------
3044    // Rule suggestion API
3045    // -----------------------------------------------------------------------
3046
3047    /// Profile the database and suggest linking rules with previewed edge counts.
3048    ///
3049    /// Uses the default seed ([`core_rules::SUGGEST_DEFAULT_SEED`]) for deterministic
3050    /// sampling. Suggestions are sorted by estimated edge count (descending).
3051    /// **NO auto-accept** — call [`GraphDb::create_rule`] explicitly to apply.
3052    pub fn suggest_rules(&self) -> Vec<core_rules::RuleSuggestion> {
3053        self.suggest_rules_seeded(core_rules::SUGGEST_DEFAULT_SEED)
3054    }
3055
3056    /// Like [`suggest_rules`] but with a caller-supplied RNG seed for
3057    /// reproducibility. Same seed + same data = identical output.
3058    pub fn suggest_rules_seeded(&self, seed: u64) -> Vec<core_rules::RuleSuggestion> {
3059        self.suggest_rules_with_config(&core_rules::suggest::SuggestConfig::default(), seed)
3060            .suggestions
3061    }
3062
3063    /// [`suggest_rules_seeded`] with a fully custom [`SuggestConfig`].
3064    ///
3065    /// Returns a [`core_rules::SuggestReport`] that includes both the candidate list
3066    /// and a `truncated` flag indicating whether the global budget fired before all
3067    /// candidates were evaluated.
3068    pub fn suggest_rules_with_config(
3069        &self,
3070        config: &core_rules::suggest::SuggestConfig,
3071        seed: u64,
3072    ) -> core_rules::SuggestReport {
3073        use std::collections::BTreeMap;
3074
3075        // Collect (node_id, key) pairs per label, skipping tombstoned nodes.
3076        let mut label_nodes: BTreeMap<String, Vec<(u32, String)>> = BTreeMap::new();
3077        for id in 0..self.ids.len() as u32 {
3078            let Some(key) = self.ids.key_of(id) else {
3079                continue;
3080            };
3081            let Some(&sym) = self.labels.get(id as usize) else {
3082                continue;
3083            };
3084            if sym == u32::MAX {
3085                continue; // tombstoned
3086            }
3087            let Some(label) = self.syms.resolve(sym) else {
3088                continue;
3089            };
3090            label_nodes
3091                .entry(label.to_string())
3092                .or_default()
3093                .push((id, key.to_string()));
3094        }
3095
3096        let all_fields: Vec<String> = self.props.fields().map(String::from).collect();
3097        let existing = self.rules();
3098
3099        core_rules::suggest::suggest_rules(
3100            &label_nodes,
3101            &|id, field| self.props.get(id, field).cloned(),
3102            &all_fields,
3103            &existing,
3104            config,
3105            seed,
3106        )
3107    }
3108
3109    /// Recompute a rule's derived edges from scratch. WAL-logged so un-trip
3110    /// plus later mutations replay identically (rebuild is a pure function
3111    /// of state).
3112    ///
3113    /// Only exit from the tripped latch: if the full desired set fits the
3114    /// budget, it is applied completely and `tripped` clears; if it still
3115    /// exceeds the budget, provenance is left untouched and `tripped` stays
3116    /// true. Counts as a fire evaluation (see [`RuleStats::fires`]).
3117    /// Unknown rule → `RuleNotFound`, nothing logged.
3118    pub fn rebuild_rule(&mut self, name: &str) -> Result<()> {
3119        if self.read_only {
3120            return Err(GraphError::ReadOnly);
3121        }
3122        if !self.engine.rules().any(|r| r.name == name) {
3123            return Err(GraphError::RuleNotFound { name: name.into() });
3124        }
3125        self.log_then_apply(WalRecord::RebuildRule { name: name.into() })
3126    }
3127
3128    // -----------------------------------------------------------------------
3129    // Materialized view API
3130    // -----------------------------------------------------------------------
3131
3132    /// Register a new materialized property view, backfill its values for all
3133    /// existing nodes, and WAL-log the definition.
3134    ///
3135    /// # Errors
3136    /// - `ReadOnly`: called on an as-of instance.
3137    /// - `RuleInvalid`: name collision, view_prop collision, or invalid def.
3138    pub fn create_view(&mut self, def: ViewDef) -> Result<()> {
3139        if self.read_only {
3140            return Err(GraphError::ReadOnly);
3141        }
3142        // Pre-validate before WAL write.
3143        def.validate()
3144            .map_err(|e| GraphError::RuleInvalid { detail: e })?;
3145        if self.view_store.has_view(&def.name) {
3146            return Err(GraphError::RuleInvalid {
3147                detail: format!("view {:?} already exists", def.name),
3148            });
3149        }
3150        if let Some(existing) = self.view_store.view_for_prop(&def.view_prop) {
3151            return Err(GraphError::RuleInvalid {
3152                detail: format!(
3153                    "view_prop {:?} is already used by view {:?}",
3154                    def.view_prop, existing
3155                ),
3156            });
3157        }
3158        let def_bytes = bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
3159            detail: format!("serialize view: {e}"),
3160        })?;
3161        // Enable delta accumulation before the view is registered so subsequent
3162        // incremental edge events reach view maintenance from this point onward.
3163        // (The backfill inside create_view reads topo directly; it does not rely
3164        // on pending deltas.)
3165        self.engine.set_emit_deltas(true);
3166        self.log_then_apply(WalRecord::CreateView { def_bytes })
3167    }
3168
3169    /// Remove a named view and delete its values from every node.
3170    ///
3171    /// # Errors
3172    /// - `ReadOnly`: called on an as-of instance.
3173    /// - `RuleNotFound`: view does not exist.
3174    pub fn delete_view(&mut self, name: &str) -> Result<()> {
3175        if self.read_only {
3176            return Err(GraphError::ReadOnly);
3177        }
3178        if !self.view_store.has_view(name) {
3179            return Err(GraphError::RuleNotFound { name: name.into() });
3180        }
3181        let result = self.log_then_apply(WalRecord::DeleteView { name: name.into() });
3182        // After deletion, disable accumulation if no listeners remain.
3183        if !self.needs_emit_deltas() {
3184            self.engine.set_emit_deltas(false);
3185        }
3186        result
3187    }
3188
3189    /// Snapshot of all registered view definitions.
3190    pub fn views(&self) -> Vec<ViewDef> {
3191        self.view_store.views().cloned().collect()
3192    }
3193
3194    // -----------------------------------------------------------------------
3195    // Full-text-lite API
3196    // -----------------------------------------------------------------------
3197
3198    /// Enable full-text indexing for all nodes of `label` on property `field`.
3199    ///
3200    /// After this call, every subsequent write to `(label, field)` is reflected
3201    /// in the index incrementally.  Existing nodes are backfilled immediately.
3202    /// The declaration is persisted as a WAL record; the index itself is rebuilt
3203    /// from scratch on re-open (no snapshot format changes).
3204    ///
3205    /// # Errors
3206    /// - [`GraphError::ReadOnly`]: called on an as-of instance.
3207    /// - [`GraphError::RuleInvalid`]: `(label, field)` is already indexed.
3208    pub fn enable_fulltext(&mut self, label: &str, field: &str) -> Result<()> {
3209        if self.read_only {
3210            return Err(GraphError::ReadOnly);
3211        }
3212        if self.fulltext.is_enabled(label, field) {
3213            return Err(GraphError::RuleInvalid {
3214                detail: format!("full-text index for ({label:?}, {field:?}) already enabled"),
3215            });
3216        }
3217        self.log_then_apply(WalRecord::EnableFulltext {
3218            label: label.into(),
3219            field: field.into(),
3220        })
3221    }
3222
3223    /// Disable full-text indexing for `(label, field)` and drop its postings.
3224    ///
3225    /// # Errors
3226    /// - [`GraphError::ReadOnly`]: called on an as-of instance.
3227    /// - [`GraphError::RuleNotFound`]: `(label, field)` is not currently indexed.
3228    pub fn disable_fulltext(&mut self, label: &str, field: &str) -> Result<()> {
3229        if self.read_only {
3230            return Err(GraphError::ReadOnly);
3231        }
3232        if !self.fulltext.is_enabled(label, field) {
3233            return Err(GraphError::RuleNotFound {
3234                name: format!("fulltext({label},{field})"),
3235            });
3236        }
3237        self.log_then_apply(WalRecord::DisableFulltext {
3238            label: label.into(),
3239            field: field.into(),
3240        })
3241    }
3242
3243    /// Whether `(label, field)` is currently indexed for full-text search.
3244    pub fn is_fulltext_enabled(&self, label: &str, field: &str) -> bool {
3245        self.fulltext.is_enabled(label, field)
3246    }
3247
3248    /// Search a full-text-indexed field.
3249    ///
3250    /// Returns `(node_key, match_count)` pairs sorted by match_count descending,
3251    /// ties broken by key (lexicographic).  Tombstoned nodes are excluded.
3252    ///
3253    /// **Query syntax:**
3254    /// - Space-separated terms are AND'd: `"foo bar"` requires both.
3255    /// - `OR` between terms forms disjunction: `"foo OR bar"` matches either.
3256    /// - Trailing `*` on a term is a prefix match: `"rust*"` matches `rustlang`, `rusty`.
3257    /// - `AND` keyword is accepted explicitly and is the default.
3258    /// - Tokenization is unicode-alphanumeric (same as index time); case-insensitive.
3259    ///
3260    /// **Unindexed field:** returns `Ok(vec![])` if `field` is not indexed.
3261    /// Pin: this is the documented, tested, stable behavior for v1.
3262    ///
3263    /// **Memory / performance:** O(postings) lookup; no scan.  The index is
3264    /// in-memory and proportional to total indexed text across all enabled fields.
3265    pub fn search(&self, field: &str, query: &str) -> Vec<(String, usize)> {
3266        // Resolve node_ids to keys (excluding tombstones) then re-sort by
3267        // (match_count DESC, key ASC) to give a deterministic, key-lexicographic
3268        // tiebreak.  FulltextIndex::search sorts by (count DESC, node_id ASC)
3269        // which diverges from key order when nodes were not inserted in key-lex order.
3270        let mut results: Vec<(String, usize)> = self
3271            .fulltext
3272            .search(field, query)
3273            .into_iter()
3274            .filter_map(|(id, count)| self.ids.key_of(id).map(|key| (key.to_string(), count)))
3275            .collect();
3276        results.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
3277        results
3278    }
3279
3280    /// Hybrid search: Reciprocal Rank Fusion (RRF) over fulltext + vector results.
3281    ///
3282    /// Takes up to `4*k` fulltext hits for `(text_field, query_text)` and up to
3283    /// `4*k` vector hits for `(vector_field, query_vec, min=0.0)`, then fuses
3284    /// them with RRF using a fixed constant of 60.
3285    ///
3286    /// ```text
3287    /// score(d) = Σ  1 / (60 + rank_i(d))    (rank 1-based per list)
3288    /// ```
3289    ///
3290    /// Returns the top `k` nodes by fused score, ties broken by node key
3291    /// ascending (deterministic).
3292    ///
3293    /// # Vector leg fallback
3294    ///
3295    /// When `query_vec` is empty the vector leg is skipped entirely and
3296    /// results are ranked by the text list alone through the same RRF path
3297    /// (each text result scores `1/(60 + rank)` from that single list).
3298    ///
3299    /// When `label` is `None` and no HNSW rule covers `vector_field`, the
3300    /// brute-force scan cannot enumerate a node universe; `find_similar_vector`
3301    /// returns an empty result and the fused ranking is text-only.  Document
3302    /// this in your application layer if you rely on it.
3303    pub fn search_hybrid(
3304        &self,
3305        text_field: &str,
3306        query_text: &str,
3307        vector_field: &str,
3308        query_vec: &[f64],
3309        label: Option<&str>,
3310        k: usize,
3311    ) -> Vec<(String, f64)> {
3312        use std::collections::HashMap;
3313
3314        const RRF_K: f64 = 60.0;
3315        let pool = 4 * k.max(1);
3316
3317        // Accumulate per-node RRF scores.
3318        let mut scores: HashMap<String, f64> = HashMap::new();
3319
3320        // Text leg.
3321        let text_hits = self.search(text_field, query_text);
3322        for (rank0, (key, _count)) in text_hits.into_iter().take(pool).enumerate() {
3323            let rank = (rank0 + 1) as f64;
3324            *scores.entry(key).or_insert(0.0) += 1.0 / (RRF_K + rank);
3325        }
3326
3327        // Vector leg (skipped when query_vec is empty).
3328        if !query_vec.is_empty() {
3329            let lbl = label.unwrap_or("");
3330            let vec_hits = self.find_similar_vector(vector_field, lbl, query_vec, pool, 0.0);
3331            for (rank0, (key, _sim)) in vec_hits.into_iter().enumerate() {
3332                let rank = (rank0 + 1) as f64;
3333                *scores.entry(key).or_insert(0.0) += 1.0 / (RRF_K + rank);
3334            }
3335        }
3336
3337        // Sort: score DESC, then key ASC for deterministic tie-breaking.
3338        let mut ranked: Vec<(String, f64)> = scores.into_iter().collect();
3339        ranked.sort_by(|a, b| {
3340            b.1.partial_cmp(&a.1)
3341                .unwrap_or(std::cmp::Ordering::Equal)
3342                .then(a.0.cmp(&b.0))
3343        });
3344        ranked.truncate(k);
3345        ranked
3346    }
3347
3348    /// For DST/testing: scratch full-text search over live nodes without using
3349    /// the index.  Walks every live node, tokenizes the field value, and returns
3350    /// nodes matching the query.  Results are sorted match_count desc, key asc.
3351    ///
3352    /// The oracle: `search(field, q)` must equal `scratch_search(field, q)`.
3353    #[doc(hidden)]
3354    pub fn scratch_search(&self, field: &str, query: &str) -> Vec<(String, usize)> {
3355        use core_storage::fulltext::{parse_query, tokenize};
3356        use std::collections::BTreeSet;
3357        let groups = parse_query(query);
3358        let mut results: Vec<(String, usize)> = Vec::new();
3359        for id in 0..self.ids.len() as u32 {
3360            let Some(key) = self.ids.key_of(id) else {
3361                continue;
3362            };
3363            let Some(&sym) = self.labels.get(id as usize) else {
3364                continue;
3365            };
3366            if sym == u32::MAX {
3367                continue;
3368            }
3369            // Only scan nodes whose label has this field indexed.
3370            let label = match self.syms.resolve(sym) {
3371                Some(l) => l,
3372                None => continue,
3373            };
3374            if !self.fulltext.is_enabled(label, field) {
3375                continue;
3376            }
3377            let Some(value) = self.props.get(id, field) else {
3378                continue;
3379            };
3380            let node_tokens: BTreeSet<String> = match value {
3381                Value::Str(s) => tokenize(s).into_iter().collect(),
3382                Value::List(items) => items
3383                    .iter()
3384                    .flat_map(|v| {
3385                        if let Value::Str(s) = v {
3386                            tokenize(s)
3387                        } else {
3388                            vec![]
3389                        }
3390                    })
3391                    .collect(),
3392                _ => BTreeSet::new(),
3393            };
3394            // Count OR-group matches.
3395            let mut count = 0usize;
3396            for group in &groups {
3397                let mut group_match = true;
3398                for term in group {
3399                    let matched = if term.prefix {
3400                        node_tokens.iter().any(|t| t.starts_with(&term.token))
3401                    } else {
3402                        node_tokens.contains(&term.token)
3403                    };
3404                    if !matched {
3405                        group_match = false;
3406                        break;
3407                    }
3408                }
3409                if group_match {
3410                    count += 1;
3411                }
3412            }
3413            if count > 0 {
3414                results.push((key.to_string(), count));
3415            }
3416        }
3417        results.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
3418        results
3419    }
3420
3421    /// Return the current view-maintained value of `view_prop` for node `key`.
3422    /// Equivalent to `get_prop` but documents that it reads a view-managed column.
3423    pub fn get_view_prop(&self, key: &str, view_prop: &str) -> Option<&Value> {
3424        self.props.get(self.ids.get(key)?, view_prop)
3425    }
3426
3427    /// For testing / DST oracle: scratch recompute of a view value for one node.
3428    ///
3429    /// Returns `None` if the node does not exist, the view does not exist, or
3430    /// the view has no result for the node (e.g. Avg with no qualifying neighbors).
3431    #[doc(hidden)]
3432    pub fn scratch_view_value(&self, key: &str, view_name: &str) -> Option<Value> {
3433        let node = self.ids.get(key)?;
3434        let def = self.view_store.views().find(|v| v.name == view_name)?;
3435        // Direct scratch computation using the same internal function,
3436        // reading from live props so NeighborAgg sees real neighbor values.
3437        core_rules::views::compute_view_value(
3438            def,
3439            node,
3440            &self.props,
3441            &self.topo,
3442            &self.ids,
3443            &self.syms,
3444            &self.labels,
3445        )
3446    }
3447
3448    // -----------------------------------------------------------------------
3449    // Graph algorithm API
3450    // -----------------------------------------------------------------------
3451
3452    /// Run PageRank over the unified topology (manual + derived edges).
3453    ///
3454    /// Returns a [`PageRankReport`] with scores sorted descending (ties: key
3455    /// ascending).  Set `config.edge_type` to restrict to one edge type.
3456    /// `config.converged` is `true` only when the power iteration converged
3457    /// within `config.max_iters` and within any time budget.
3458    pub fn pagerank(&self, config: &crate::algo::PageRankConfig) -> crate::algo::PageRankReport {
3459        crate::algo::pagerank(&self.topo, &self.ids, &self.syms, &self.labels, config)
3460    }
3461
3462    /// Weakly-connected components over the unified topology (treated as
3463    /// undirected regardless of how edges were inserted).
3464    ///
3465    /// Component IDs are the key of the smallest member in the component
3466    /// (deterministic).  Result sorted by (component_id, key).
3467    pub fn connected_components(&self, config: &crate::algo::WccConfig) -> crate::algo::WccReport {
3468        crate::algo::wcc(&self.topo, &self.ids, &self.syms, &self.labels, config)
3469    }
3470
3471    /// Degree centrality for every live node.
3472    ///
3473    /// `direction`: `AlgoDir::Out` = out-degree, `AlgoDir::In` = in-degree,
3474    /// `AlgoDir::Both` = out + in (total directed degree).
3475    ///
3476    /// For one-shot ranking use this; for a live property updated on every
3477    /// write, create a Degree materialized view instead (see `docs/site/algorithms.md`).
3478    pub fn degree_centrality(
3479        &self,
3480        config: &crate::algo::DegreeConfig,
3481    ) -> crate::algo::DegreeReport {
3482        crate::algo::degree_centrality(&self.topo, &self.ids, &self.syms, &self.labels, config)
3483    }
3484
3485    /// Write a vector of `(node_key, score)` pairs as `prop_name` on each node,
3486    /// atomically via a single write-batch (one WAL frame, one fsync).
3487    ///
3488    /// # Errors
3489    /// - [`GraphError::ReadOnly`]: called on an as-of instance.
3490    /// - [`GraphError::RuleInvalid`]: `prop_name` is managed by an existing view
3491    ///   (collision check mirrors `create_view`).
3492    /// - [`GraphError::KeyNotFound`]: a key in `scores` does not exist as a live node.
3493    pub fn write_scores(&mut self, prop_name: &str, scores: &[(String, f64)]) -> Result<()> {
3494        if self.read_only {
3495            return Err(GraphError::ReadOnly);
3496        }
3497        // Collision check: refuse if prop_name is view-managed.
3498        if let Some(view_name) = self.view_store.view_for_prop(prop_name) {
3499            return Err(GraphError::RuleInvalid {
3500                detail: format!(
3501                    "prop {:?} is managed by view {:?} and cannot be written as scores",
3502                    prop_name, view_name
3503                ),
3504            });
3505        }
3506        // Refuse if prop_name is a view name itself (confusing namespace collision).
3507        if self.view_store.has_view(prop_name) {
3508            return Err(GraphError::RuleInvalid {
3509                detail: format!(
3510                    "prop_name {:?} collides with an existing view name",
3511                    prop_name
3512                ),
3513            });
3514        }
3515        // Write all scores in a single crash-atomic batch.
3516        self.write_batch(|b| {
3517            for (key, score) in scores {
3518                b.set_prop(key, prop_name, Value::Float(*score));
3519            }
3520        })?;
3521        Ok(())
3522    }
3523
3524    pub fn get_prop(&self, key: &str, field: &str) -> Option<&Value> {
3525        self.props.get(self.ids.get(key)?, field)
3526    }
3527
3528    pub fn has_node(&self, key: &str) -> bool {
3529        self.ids.get(key).is_some()
3530    }
3531
3532    /// Borrow the raw id map. Used by `NodeMask::from_keys` to resolve keys.
3533    pub(crate) fn ids(&self) -> &IdMap {
3534        &self.ids
3535    }
3536
3537    fn view(&self) -> GraphView<'_> {
3538        GraphView {
3539            ids: &self.ids,
3540            syms: &self.syms,
3541            labels: &self.labels,
3542            props: &self.props,
3543            topo: &self.topo,
3544            edge_props: &self.edge_props,
3545            mask: None,
3546        }
3547    }
3548
3549    fn view_masked<'a>(&'a self, mask: &'a crate::mask::NodeMask) -> GraphView<'a> {
3550        GraphView {
3551            ids: &self.ids,
3552            syms: &self.syms,
3553            labels: &self.labels,
3554            props: &self.props,
3555            topo: &self.topo,
3556            edge_props: &self.edge_props,
3557            mask: Some(&mask.visible),
3558        }
3559    }
3560
3561    /// Execute a read-only Cypher query with a node visibility mask.
3562    ///
3563    /// Only nodes whose key is in `mask` are accessible: label scans, key
3564    /// lookups, and neighbor expansions all respect the mask. Edges where
3565    /// either endpoint is hidden are silently dropped.
3566    ///
3567    /// Returns `Err` with a "masked queries are read-only" message when
3568    /// `cypher` is a write statement (CREATE / MERGE / MATCH…SET / DELETE).
3569    pub fn query_masked(
3570        &self,
3571        cypher: &str,
3572        params: &std::collections::BTreeMap<String, Value>,
3573        mask: &crate::mask::NodeMask,
3574    ) -> Result<ResultSet> {
3575        // Reject write statements up front.
3576        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
3577            detail: format!("lex: {e}"),
3578        })?;
3579        if is_write_tokens(&tokens) {
3580            return Err(GraphError::QueryError {
3581                detail: "masked queries are read-only".into(),
3582            });
3583        }
3584        let ast = parse(&tokens).map_err(|e| GraphError::QueryError {
3585            detail: format!("parse: {e}"),
3586        })?;
3587        let ops = plan(&ast).map_err(|e| GraphError::QueryError {
3588            detail: format!("plan: {e}"),
3589        })?;
3590        execute(&self.view_masked(mask), &ops, &Params(params)).map_err(|e| {
3591            GraphError::QueryError {
3592                detail: format!("execute: {e}"),
3593            }
3594        })
3595    }
3596
3597    pub fn node_ref(&self, key: &str) -> Option<NodeRef<'_, F>> {
3598        let id = self.ids.get(key)?;
3599        Some(NodeRef { db: self, id })
3600    }
3601
3602    /// Live node's key, label, and columnar props. Unknown or tombstoned → `None`.
3603    pub fn node_info(&self, key: &str) -> Option<NodeInfo> {
3604        let n = self.node_ref(key)?;
3605        Some(NodeInfo {
3606            key: n.key().to_string(),
3607            label: n.label().to_string(),
3608            props: n.props(),
3609        })
3610    }
3611
3612    /// Every directed edge incident on `key`, both directions, every etype.
3613    ///
3614    /// Walk is `topology.etypes()` × `{Out, In}` × `neighbors()`. `derived` is
3615    /// membership in [`RuleEngine::provenance_touching`] (O(degree) via the
3616    /// Plan-8 `by_node` index). Sorted by `(edge_type, src_key, dst_key)`.
3617    /// Unknown key → [`GraphError::KeyNotFound`].
3618    pub fn node_edges(&self, key: &str) -> Result<Vec<EdgeInfo>> {
3619        let id = self
3620            .ids
3621            .get(key)
3622            .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
3623        let derived: BTreeSet<(u32, u32, u32)> = self
3624            .engine
3625            .provenance_touching(id)
3626            .map(|(_rule, etype, src, dst)| (etype, src, dst))
3627            .collect();
3628        let mut edges = Vec::new();
3629        for etype in self.topo.etypes() {
3630            let edge_type = self
3631                .syms
3632                .resolve(etype)
3633                .expect("topology etype is interned")
3634                .to_string();
3635            for dir in [Direction::Out, Direction::In] {
3636                for &nbr in self.topo.neighbors(etype, dir, id).as_ref() {
3637                    let (src, dst, src_key, dst_key) = match dir {
3638                        Direction::Out => (
3639                            id,
3640                            nbr,
3641                            key.to_string(),
3642                            self.ids
3643                                .key_of(nbr)
3644                                .ok_or_else(|| GraphError::Corrupt {
3645                                    detail: format!("topology id {nbr} has no key"),
3646                                })?
3647                                .to_string(),
3648                        ),
3649                        Direction::In => (
3650                            nbr,
3651                            id,
3652                            self.ids
3653                                .key_of(nbr)
3654                                .ok_or_else(|| GraphError::Corrupt {
3655                                    detail: format!("topology id {nbr} has no key"),
3656                                })?
3657                                .to_string(),
3658                            key.to_string(),
3659                        ),
3660                    };
3661                    edges.push(EdgeInfo {
3662                        edge_type: edge_type.clone(),
3663                        src_key,
3664                        dst_key,
3665                        derived: derived.contains(&(etype, src, dst)),
3666                    });
3667                }
3668            }
3669        }
3670        edges.sort_by(|a, b| {
3671            a.edge_type
3672                .cmp(&b.edge_type)
3673                .then(a.src_key.cmp(&b.src_key))
3674                .then(a.dst_key.cmp(&b.dst_key))
3675        });
3676        // Self-loops appear in both Out and In; sort makes the pair adjacent
3677        // (sort key matches PartialEq for this case) so one pass drops the dup.
3678        edges.dedup();
3679        Ok(edges)
3680    }
3681
3682    pub fn nodes_with_label(&self, label: &str) -> Vec<NodeRef<'_, F>> {
3683        self.view()
3684            .nodes_with_label(label)
3685            .into_iter()
3686            .map(|id| NodeRef { db: self, id })
3687            .collect()
3688    }
3689
3690    pub fn find_nodes(&self, label: &str, filter: &Filter) -> Vec<NodeRef<'_, F>> {
3691        let view = self.view();
3692        view.nodes_with_label(label)
3693            .into_iter()
3694            .filter(|&id| eval_filter(filter, &|field| view.prop(id, field).cloned()))
3695            .map(|id| NodeRef { db: self, id })
3696            .collect()
3697    }
3698
3699    /// Find nodes with the given `label` whose `field` vector is most similar
3700    /// to `q` (cosine similarity), returning up to `k` results with similarity
3701    /// ≥ `min`, sorted descending.
3702    ///
3703    /// Uses the HNSW index when one is available (fast path); otherwise falls
3704    /// back to an O(n) brute-force scan over all nodes with that label (exact).
3705    pub fn find_similar_vector(
3706        &self,
3707        field: &str,
3708        label: &str,
3709        q: &[f64],
3710        k: usize,
3711        min: f64,
3712    ) -> Vec<(String, f64)> {
3713        // L2-normalise query for cosine via dot product.
3714        let norm: f64 = q.iter().map(|x| x * x).sum::<f64>().sqrt();
3715        if norm == 0.0 {
3716            return vec![];
3717        }
3718        let q_unit: Vec<f64> = q.iter().map(|x| x / norm).collect();
3719
3720        // Try HNSW fast path.
3721        if let Some(hits) = self.engine.hnsw_search_dst(field, label, &q_unit, k) {
3722            let mut out: Vec<(String, f64)> = hits
3723                .into_iter()
3724                .filter(|&(_, sim)| sim >= min)
3725                .filter_map(|(id, sim)| self.ids.key_of(id).map(|key| (key.to_string(), sim)))
3726                .collect();
3727            out.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
3728            out.truncate(k);
3729            return out;
3730        }
3731
3732        // Brute-force fallback: O(n) scan.
3733        let view = self.view();
3734        let mut scored: Vec<(String, f64)> = view
3735            .nodes_with_label(label)
3736            .into_iter()
3737            .filter_map(|id| {
3738                let v = view.prop(id, field)?;
3739                let xs = value_as_float_list(v)?;
3740                let v_norm: f64 = xs.iter().map(|x| x * x).sum::<f64>().sqrt();
3741                if v_norm == 0.0 {
3742                    return None;
3743                }
3744                let dot: f64 = q_unit
3745                    .iter()
3746                    .zip(xs.iter())
3747                    .map(|(a, b)| a * (b / v_norm))
3748                    .sum();
3749                if dot < min {
3750                    return None;
3751                }
3752                let key = self.ids.key_of(id)?.to_string();
3753                Some((key, dot))
3754            })
3755            .collect();
3756        scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
3757        scored.truncate(k);
3758        scored
3759    }
3760
3761    /// Lex → parse → plan → execute `cypher` over a read-only view.
3762    /// Every pipeline `Err(String)` becomes `GraphError::QueryError` with a
3763    /// stage prefix (`lex:` / `parse:` / `plan:` / `execute:`).
3764    pub fn query(&self, cypher: &str, params: &BTreeMap<String, Value>) -> Result<ResultSet> {
3765        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
3766            detail: format!("lex: {e}"),
3767        })?;
3768        let ast = parse(&tokens).map_err(|e| GraphError::QueryError {
3769            detail: format!("parse: {e}"),
3770        })?;
3771        let ops = plan(&ast).map_err(|e| GraphError::QueryError {
3772            detail: format!("plan: {e}"),
3773        })?;
3774        execute(&self.view(), &ops, &Params(params)).map_err(|e| GraphError::QueryError {
3775            detail: format!("execute: {e}"),
3776        })
3777    }
3778
3779    /// Convenience entry-point that accepts a slice of `(name, value)` pairs
3780    /// instead of a pre-built `BTreeMap`.  Equivalent to building the map and
3781    /// calling [`GraphDb::query`].
3782    pub fn query_with_params(&self, cypher: &str, params: &[(&str, Value)]) -> Result<ResultSet> {
3783        let map: BTreeMap<String, Value> = params
3784            .iter()
3785            .map(|(k, v)| (k.to_string(), v.clone()))
3786            .collect();
3787        self.query(cypher, &map)
3788    }
3789
3790    /// Execute a Cypher write statement (CREATE / MATCH…SET / MATCH…DELETE / MERGE).
3791    ///
3792    /// All mutations flow through the same `insert_node` / `set_prop` /
3793    /// `delete_edge` / `insert_edge` path as the Rust API so the rule engine
3794    /// fires and the WAL captures everything with one fsync per statement.
3795    ///
3796    /// Returns a one-row [`ResultSet`] with columns `created`, `properties_set`,
3797    /// and `deleted` matching the write-result contract.
3798    ///
3799    /// **Mutation routing**: mutations are collected into a single
3800    /// [`BatchBuilder`] and committed atomically (one WAL `Batch` frame, one
3801    /// fsync). The MATCH phase for SET/DELETE uses a read-only `execute` call
3802    /// over `self.view()` — the borrow is dropped before the batch is opened.
3803    ///
3804    /// **Limitations (v1)**:
3805    /// - SET RHS must be a literal, `$param`, or arithmetic; bare property copy → named error.
3806    /// - `DETACH DELETE n` → calls `delete_node` for each matched node (removes all edges).
3807    /// - Bare `DELETE n` → error if n has any incident edges; succeeds for isolated nodes.
3808    /// - MERGE supports `ON CREATE SET` / `ON MATCH SET` in the same write batch.
3809    /// - Deleting a derived edge → named error "cannot delete derived edge".
3810    pub fn query_write(
3811        &mut self,
3812        cypher: &str,
3813        params: &BTreeMap<String, Value>,
3814    ) -> Result<ResultSet> {
3815        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
3816            detail: format!("lex: {e}"),
3817        })?;
3818        let stmt = parse_write(&tokens).map_err(|e| GraphError::QueryError {
3819            detail: format!("parse: {e}"),
3820        })?;
3821        self.exec_write_stmt(stmt, params)
3822    }
3823
3824    fn exec_write_stmt(
3825        &mut self,
3826        stmt: WriteStatement,
3827        params: &BTreeMap<String, Value>,
3828    ) -> Result<ResultSet> {
3829        match stmt {
3830            WriteStatement::Create(s) => self.exec_create(s, params),
3831            WriteStatement::MatchSet(s) => self.exec_match_set(s, params),
3832            WriteStatement::MatchDelete(s) => self.exec_match_delete(s, params),
3833            WriteStatement::MatchDeleteNode(s) => self.exec_match_delete_node(s, params),
3834            WriteStatement::Merge(s) => self.exec_merge(s, params),
3835        }
3836    }
3837
3838    fn exec_create(
3839        &mut self,
3840        stmt: core_query::cypher::CreateStmt,
3841        params: &BTreeMap<String, Value>,
3842    ) -> Result<ResultSet> {
3843        // Extract the node key from props: require a string-valued `id` field.
3844        let mut var_to_key: BTreeMap<String, String> = BTreeMap::new();
3845        for node in &stmt.nodes {
3846            let var = node.var.as_deref().unwrap_or("_cn0");
3847            let key = node
3848                .props
3849                .iter()
3850                .find(|(f, _)| f == "id")
3851                .and_then(|(_, v)| {
3852                    if let Value::Str(s) = v {
3853                        Some(s.clone())
3854                    } else {
3855                        None
3856                    }
3857                })
3858                .ok_or_else(|| GraphError::QueryError {
3859                    detail: format!(
3860                        "CREATE node ({}:{}) requires a string 'id' property",
3861                        var, node.label
3862                    ),
3863                })?;
3864            var_to_key.insert(var.to_string(), key);
3865        }
3866
3867        let mut batch = self.batch();
3868        let mut created: usize = 0;
3869        for node in &stmt.nodes {
3870            let var = node.var.as_deref().unwrap_or("_cn0");
3871            let key = &var_to_key[var];
3872            batch.insert_node(&node.label, key, node.props.clone());
3873            created += 1;
3874        }
3875        for edge in &stmt.edges {
3876            let src_key = var_to_key
3877                .get(&edge.src_var)
3878                .ok_or_else(|| GraphError::QueryError {
3879                    detail: format!("CREATE edge src variable '{}' is not bound", edge.src_var),
3880                })?;
3881            let dst_key = var_to_key
3882                .get(&edge.dst_var)
3883                .ok_or_else(|| GraphError::QueryError {
3884                    detail: format!("CREATE edge dst variable '{}' is not bound", edge.dst_var),
3885                })?;
3886            batch.insert_edge(&edge.etype, src_key, dst_key);
3887        }
3888        batch.commit()?;
3889
3890        // Optional RETURN clause: project created bindings as a read result.
3891        if let Some(returns) = stmt.returns {
3892            // Each created node is looked up by its key via a separate MATCH pattern.
3893            // Multiple single-node patterns cross-join to produce 1 output row with
3894            // all variables bound (each pattern returns exactly 1 row).
3895            let patterns: Vec<Pattern> = stmt
3896                .nodes
3897                .iter()
3898                .map(|node| {
3899                    let var = node.var.as_deref().unwrap_or("_cn0");
3900                    let key = var_to_key[var].clone();
3901                    Pattern {
3902                        start: NodePat {
3903                            var: Some(var.to_string()),
3904                            label: Some(node.label.clone()),
3905                            props: vec![("id".to_string(), Operand::Lit(Value::Str(key)))],
3906                        },
3907                        chain: vec![],
3908                        shortest: false,
3909                    }
3910                })
3911                .collect();
3912            let q = Query {
3913                matches: patterns,
3914                optional_clauses: vec![],
3915                where_expr: None,
3916                unwinds: vec![],
3917                post_unwind_where: None,
3918                stages: vec![],
3919                returns,
3920                distinct: false,
3921                order_by: vec![],
3922                skip: None,
3923                limit: None,
3924            };
3925            let ops = plan(&q).map_err(|e| GraphError::QueryError {
3926                detail: format!("plan: {e}"),
3927            })?;
3928            return execute(&self.view(), &ops, &Params(params)).map_err(|e| {
3929                GraphError::QueryError {
3930                    detail: format!("execute: {e}"),
3931                }
3932            });
3933        }
3934
3935        let mut rs = write_result_set();
3936        rs.push_row(vec![
3937            Some(Value::Int(created as i64)),
3938            Some(Value::Int(0)),
3939            Some(Value::Int(0)),
3940        ]);
3941        Ok(rs)
3942    }
3943
3944    fn exec_match_set(
3945        &mut self,
3946        stmt: core_query::cypher::MatchSetStmt,
3947        params: &BTreeMap<String, Value>,
3948    ) -> Result<ResultSet> {
3949        let project_returns = stmt.returns.clone();
3950        // Collect unique node vars targeted by SET clauses, plus RETURN bindings
3951        // so the post-write projection can look them up by key.
3952        let mut set_vars: Vec<String> = Vec::new();
3953        for s in &stmt.sets {
3954            if !set_vars.contains(&s.var) {
3955                set_vars.push(s.var.clone());
3956            }
3957        }
3958        let rel_vars = pattern_rel_vars(&stmt.matches);
3959        let mut lookup_vars = set_vars.clone();
3960        for v in pattern_node_vars(&stmt.matches) {
3961            add_var(&mut lookup_vars, &v);
3962        }
3963        if let Some(ref returns) = project_returns {
3964            for v in ret_node_vars(returns) {
3965                if !rel_vars.iter().any(|r| r == &v) {
3966                    add_var(&mut lookup_vars, &v);
3967                }
3968            }
3969        }
3970
3971        // Synthesize a read query: MATCH … WHERE … RETURN <lookup_vars>, <set_values…>
3972        // SET values are projected as ScalarExpr items so that arithmetic expressions
3973        // (e.g. `SET n.score = n.score * 1.5`) are evaluated in the matched-row context.
3974        let mut set_returns: Vec<RetItem> = lookup_vars
3975            .iter()
3976            .map(|v| RetItem {
3977                value: RetVal::Var(v.clone()),
3978                alias: None,
3979            })
3980            .collect();
3981        // One computed column per SET clause; alias is `__sv_<i>`.
3982        let set_val_cols: Vec<String> = stmt
3983            .sets
3984            .iter()
3985            .enumerate()
3986            .map(|(i, _)| format!("__sv_{i}"))
3987            .collect();
3988        for (sc, col) in stmt.sets.iter().zip(&set_val_cols) {
3989            set_returns.push(RetItem {
3990                value: RetVal::ScalarExpr(sc.value.clone()),
3991                alias: Some(col.clone()),
3992            });
3993        }
3994        // Capture relationship types while r is bound; SET does not change them.
3995        for r in &rel_vars {
3996            set_returns.push(RetItem {
3997                value: RetVal::FuncCall {
3998                    name: "type".into(),
3999                    args: vec![Operand::Var(r.clone())],
4000                },
4001                alias: Some(rel_type_alias(r)),
4002            });
4003        }
4004
4005        let read_q = Query {
4006            matches: stmt.matches.clone(),
4007            optional_clauses: vec![],
4008            where_expr: stmt.where_expr.clone(),
4009            unwinds: vec![],
4010            post_unwind_where: None,
4011            stages: vec![],
4012            returns: set_returns,
4013            distinct: false,
4014            order_by: vec![],
4015            skip: None,
4016            limit: None,
4017        };
4018        let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
4019            detail: format!("plan: {e}"),
4020        })?;
4021        // MATCH phase is read-only; borrow ends before batch opens.
4022        let match_rs =
4023            execute(&self.view(), &ops, &Params(params)).map_err(|e| GraphError::QueryError {
4024                detail: format!("execute: {e}"),
4025            })?;
4026
4027        // Collect (key, field, value) for each matched row × each SET clause.
4028        let mut set_ops: Vec<(String, String, Value)> = Vec::new();
4029        for row_i in 0..match_rs.len() {
4030            for (sc, col) in stmt.sets.iter().zip(&set_val_cols) {
4031                let key = match match_rs.get(row_i, &sc.var) {
4032                    Some(Value::Str(k)) => k.clone(),
4033                    _ => {
4034                        return Err(GraphError::QueryError {
4035                            detail: format!(
4036                                "SET variable '{}' did not resolve to a node key",
4037                                sc.var
4038                            ),
4039                        })
4040                    }
4041                };
4042                // The SET value was already evaluated by the executor.
4043                let value = match match_rs.get(row_i, col) {
4044                    Some(v) => v.clone(),
4045                    None => {
4046                        return Err(GraphError::QueryError {
4047                            detail: format!(
4048                                "SET value for {}.{} evaluated to null",
4049                                sc.var, sc.field
4050                            ),
4051                        })
4052                    }
4053                };
4054                set_ops.push((key, sc.field.clone(), value));
4055            }
4056        }
4057
4058        // Apply as one atomic batch.
4059        let props_set = set_ops.len();
4060        let mut batch = self.batch();
4061        for (key, field, value) in set_ops {
4062            batch.set_prop(&key, &field, value);
4063        }
4064        batch.commit()?;
4065
4066        if let Some(returns) = project_returns {
4067            return project_set_return_rows(self, &rel_vars, &match_rs, &returns, params);
4068        }
4069
4070        let mut rs = write_result_set();
4071        rs.push_row(vec![
4072            Some(Value::Int(0)),
4073            Some(Value::Int(props_set as i64)),
4074            Some(Value::Int(0)),
4075        ]);
4076        Ok(rs)
4077    }
4078
4079    fn exec_match_delete(
4080        &mut self,
4081        stmt: core_query::cypher::MatchDeleteStmt,
4082        params: &BTreeMap<String, Value>,
4083    ) -> Result<ResultSet> {
4084        // Collect unique node vars needed to identify edge endpoints.
4085        let mut node_vars: Vec<String> = Vec::new();
4086        for ed in &stmt.deletes {
4087            if !node_vars.contains(&ed.src_var) {
4088                node_vars.push(ed.src_var.clone());
4089            }
4090            if !node_vars.contains(&ed.dst_var) {
4091                node_vars.push(ed.dst_var.clone());
4092            }
4093        }
4094
4095        // Synthesize read query.
4096        let returns: Vec<RetItem> = node_vars
4097            .iter()
4098            .map(|v| RetItem {
4099                value: RetVal::Var(v.clone()),
4100                alias: None,
4101            })
4102            .collect();
4103        let read_q = Query {
4104            matches: stmt.matches,
4105            optional_clauses: vec![],
4106            where_expr: stmt.where_expr,
4107            unwinds: vec![],
4108            post_unwind_where: None,
4109            stages: vec![],
4110            returns,
4111            distinct: false,
4112            order_by: vec![],
4113            skip: None,
4114            limit: None,
4115        };
4116        let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
4117            detail: format!("plan: {e}"),
4118        })?;
4119        let match_rs =
4120            execute(&self.view(), &ops, &Params(params)).map_err(|e| GraphError::QueryError {
4121                detail: format!("execute: {e}"),
4122            })?;
4123
4124        // Collect (etype, src_key, dst_key) for each row × each delete target.
4125        let mut del_ops: Vec<(String, String, String)> = Vec::new();
4126        for row_i in 0..match_rs.len() {
4127            for ed in &stmt.deletes {
4128                let src_key = match match_rs.get(row_i, &ed.src_var) {
4129                    Some(Value::Str(k)) => k.clone(),
4130                    _ => {
4131                        return Err(GraphError::QueryError {
4132                            detail: format!(
4133                                "DELETE src variable '{}' did not resolve to a node key",
4134                                ed.src_var
4135                            ),
4136                        })
4137                    }
4138                };
4139                let dst_key = match match_rs.get(row_i, &ed.dst_var) {
4140                    Some(Value::Str(k)) => k.clone(),
4141                    _ => {
4142                        return Err(GraphError::QueryError {
4143                            detail: format!(
4144                                "DELETE dst variable '{}' did not resolve to a node key",
4145                                ed.dst_var
4146                            ),
4147                        })
4148                    }
4149                };
4150                del_ops.push((ed.etype.clone(), src_key, dst_key));
4151            }
4152        }
4153
4154        // Apply as one atomic batch.
4155        let deleted = del_ops.len();
4156        let mut batch = self.batch();
4157        for (etype, src_key, dst_key) in del_ops {
4158            batch.delete_edge(&etype, &src_key, &dst_key);
4159        }
4160        batch.commit().map_err(|e| match e {
4161            GraphError::RuleOwned { .. } => GraphError::QueryError {
4162                detail: "cannot delete derived edge; retract via the rule or change the property"
4163                    .to_string(),
4164            },
4165            other => other,
4166        })?;
4167
4168        let mut rs = write_result_set();
4169        rs.push_row(vec![
4170            Some(Value::Int(0)),
4171            Some(Value::Int(0)),
4172            Some(Value::Int(deleted as i64)),
4173        ]);
4174        Ok(rs)
4175    }
4176
4177    /// Execute `MATCH … [DETACH] DELETE <node_var> [, …]`.
4178    ///
4179    /// Collects the matching node keys via an ephemeral read query, then calls
4180    /// `delete_node` on each one.  When `stmt.detach` is `false` (bare DELETE)
4181    /// the executor first checks that the node has no incident edges; if any
4182    /// remain it returns a named error matching openCypher semantics.
4183    fn exec_match_delete_node(
4184        &mut self,
4185        stmt: MatchDeleteNodeStmt,
4186        params: &BTreeMap<String, Value>,
4187    ) -> Result<ResultSet> {
4188        // Build a read query returning only the node keys we need.
4189        let returns: Vec<RetItem> = stmt
4190            .node_vars
4191            .iter()
4192            .map(|v| RetItem {
4193                value: RetVal::Var(v.clone()),
4194                alias: None,
4195            })
4196            .collect();
4197        let read_q = Query {
4198            matches: stmt.matches,
4199            optional_clauses: vec![],
4200            where_expr: stmt.where_expr,
4201            unwinds: vec![],
4202            post_unwind_where: None,
4203            stages: vec![],
4204            returns,
4205            distinct: false,
4206            order_by: vec![],
4207            skip: None,
4208            limit: None,
4209        };
4210        let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
4211            detail: format!("plan: {e}"),
4212        })?;
4213        let match_rs =
4214            execute(&self.view(), &ops, &Params(params)).map_err(|e| GraphError::QueryError {
4215                detail: format!("execute: {e}"),
4216            })?;
4217
4218        // Collect unique node keys to delete (deduplicate across rows × vars).
4219        let mut keys: Vec<String> = Vec::new();
4220        for row_i in 0..match_rs.len() {
4221            for var in &stmt.node_vars {
4222                if let Some(Value::Str(k)) = match_rs.get(row_i, var) {
4223                    if !keys.contains(k) {
4224                        keys.push(k.clone());
4225                    }
4226                }
4227            }
4228        }
4229
4230        if !stmt.detach {
4231            // openCypher bare DELETE: error if any matched node has incident edges.
4232            for key in &keys {
4233                if let Some(id) = self.ids.get(key) {
4234                    let has_edges = self.topo.etypes().any(|et| {
4235                        !self.topo.neighbors(et, Direction::Out, id).is_empty()
4236                            || !self.topo.neighbors(et, Direction::In, id).is_empty()
4237                    });
4238                    if has_edges {
4239                        return Err(GraphError::QueryError {
4240                            detail: format!(
4241                                "Cannot delete node `{key}` because it still has incident edges. \
4242                                 Use DETACH DELETE to remove the node and all its edges."
4243                            ),
4244                        });
4245                    }
4246                }
4247            }
4248        }
4249
4250        let mut nodes_deleted = 0i64;
4251        let mut edges_deleted = 0i64;
4252        for key in keys {
4253            match self.delete_node(&key) {
4254                Ok(report) => {
4255                    nodes_deleted += 1;
4256                    edges_deleted += (report.manual_edges + report.derived_edges) as i64;
4257                }
4258                Err(GraphError::KeyNotFound { .. }) => {
4259                    // Node may have been deleted by an earlier iteration (e.g., via
4260                    // multiple MATCH rows for the same node).  Safe to skip.
4261                }
4262                Err(e) => return Err(e),
4263            }
4264        }
4265
4266        let mut rs = write_result_set();
4267        rs.push_row(vec![
4268            Some(Value::Int(0)),
4269            Some(Value::Int(0)),
4270            Some(Value::Int(nodes_deleted + edges_deleted)),
4271        ]);
4272        Ok(rs)
4273    }
4274
4275    fn exec_merge(
4276        &mut self,
4277        stmt: core_query::cypher::MergeStmt,
4278        params: &BTreeMap<String, Value>,
4279    ) -> Result<ResultSet> {
4280        // MERGE: check if a node with the given key already exists.
4281        let key = match &stmt.key_value {
4282            Value::Str(s) => s.clone(),
4283            _ => {
4284                return Err(GraphError::QueryError {
4285                    detail: format!(
4286                        "MERGE key value must be a string (got {:?})",
4287                        stmt.key_value
4288                    ),
4289                })
4290            }
4291        };
4292
4293        if let Some(var) = stmt.var.as_deref() {
4294            for sc in stmt.on_create.iter().chain(&stmt.on_match) {
4295                if sc.var != var {
4296                    return Err(GraphError::QueryError {
4297                        detail: format!(
4298                            "SET variable '{}' does not match MERGE variable '{var}'",
4299                            sc.var
4300                        ),
4301                    });
4302                }
4303            }
4304        }
4305
4306        let existed = self.has_node(&key);
4307        let mut created = 0i64;
4308        if !existed || !stmt.on_match.is_empty() {
4309            let mut batch = self.batch();
4310            if !existed {
4311                let props = vec![(stmt.key_field.clone(), stmt.key_value.clone())];
4312                batch.insert_node(&stmt.label, &key, props);
4313                for sc in &stmt.on_create {
4314                    let value = resolve_merge_set_value(&sc.value, params)?;
4315                    batch.set_prop(&key, &sc.field, value);
4316                }
4317                created = 1;
4318            } else {
4319                for sc in &stmt.on_match {
4320                    let value = resolve_merge_set_value(&sc.value, params)?;
4321                    batch.set_prop(&key, &sc.field, value);
4322                }
4323            }
4324            batch.commit()?;
4325        }
4326
4327        // Optional RETURN clause: project the node (created or matched) as a read result.
4328        if let Some(returns) = stmt.returns {
4329            let var = stmt.var.as_deref().unwrap_or("_mn0");
4330            let q = Query {
4331                matches: vec![Pattern {
4332                    start: NodePat {
4333                        var: Some(var.to_string()),
4334                        label: Some(stmt.label.clone()),
4335                        props: vec![("id".to_string(), Operand::Lit(stmt.key_value.clone()))],
4336                    },
4337                    chain: vec![],
4338                    shortest: false,
4339                }],
4340                optional_clauses: vec![],
4341                where_expr: None,
4342                unwinds: vec![],
4343                post_unwind_where: None,
4344                stages: vec![],
4345                returns,
4346                distinct: false,
4347                order_by: vec![],
4348                skip: None,
4349                limit: None,
4350            };
4351            let ops = plan(&q).map_err(|e| GraphError::QueryError {
4352                detail: format!("plan: {e}"),
4353            })?;
4354            return execute(&self.view(), &ops, &Params(params)).map_err(|e| {
4355                GraphError::QueryError {
4356                    detail: format!("execute: {e}"),
4357                }
4358            });
4359        }
4360
4361        let mut rs = write_result_set();
4362        rs.push_row(vec![
4363            Some(Value::Int(created)),
4364            Some(Value::Int(0)),
4365            Some(Value::Int(0)),
4366        ]);
4367        Ok(rs)
4368    }
4369
4370    /// Return all rule-owned edges between `key_a` and `key_b` (either direction),
4371    /// annotated with rule name, edge type, direction, and weight.
4372    /// Results are sorted by (rule, edge_type).
4373    /// Returns `Err(KeyNotFound)` if either key is unknown.
4374    pub fn explain(&self, key_a: &str, key_b: &str) -> Result<Vec<Explanation>> {
4375        let id_a = self
4376            .ids
4377            .get(key_a)
4378            .ok_or_else(|| GraphError::KeyNotFound { key: key_a.into() })?;
4379        let id_b = self
4380            .ids
4381            .get(key_b)
4382            .ok_or_else(|| GraphError::KeyNotFound { key: key_b.into() })?;
4383
4384        let mut results = Vec::new();
4385
4386        // Walk the smaller incident set so explain is O(min(deg(a), deg(b)))
4387        // rather than O(total provenance).
4388        let scan = if self.engine.provenance_touching_len(id_a)
4389            <= self.engine.provenance_touching_len(id_b)
4390        {
4391            id_a
4392        } else {
4393            id_b
4394        };
4395        for (rule_name, etype, src, dst) in self.engine.provenance_touching(scan) {
4396            if !((src == id_a && dst == id_b) || (src == id_b && dst == id_a)) {
4397                continue;
4398            }
4399            let Some(rule_def) = self.engine.rules().find(|r| r.name == rule_name) else {
4400                continue;
4401            };
4402            let edge_type = match self.syms.resolve(etype) {
4403                Some(s) => s.to_string(),
4404                None => continue,
4405            };
4406            let src_key = self
4407                .ids
4408                .key_of(src)
4409                .expect("provenance ids always resolvable")
4410                .to_string();
4411            let dst_key = self
4412                .ids
4413                .key_of(dst)
4414                .expect("provenance ids always resolvable")
4415                .to_string();
4416            let weight = rule_def.weight_prop.as_deref().and_then(|prop| {
4417                self.edge_props.get(etype, src, dst, prop).and_then(|v| {
4418                    if let Value::Float(f) = v {
4419                        Some(*f)
4420                    } else {
4421                        None
4422                    }
4423                })
4424            });
4425            results.push(Explanation {
4426                rule: rule_name.to_string(),
4427                edge_type,
4428                src_key,
4429                dst_key,
4430                weight,
4431                predicate: PredicateSummary {
4432                    approximate: rule_def.approximate,
4433                    ..PredicateSummary::from(&rule_def.predicate)
4434                },
4435            });
4436        }
4437
4438        results.sort_by(|a, b| a.rule.cmp(&b.rule).then(a.edge_type.cmp(&b.edge_type)));
4439        Ok(results)
4440    }
4441
4442    pub fn neighbors(&self, key: &str, edge_type: &str, dir: Direction) -> Result<Vec<String>> {
4443        let id = self
4444            .ids
4445            .get(key)
4446            .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
4447        let Some(sym) = self.syms.get(edge_type) else {
4448            return Ok(Vec::new());
4449        };
4450        self.topo
4451            .neighbors(sym, dir, id)
4452            .iter()
4453            .map(|&n| {
4454                self.ids
4455                    .key_of(n)
4456                    .map(|k| k.to_string())
4457                    .ok_or_else(|| GraphError::Corrupt {
4458                        detail: format!("topology id {n} has no key"),
4459                    })
4460            })
4461            .collect::<Result<Vec<_>>>()
4462    }
4463
4464    pub fn node_count(&self) -> usize {
4465        self.ids.len()
4466    }
4467
4468    /// Return the per-node change history for `key` by scanning the on-disk WAL.
4469    ///
4470    /// ## Horizon
4471    ///
4472    /// History reaches back only to the last WAL-truncating snapshot, exactly like `open_at`.
4473    /// Snapshots written with `keep_wal: true` preserve deeper history. This is the honest,
4474    /// zero-cost contract; a durable history log is out of scope.
4475    ///
4476    /// ## Derived edges
4477    ///
4478    /// Rule-created (derived) edges are **not** in the WAL and therefore do not appear in
4479    /// history. Only edges written directly by the application are recorded.
4480    ///
4481    /// ## Deleted nodes
4482    ///
4483    /// For nodes that have been deleted, dense-id records (SetPropId, InsertEdgeId) that
4484    /// predate the deletion may not resolve (the id is tombstoned in the live map). The
4485    /// string-keyed `DeleteNode` record still matches and produces a `NodeDeleted` entry.
4486    /// Prop/edge history of a deleted node may therefore be partially unresolvable.
4487    ///
4488    /// ## Dense-id edge entries and tombstoned partners
4489    ///
4490    /// Edge entries from dense-id WAL records (`InsertEdgeId`) are omitted when the partner
4491    /// endpoint's dense id is tombstoned. As a result, a live node's history can contain an
4492    /// `EdgeRemoved` (string-keyed, always resolves) without a corresponding `EdgeAdded`.
4493    pub fn node_history(&self, key: &str) -> Result<Vec<crate::history::HistoryEntry>> {
4494        use crate::history::{HistoryChange, HistoryEntry};
4495        use core_storage::wal::WalRecord;
4496
4497        let bytes = self.fs.read(FileId::Wal)?;
4498        let (frames, _) = decode_all(&bytes);
4499
4500        let mut out: Vec<HistoryEntry> = Vec::new();
4501
4502        for (commit, frame) in frames.iter().enumerate() {
4503            let commit = commit as u64;
4504            // Collect the inner records to process — Batch is one commit, single records are one commit.
4505            let records: &[WalRecord] = match frame {
4506                WalRecord::Batch(inner) => inner.as_slice(),
4507                single => std::slice::from_ref(single),
4508            };
4509
4510            for rec in records {
4511                let change = match rec {
4512                    WalRecord::InsertNode { label, key: k, .. } if k == key => {
4513                        Some(HistoryChange::NodeInserted {
4514                            label: label.clone(),
4515                        })
4516                    }
4517                    WalRecord::InsertNodeId { label, key: k, .. } if k == key => {
4518                        let label_str = match self.syms.resolve(*label) {
4519                            Some(s) => s.to_string(),
4520                            None => continue,
4521                        };
4522                        Some(HistoryChange::NodeInserted { label: label_str })
4523                    }
4524                    WalRecord::SetProp {
4525                        key: k,
4526                        field,
4527                        value,
4528                    } if k == key => Some(HistoryChange::PropSet {
4529                        field: field.clone(),
4530                        value: value.clone(),
4531                    }),
4532                    WalRecord::SetPropId { id, field, value } => match self.ids.key_of(*id) {
4533                        Some(resolved) if resolved == key => {
4534                            let field_str = match self.syms.resolve(*field) {
4535                                Some(s) => s.to_string(),
4536                                None => continue,
4537                            };
4538                            Some(HistoryChange::PropSet {
4539                                field: field_str,
4540                                value: value.clone(),
4541                            })
4542                        }
4543                        _ => None,
4544                    },
4545                    WalRecord::RemoveProp { key: k, field } if k == key => {
4546                        Some(HistoryChange::PropRemoved {
4547                            field: field.clone(),
4548                        })
4549                    }
4550                    WalRecord::InsertEdge {
4551                        edge_type,
4552                        src_key,
4553                        dst_key,
4554                    } => {
4555                        if src_key == key {
4556                            Some(HistoryChange::EdgeAdded {
4557                                edge_type: edge_type.clone(),
4558                                other: dst_key.clone(),
4559                                outgoing: true,
4560                            })
4561                        } else if dst_key == key {
4562                            Some(HistoryChange::EdgeAdded {
4563                                edge_type: edge_type.clone(),
4564                                other: src_key.clone(),
4565                                outgoing: false,
4566                            })
4567                        } else {
4568                            None
4569                        }
4570                    }
4571                    WalRecord::InsertEdgeId { etype, src, dst } => {
4572                        let etype_str = match self.syms.resolve(*etype) {
4573                            Some(s) => s.to_string(),
4574                            None => continue,
4575                        };
4576                        let src_key = self.ids.key_of(*src);
4577                        let dst_key = self.ids.key_of(*dst);
4578                        if src_key == Some(key) {
4579                            let other = match dst_key {
4580                                Some(s) => s.to_string(),
4581                                None => continue,
4582                            };
4583                            Some(HistoryChange::EdgeAdded {
4584                                edge_type: etype_str,
4585                                other,
4586                                outgoing: true,
4587                            })
4588                        } else if dst_key == Some(key) {
4589                            let other = match src_key {
4590                                Some(s) => s.to_string(),
4591                                None => continue,
4592                            };
4593                            Some(HistoryChange::EdgeAdded {
4594                                edge_type: etype_str,
4595                                other,
4596                                outgoing: false,
4597                            })
4598                        } else {
4599                            None
4600                        }
4601                    }
4602                    WalRecord::DeleteEdge {
4603                        edge_type,
4604                        src_key,
4605                        dst_key,
4606                    } => {
4607                        if src_key == key {
4608                            Some(HistoryChange::EdgeRemoved {
4609                                edge_type: edge_type.clone(),
4610                                other: dst_key.clone(),
4611                                outgoing: true,
4612                            })
4613                        } else if dst_key == key {
4614                            Some(HistoryChange::EdgeRemoved {
4615                                edge_type: edge_type.clone(),
4616                                other: src_key.clone(),
4617                                outgoing: false,
4618                            })
4619                        } else {
4620                            None
4621                        }
4622                    }
4623                    WalRecord::DeleteNode { key: k } if k == key => {
4624                        Some(HistoryChange::NodeDeleted)
4625                    }
4626                    // Skip: rule/view/fulltext/intern metadata; Batch wrapper handled above.
4627                    _ => None,
4628                };
4629
4630                if let Some(change) = change {
4631                    out.push(HistoryEntry { commit, change });
4632                }
4633            }
4634        }
4635
4636        Ok(out)
4637    }
4638
4639    pub fn edge_count(&self) -> u64 {
4640        self.topo.edge_count()
4641    }
4642
4643    /// Live/tombstone/edge counts plus per-rule provenance size, trip latch,
4644    /// and fire counter (includes rebuild evaluations). Rules are sorted by name.
4645    pub fn stats(&self) -> Stats {
4646        let rules: Vec<RuleStats> = self
4647            .engine
4648            .rules()
4649            .map(|r| RuleStats {
4650                name: r.name.clone(),
4651                edges: self
4652                    .engine
4653                    .provenance()
4654                    .get(&r.name)
4655                    .map(|s| s.len() as u64)
4656                    .unwrap_or(0),
4657                tripped: self.engine.is_tripped(&r.name),
4658                fires: self.engine.fire_count(&r.name),
4659                approximate: r.approximate,
4660            })
4661            .collect();
4662        Stats {
4663            nodes_live: self.ids.live_len(),
4664            nodes_tombstoned: self.ids.len() - self.ids.live_len(),
4665            edges: self.topo.edge_count(),
4666            rules,
4667        }
4668    }
4669
4670    /// On-disk snapshot format version this binary writes and reads.
4671    pub fn format_version() -> u16 {
4672        core_storage::snapshot::VERSION
4673    }
4674
4675    /// Test-support: total bytes appended (SimFs only usage).
4676    pub fn fs_total_appended(&self) -> usize
4677    where
4678        F: FsIntrospect,
4679    {
4680        self.fs.total_appended()
4681    }
4682
4683    /// Test-support: successful `Fs::sync` calls (SimFs / counting fs).
4684    pub fn fs_sync_count(&self) -> usize
4685    where
4686        F: FsIntrospect,
4687    {
4688        self.fs.sync_count()
4689    }
4690
4691    /// Consume the db, returning its fs (for crash simulation).
4692    pub fn into_fs(self) -> F {
4693        self.fs
4694    }
4695
4696    pub fn snapshot(&mut self) -> Result<()> {
4697        self.snapshot_with(SnapshotOptions::default())
4698    }
4699
4700    /// Snapshot with explicit options.
4701    ///
4702    /// # `keep_wal`
4703    ///
4704    /// When `keep_wal` is `false` (the default, same as [`snapshot`]):
4705    ///   - The WAL is replaced with a minimal baseline containing one
4706    ///     `EnableFulltext` record per active declaration.  All pre-snapshot
4707    ///     history is discarded; `open_at` can only reach post-snapshot commits.
4708    ///
4709    /// When `keep_wal` is `true`:
4710    ///   - The WAL is left intact.  All pre-snapshot commits remain reachable
4711    ///     via `open_at`.  The existing WAL already contains the original
4712    ///     `EnableFulltext` records, so no baseline re-write is needed; the
4713    ///     recovery guards in `apply()` silently skip any duplicate records on
4714    ///     replay.
4715    ///   - Crash window: a crash after the snapshot write but before the next
4716    ///     WAL write leaves the full pre-snapshot WAL intact.  On reopen the
4717    ///     snapshot is loaded and the WAL replayed idempotently over it — safe
4718    ///     because every `apply()` arm is idempotent when replayed over an
4719    ///     already-current snapshot.
4720    pub fn snapshot_with(&mut self, opts: SnapshotOptions) -> Result<()> {
4721        if self.read_only {
4722            return Err(GraphError::ReadOnly);
4723        }
4724        let (rule_defs_typed, provenance, rule_tripped, rule_fires) = self.engine.to_persist();
4725        let rule_defs = rule_defs_typed
4726            .iter()
4727            .map(|r| bincode::serialize(r).expect("RuleDef serialize cannot fail"))
4728            .collect();
4729        // Collect IVF state for approximate rules (V4).
4730        let hnsw_state = self.engine.export_hnsw_state();
4731        let raw_ivf = self.engine.export_ivf_state();
4732        let ivf_state: BTreeMap<String, core_storage::snapshot::PerRuleIvfState> = raw_ivf
4733            .into_iter()
4734            .map(|(name, ((sc, sa, sd), (dc, da, dd)))| {
4735                (
4736                    name,
4737                    core_storage::snapshot::PerRuleIvfState {
4738                        src: core_storage::snapshot::SideIvfState {
4739                            centroids: sc,
4740                            clusters: sa,
4741                            drift: sd,
4742                        },
4743                        dst: core_storage::snapshot::SideIvfState {
4744                            centroids: dc,
4745                            clusters: da,
4746                            drift: dd,
4747                        },
4748                    },
4749                )
4750            })
4751            .collect();
4752        let view_defs: Vec<Vec<u8>> = self
4753            .view_store
4754            .views()
4755            .map(|v| bincode::serialize(v).expect("ViewDef serialize cannot fail"))
4756            .collect();
4757        let state = core_storage::snapshot::SnapshotState {
4758            ids: self.ids.clone(),
4759            syms: self.syms.clone(),
4760            topo: self.topo.clone(),
4761            props: self.props.clone(),
4762            labels: self.labels.clone(),
4763            edge_props: self.edge_props.clone(),
4764            rule_defs,
4765            provenance,
4766            rule_tripped,
4767            rule_fires,
4768            ivf_state,
4769            hnsw_state,
4770            view_defs,
4771            wal_truncated: !opts.keep_wal,
4772        };
4773        self.fs
4774            .write_atomic(FileId::Snapshot, &core_storage::snapshot::encode(&state)?)?;
4775
4776        if opts.keep_wal {
4777            // keep_wal=true: WAL is left untouched.  The existing WAL already
4778            // contains the EnableFulltext records from the original enable calls;
4779            // replay is idempotent (guards in apply() skip already-live entries).
4780            // No baseline re-write is needed or safe here — the full WAL history
4781            // must remain intact for open_at to reach pre-snapshot commits.
4782        } else {
4783            // keep_wal=false (default): truncate by replacing the WAL with a
4784            // minimal baseline of one EnableFulltext record per active pair.
4785            //
4786            // Crash-ordering: write_atomic is atomic.
4787            //   • Crash before snapshot write  → WAL unchanged.  Safe.
4788            //   • Crash after snapshot write but before this WAL write → full
4789            //     pre-snapshot WAL still present; open_with replays idempotently.
4790            //   • Crash after both writes → normal post-snapshot state.
4791            let mut baseline_wal: Vec<u8> = Vec::new();
4792            for (label, field) in self.fulltext.enabled_pairs() {
4793                let rec = WalRecord::EnableFulltext {
4794                    label: label.clone(),
4795                    field: field.clone(),
4796                };
4797                baseline_wal.extend_from_slice(&encode_record(&rec));
4798            }
4799            self.fs.write_atomic(FileId::Wal, &baseline_wal)?;
4800        }
4801        Ok(())
4802    }
4803}
4804
4805/// Queued mutation for a [`BatchBuilder`].
4806enum BatchOp {
4807    InsertNode {
4808        label: String,
4809        key: String,
4810        props: Vec<(String, Value)>,
4811    },
4812    InsertEdge {
4813        edge_type: String,
4814        src_key: String,
4815        dst_key: String,
4816    },
4817    SetProp {
4818        key: String,
4819        field: String,
4820        value: Value,
4821    },
4822    RemoveProp {
4823        key: String,
4824        field: String,
4825    },
4826    DeleteEdge {
4827        edge_type: String,
4828        src_key: String,
4829        dst_key: String,
4830    },
4831    DeleteNode {
4832        key: String,
4833    },
4834    CreateRule(RuleDef),
4835    DeleteRule {
4836        name: String,
4837    },
4838}
4839
4840/// Overlay of ops already accepted earlier in the same batch. Never written
4841/// back to the database — validation only.
4842#[derive(Default)]
4843struct Overlay {
4844    extra_keys: BTreeSet<String>,
4845    deleted_keys: BTreeSet<String>,
4846    extra_props: BTreeMap<(String, String), Value>,
4847    removed_props: BTreeSet<(String, String)>,
4848    extra_edges: BTreeSet<(String, String, String)>,
4849    deleted_edges: BTreeSet<(String, String, String)>,
4850    extra_rules: BTreeSet<String>,
4851    deleted_rules: BTreeSet<String>,
4852}
4853
4854/// Read-only view of live db state plus a batch overlay. Shared by single-op
4855/// public methods (empty overlay) and `commit_batch`.
4856struct MutPreview<'a, F: Fs> {
4857    db: &'a GraphDb<F>,
4858    overlay: Overlay,
4859}
4860
4861impl<'a, F: Fs> MutPreview<'a, F> {
4862    fn new(db: &'a GraphDb<F>) -> Self {
4863        Self {
4864            db,
4865            overlay: Overlay::default(),
4866        }
4867    }
4868
4869    fn has_key(&self, key: &str) -> bool {
4870        if self.overlay.extra_keys.contains(key) {
4871            return true;
4872        }
4873        if self.overlay.deleted_keys.contains(key) {
4874            return false;
4875        }
4876        self.db.ids.get(key).is_some()
4877    }
4878
4879    fn has_prop(&self, key: &str, field: &str) -> bool {
4880        if !self.has_key(key) {
4881            return false;
4882        }
4883        let k = (key.to_string(), field.to_string());
4884        if self.overlay.removed_props.contains(&k) {
4885            return false;
4886        }
4887        if self.overlay.extra_props.contains_key(&k) {
4888            return true;
4889        }
4890        // Fresh identity (first insert in this batch, or delete+reinsert):
4891        // ignore props still sitting on the soon-to-be-tombstoned slot.
4892        if self.overlay.extra_keys.contains(key) {
4893            return false;
4894        }
4895        self.db.get_prop(key, field).is_some()
4896    }
4897
4898    fn has_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
4899        let k = (
4900            edge_type.to_string(),
4901            src_key.to_string(),
4902            dst_key.to_string(),
4903        );
4904        if self.overlay.deleted_edges.contains(&k) {
4905            return false;
4906        }
4907        if self.overlay.extra_edges.contains(&k) {
4908            return true;
4909        }
4910        // A key created in this batch (including reinsert) has no db edges.
4911        if self.overlay.extra_keys.contains(src_key) || self.overlay.extra_keys.contains(dst_key) {
4912            return false;
4913        }
4914        if self.overlay.deleted_keys.contains(src_key)
4915            || self.overlay.deleted_keys.contains(dst_key)
4916        {
4917            return false;
4918        }
4919        let Some(src) = self.db.ids.get(src_key) else {
4920            return false;
4921        };
4922        let Some(dst) = self.db.ids.get(dst_key) else {
4923            return false;
4924        };
4925        let Some(sym) = self.db.syms.get(edge_type) else {
4926            return false;
4927        };
4928        self.db
4929            .topo
4930            .neighbors(sym, Direction::Out, src)
4931            .binary_search(&dst)
4932            .is_ok()
4933    }
4934
4935    fn has_rule(&self, name: &str) -> bool {
4936        if self.overlay.extra_rules.contains(name) {
4937            return true;
4938        }
4939        if self.overlay.deleted_rules.contains(name) {
4940            return false;
4941        }
4942        self.db.engine.rules().any(|r| r.name == name)
4943    }
4944
4945    fn is_rule_owned(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
4946        if self.overlay.extra_keys.contains(src_key) || self.overlay.extra_keys.contains(dst_key) {
4947            return false;
4948        }
4949        if self.overlay.deleted_keys.contains(src_key)
4950            || self.overlay.deleted_keys.contains(dst_key)
4951        {
4952            return false;
4953        }
4954        let Some(src) = self.db.ids.get(src_key) else {
4955            return false;
4956        };
4957        let Some(dst) = self.db.ids.get(dst_key) else {
4958            return false;
4959        };
4960        let Some(et) = self.db.syms.get(edge_type) else {
4961            return false;
4962        };
4963        // extra_rules is deliberately not consulted: a CreateRule earlier in
4964        // this batch has not fired, so it contributes no provenance. That is
4965        // the documented rule-window gap (see GraphDb::batch).
4966        if self.overlay.deleted_rules.is_empty() {
4967            return self.db.engine.is_owned(et, src, dst);
4968        }
4969        for (rule, triples) in self.db.engine.provenance() {
4970            if self.overlay.deleted_rules.contains(rule) {
4971                continue;
4972            }
4973            if triples.contains(&(et, src, dst)) {
4974                return true;
4975            }
4976        }
4977        false
4978    }
4979
4980    fn check_insert_node(&self, key: &str) -> Result<()> {
4981        if self.has_key(key) {
4982            Err(GraphError::DuplicateKey { key: key.into() })
4983        } else {
4984            Ok(())
4985        }
4986    }
4987
4988    fn check_live_key(&self, key: &str) -> Result<()> {
4989        if self.has_key(key) {
4990            Ok(())
4991        } else {
4992            Err(GraphError::KeyNotFound { key: key.into() })
4993        }
4994    }
4995
4996    fn prepare_insert_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
4997        for k in [src_key, dst_key] {
4998            if !self.has_key(k) {
4999                return Err(GraphError::KeyNotFound { key: k.into() });
5000            }
5001        }
5002        if self.is_rule_owned(edge_type, src_key, dst_key) {
5003            return Err(GraphError::RuleOwned {
5004                detail: format!("edge {edge_type} {src_key}→{dst_key} is rule-owned"),
5005            });
5006        }
5007        Ok(!self.has_edge(edge_type, src_key, dst_key))
5008    }
5009
5010    fn prepare_remove_prop(&self, key: &str, field: &str) -> Result<bool> {
5011        self.check_live_key(key)?;
5012        Ok(self.has_prop(key, field))
5013    }
5014
5015    fn prepare_delete_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
5016        for k in [src_key, dst_key] {
5017            if !self.has_key(k) {
5018                return Err(GraphError::KeyNotFound { key: k.into() });
5019            }
5020        }
5021        // Provenance-owned OR a live rule would derive this pair. User-first
5022        // edges that a later rule matches are not in `owned`, but deleting
5023        // them would leave a hole `rebuild_rule` immediately fills.
5024        if self.is_rule_owned(edge_type, src_key, dst_key) {
5025            return Err(GraphError::RuleOwned {
5026                detail: format!(
5027                    "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
5028                     delete or change the owning rule"
5029                ),
5030            });
5031        }
5032        if self.would_derive(edge_type, src_key, dst_key) {
5033            return Err(GraphError::RuleOwned {
5034                detail: format!(
5035                    "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
5036                     delete or change the owning rule, or a live rule would re-derive it"
5037                ),
5038            });
5039        }
5040        Ok(self.has_edge(edge_type, src_key, dst_key))
5041    }
5042
5043    /// True if any live rule (minus overlay-deleted names) would derive
5044    /// `(edge_type, src, dst)` from current overlay-visible props/labels.
5045    /// CreateRule names in `extra_rules` are ignored — same documented
5046    /// same-batch rule-window as [`Self::is_rule_owned`].
5047    fn would_derive(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
5048        if src_key == dst_key {
5049            return false;
5050        }
5051        let Some(src_label) = self.label_of(src_key) else {
5052            return false;
5053        };
5054        let Some(dst_label) = self.label_of(dst_key) else {
5055            return false;
5056        };
5057        for rule in self.db.engine.rules() {
5058            if self.overlay.deleted_rules.contains(&rule.name) {
5059                continue;
5060            }
5061            if rule.edge_type != edge_type {
5062                continue;
5063            }
5064            if rule.src_label != src_label || rule.dst_label != dst_label {
5065                continue;
5066            }
5067            let src_props = |f: &str| self.prop_value(src_key, f);
5068            let dst_props = |f: &str| self.prop_value(dst_key, f);
5069            let src_view = NodeView {
5070                key: src_key,
5071                props: &src_props,
5072            };
5073            let dst_view = NodeView {
5074                key: dst_key,
5075                props: &dst_props,
5076            };
5077            if evaluate(&rule.predicate, &src_view, &dst_view).is_some() {
5078                return true;
5079            }
5080        }
5081        false
5082    }
5083
5084    fn label_of(&self, key: &str) -> Option<String> {
5085        if self.overlay.deleted_keys.contains(key) {
5086            return None;
5087        }
5088        // Fresh identities created in this batch have no stored label in the
5089        // overlay; they cannot be provenance-owned yet either.
5090        let id = self.db.ids.get(key)?;
5091        let sym = self.db.labels.get(id as usize).copied()?;
5092        if sym == u32::MAX {
5093            return None;
5094        }
5095        self.db.syms.resolve(sym).map(str::to_string)
5096    }
5097
5098    fn prop_value(&self, key: &str, field: &str) -> Option<Value> {
5099        if !self.has_key(key) {
5100            return None;
5101        }
5102        let k = (key.to_string(), field.to_string());
5103        if self.overlay.removed_props.contains(&k) {
5104            return None;
5105        }
5106        if let Some(v) = self.overlay.extra_props.get(&k) {
5107            return Some(v.clone());
5108        }
5109        if self.overlay.extra_keys.contains(key) {
5110            return None;
5111        }
5112        self.db.get_prop(key, field).cloned()
5113    }
5114
5115    fn check_create_rule(&self, def: &RuleDef) -> Result<()> {
5116        def.validate()
5117            .map_err(|e| GraphError::RuleInvalid { detail: e })?;
5118        if self.has_rule(&def.name) {
5119            return Err(GraphError::RuleInvalid {
5120                detail: format!("rule {:?} already exists", def.name),
5121            });
5122        }
5123        Ok(())
5124    }
5125
5126    fn check_delete_rule(&self, name: &str) -> Result<()> {
5127        if self.has_rule(name) {
5128            Ok(())
5129        } else {
5130            Err(GraphError::RuleNotFound { name: name.into() })
5131        }
5132    }
5133
5134    fn note_insert_node(&mut self, key: &str, props: &[(String, Value)]) {
5135        self.overlay.deleted_keys.remove(key);
5136        self.overlay.extra_keys.insert(key.to_string());
5137        self.overlay.extra_props.retain(|(k, _), _| k != key);
5138        self.overlay.removed_props.retain(|(k, _)| k != key);
5139        for (field, value) in props {
5140            self.overlay
5141                .extra_props
5142                .insert((key.to_string(), field.clone()), value.clone());
5143        }
5144    }
5145
5146    fn note_insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) {
5147        let k = (
5148            edge_type.to_string(),
5149            src_key.to_string(),
5150            dst_key.to_string(),
5151        );
5152        self.overlay.deleted_edges.remove(&k);
5153        self.overlay.extra_edges.insert(k);
5154    }
5155
5156    fn note_set_prop(&mut self, key: &str, field: &str, value: &Value) {
5157        let k = (key.to_string(), field.to_string());
5158        self.overlay.removed_props.remove(&k);
5159        self.overlay.extra_props.insert(k, value.clone());
5160    }
5161
5162    fn note_remove_prop(&mut self, key: &str, field: &str) {
5163        let k = (key.to_string(), field.to_string());
5164        self.overlay.extra_props.remove(&k);
5165        self.overlay.removed_props.insert(k);
5166    }
5167
5168    fn note_delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) {
5169        let k = (
5170            edge_type.to_string(),
5171            src_key.to_string(),
5172            dst_key.to_string(),
5173        );
5174        self.overlay.extra_edges.remove(&k);
5175        self.overlay.deleted_edges.insert(k);
5176    }
5177
5178    fn note_delete_node(&mut self, key: &str) {
5179        self.overlay.extra_keys.remove(key);
5180        self.overlay.deleted_keys.insert(key.to_string());
5181        self.overlay.extra_props.retain(|(k, _), _| k != key);
5182        self.overlay.removed_props.retain(|(k, _)| k != key);
5183        self.overlay
5184            .extra_edges
5185            .retain(|(_, s, d)| s != key && d != key);
5186        self.overlay
5187            .deleted_edges
5188            .retain(|(_, s, d)| s != key && d != key);
5189    }
5190
5191    fn note_create_rule(&mut self, name: &str) {
5192        self.overlay.deleted_rules.remove(name);
5193        self.overlay.extra_rules.insert(name.to_string());
5194    }
5195
5196    fn note_delete_rule(&mut self, name: &str) {
5197        self.overlay.extra_rules.remove(name);
5198        self.overlay.deleted_rules.insert(name.to_string());
5199        // Treat the deleted rule's current provenance as gone so a later
5200        // delete_edge of those triples is a no-op (matches sequential).
5201        if let Some(triples) = self.db.engine.provenance().get(name) {
5202            for &(et, s, d) in triples {
5203                let Some(etype) = self.db.syms.resolve(et) else {
5204                    continue;
5205                };
5206                let Some(src) = self.db.ids.key_of(s) else {
5207                    continue;
5208                };
5209                let Some(dst) = self.db.ids.key_of(d) else {
5210                    continue;
5211                };
5212                let k = (etype.to_string(), src.to_string(), dst.to_string());
5213                self.overlay.extra_edges.remove(&k);
5214                self.overlay.deleted_edges.insert(k);
5215            }
5216        }
5217    }
5218}
5219
5220/// Collects mutations and commits them as one WAL `Batch` frame.
5221///
5222/// Holds `&mut GraphDb` for its lifetime. Queue with the same method names
5223/// as [`GraphDb`]; call [`commit`](Self::commit) to validate, log, and apply.
5224/// See [`GraphDb::batch`] for validation and atomicity rules.
5225pub struct BatchBuilder<'a, F: Fs> {
5226    db: &'a mut GraphDb<F>,
5227    ops: Vec<BatchOp>,
5228}
5229
5230impl<'a, F: Fs> BatchBuilder<'a, F> {
5231    pub fn insert_node(
5232        &mut self,
5233        label: &str,
5234        key: &str,
5235        props: Vec<(String, Value)>,
5236    ) -> &mut Self {
5237        self.ops.push(BatchOp::InsertNode {
5238            label: label.into(),
5239            key: key.into(),
5240            props,
5241        });
5242        self
5243    }
5244
5245    pub fn insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> &mut Self {
5246        self.ops.push(BatchOp::InsertEdge {
5247            edge_type: edge_type.into(),
5248            src_key: src_key.into(),
5249            dst_key: dst_key.into(),
5250        });
5251        self
5252    }
5253
5254    pub fn set_prop(&mut self, key: &str, field: &str, value: Value) -> &mut Self {
5255        self.ops.push(BatchOp::SetProp {
5256            key: key.into(),
5257            field: field.into(),
5258            value,
5259        });
5260        self
5261    }
5262
5263    pub fn remove_prop(&mut self, key: &str, field: &str) -> &mut Self {
5264        self.ops.push(BatchOp::RemoveProp {
5265            key: key.into(),
5266            field: field.into(),
5267        });
5268        self
5269    }
5270
5271    pub fn delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> &mut Self {
5272        self.ops.push(BatchOp::DeleteEdge {
5273            edge_type: edge_type.into(),
5274            src_key: src_key.into(),
5275            dst_key: dst_key.into(),
5276        });
5277        self
5278    }
5279
5280    pub fn delete_node(&mut self, key: &str) -> &mut Self {
5281        self.ops.push(BatchOp::DeleteNode { key: key.into() });
5282        self
5283    }
5284
5285    pub fn create_rule(&mut self, def: RuleDef) -> &mut Self {
5286        self.ops.push(BatchOp::CreateRule(def));
5287        self
5288    }
5289
5290    pub fn delete_rule(&mut self, name: &str) -> &mut Self {
5291        self.ops.push(BatchOp::DeleteRule { name: name.into() });
5292        self
5293    }
5294
5295    /// Validate every queued op, then log one `Batch` frame and apply.
5296    /// Empty / all-noop batches return `Ok(())` without writing the WAL.
5297    /// A second `commit()` after a successful one is an empty-batch no-op
5298    /// (queued ops were taken).
5299    /// Takes `&mut self` so it chains after the queue methods (`b.insert_node(..).commit()`)
5300    /// and also works as `let mut b = db.batch(); b.insert_node(..); b.commit()`.
5301    ///
5302    /// **Rule-window limitation:** batch validation cannot see edges that a
5303    /// rule created earlier in the *same* batch will derive at apply time, so
5304    /// a `delete_edge` / `insert_edge` in that window is silently no-oped
5305    /// where sequential calls would return `Err(RuleOwned)`. State integrity
5306    /// is unaffected (idempotent apply, provenance intact). Create rules in
5307    /// their own batch, or sequentially, when later ops may touch derived
5308    /// edges.
5309    /// Validate every queued op and commit atomically.
5310    ///
5311    /// Returns `(nodes_inserted, edges_inserted)` — the counts of node and edge
5312    /// WAL records actually written (duplicate edges are silent no-ops and are
5313    /// NOT counted). Both are 0 when the batch is empty or all-noop.
5314    pub fn commit(&mut self) -> Result<(usize, usize)> {
5315        let ops = std::mem::take(&mut self.ops);
5316        self.db.commit_batch(ops)
5317    }
5318
5319    /// Same as [`commit`](Self::commit) but tail the inner events with
5320    /// [`MutationEvent::Ingested`] instead of [`MutationEvent::BatchApplied`].
5321    pub(crate) fn commit_ingest(&mut self, label: &str, inserted: usize) -> Result<(usize, usize)> {
5322        let ops = std::mem::take(&mut self.ops);
5323        self.db
5324            .commit_logged_batch(ops, Some((label.to_string(), inserted)))
5325    }
5326}
5327
5328pub struct NodeRef<'a, F: Fs> {
5329    db: &'a GraphDb<F>,
5330    id: u32,
5331}
5332
5333impl<'a, F: Fs> NodeRef<'a, F> {
5334    pub fn key(&self) -> &str {
5335        self.db.ids.key_of(self.id).expect("dense ids")
5336    }
5337
5338    pub fn label(&self) -> &str {
5339        let sym = self
5340            .db
5341            .labels
5342            .get(self.id as usize)
5343            .copied()
5344            .filter(|&s| s != u32::MAX)
5345            .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
5346        self.db.syms.resolve(sym).expect("interned label symbol")
5347    }
5348
5349    pub fn prop(&self, field: &str) -> Option<&Value> {
5350        self.db.props.get(self.id, field)
5351    }
5352
5353    /// All stored fields for this node, sorted by field name.
5354    pub fn props(&self) -> BTreeMap<String, Value> {
5355        let mut out = BTreeMap::new();
5356        for field in self.db.props.fields() {
5357            if let Some(v) = self.db.props.get(self.id, field) {
5358                out.insert(field.to_string(), v.clone());
5359            }
5360        }
5361        out
5362    }
5363
5364    /// depth-N BFS as a ResultSet: columns ["key","label","depth"], BFS order.
5365    pub fn neighborhood(&self, depth: u32, edge_types: Option<&[&str]>, dir: Dir) -> ResultSet {
5366        let view = self.db.view();
5367        let resolved: Option<Vec<u32>> = edge_types.map(|names| {
5368            names
5369                .iter()
5370                .filter_map(|name| view.syms.get(name))
5371                .collect()
5372        });
5373        let nb = neighborhood(&view, self.id, depth, resolved.as_deref(), dir);
5374        let mut rs = ResultSet::new(vec!["key".into(), "label".into(), "depth".into()]);
5375        for (nid, d) in nb.nodes {
5376            let key = view.key_of(nid);
5377            let label = view
5378                .label_of(nid)
5379                .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
5380            rs.push_row(vec![
5381                Some(Value::Str(key.to_string())),
5382                Some(Value::Str(label.to_string())),
5383                Some(Value::Int(d as i64)),
5384            ]);
5385        }
5386        rs
5387    }
5388
5389    /// 1-hop, Both directions: edge-type name → sorted unique neighbor keys.
5390    pub fn grouped_by_edge_type(&self) -> BTreeMap<String, Vec<String>> {
5391        let view = self.db.view();
5392        let mut groups: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
5393        for e in expand(&view, self.id, None, Dir::Both) {
5394            let etype = view
5395                .syms
5396                .resolve(e.etype)
5397                .expect("topology etype is interned")
5398                .to_string();
5399            let nbr = if e.src == self.id { e.dst } else { e.src };
5400            groups
5401                .entry(etype)
5402                .or_default()
5403                .insert(view.key_of(nbr).to_string());
5404        }
5405        groups
5406            .into_iter()
5407            .map(|(k, v)| (k, v.into_iter().collect()))
5408            .collect()
5409    }
5410}
5411
5412#[cfg(test)]
5413mod tests {
5414    use super::*;
5415    use core_rules::Predicate;
5416
5417    fn tmp_dir(name: &str) -> std::path::PathBuf {
5418        let d =
5419            std::env::temp_dir().join(format!("graphdb-db-unit-{}-{}", name, std::process::id()));
5420        let _ = std::fs::remove_dir_all(&d);
5421        d
5422    }
5423
5424    fn fk_rule() -> RuleDef {
5425        RuleDef {
5426            name: "works_at".into(),
5427            src_label: "Person".into(),
5428            dst_label: "Org".into(),
5429            predicate: Predicate::KeyMatch {
5430                field: "org_id".into(),
5431            },
5432            edge_type: "WORKS_AT".into(),
5433            weight_prop: None,
5434            max_edges: None,
5435            approximate: false,
5436            via_label: None,
5437            via_edge: None,
5438            via_dir: None,
5439        }
5440    }
5441
5442    /// Regression guard for the no-views delta-copy fast path.
5443    ///
5444    /// When no views are defined, `pending_deltas_since().to_vec()` must never
5445    /// be called — even during a large CreateRule backfill. The DELTA_COPY_COUNT
5446    /// thread-local is incremented inside every `if !view_store.is_empty()` block;
5447    /// a count of 0 after the entire sequence proves the guard fires correctly.
5448    #[test]
5449    fn no_delta_copy_when_no_views() {
5450        DELTA_COPY_COUNT.with(|c| c.set(0));
5451        let dir = tmp_dir("no-delta-copy");
5452        {
5453            let mut db = GraphDb::open(&dir).unwrap();
5454            // Insert 50 Org + 50 Person nodes with FK links.
5455            for i in 0..50u32 {
5456                db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
5457            }
5458            for i in 0..50u32 {
5459                db.insert_node(
5460                    "Person",
5461                    &format!("p{i}"),
5462                    vec![("org_id".into(), Value::Str(format!("o{i}")))],
5463                )
5464                .unwrap();
5465            }
5466            // CreateRule backfill should NOT invoke to_vec() when no views are defined.
5467            db.create_rule(fk_rule()).unwrap();
5468
5469            // Counter must stay 0 — no views, no copies.
5470            let copies = DELTA_COPY_COUNT.with(|c| c.get());
5471            assert_eq!(
5472                copies, 0,
5473                "pending_deltas_since().to_vec() called despite no views"
5474            );
5475
5476            // Derived edges must still be correct (the guard skips only the
5477            // empty delta propagation loop, not the rule application itself).
5478            let nbrs = db.neighbors("p0", "WORKS_AT", Direction::Out).unwrap();
5479            assert_eq!(
5480                nbrs,
5481                vec!["o0"],
5482                "rule must derive edges even with no views"
5483            );
5484        }
5485        let _ = std::fs::remove_dir_all(&dir);
5486    }
5487
5488    /// Gating regression: subscribe AFTER a backfill must see no stale events.
5489    /// subscribe BEFORE a backfill must see every edge-fire event.
5490    #[test]
5491    fn subscribe_after_backfill_no_stale_events() {
5492        let dir = tmp_dir("sub-after-backfill");
5493        {
5494            let mut db = GraphDb::open(&dir).unwrap();
5495            for i in 0..10u32 {
5496                db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
5497                db.insert_node(
5498                    "Person",
5499                    &format!("p{i}"),
5500                    vec![("org_id".into(), Value::Str(format!("o{i}")))],
5501                )
5502                .unwrap();
5503            }
5504            // Create rule BEFORE subscribing — emit_deltas is false during backfill.
5505            db.create_rule(fk_rule()).unwrap();
5506
5507            // Subscribe AFTER the backfill — queue must be empty (no stale events).
5508            let sub = db.subscribe_all_rules().unwrap();
5509            // No events should have queued for the prior backfill.
5510            assert!(
5511                sub.try_recv().is_none(),
5512                "subscribe after backfill must see no stale events"
5513            );
5514
5515            // Inserting a new node now should fire an event (emit_deltas is now true).
5516            db.insert_node("Org", "o_new", vec![]).unwrap();
5517            db.insert_node(
5518                "Person",
5519                "p_new",
5520                vec![("org_id".into(), Value::Str("o_new".into()))],
5521            )
5522            .unwrap();
5523            let ev = sub.recv_timeout(std::time::Duration::from_millis(200));
5524            assert!(
5525                ev.is_some(),
5526                "edge-fire event must arrive after subscribe (emit_deltas=true)"
5527            );
5528        }
5529        let _ = std::fs::remove_dir_all(&dir);
5530    }
5531
5532    /// Gating regression: subscribe BEFORE a backfill → events flow.
5533    #[test]
5534    fn subscribe_before_backfill_events_flow() {
5535        let dir = tmp_dir("sub-before-backfill");
5536        {
5537            let mut db = GraphDb::open(&dir).unwrap();
5538            // Subscribe FIRST — emit_deltas becomes true.
5539            let sub = db.subscribe_all_rules().unwrap();
5540
5541            for i in 0..5u32 {
5542                db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
5543                db.insert_node(
5544                    "Person",
5545                    &format!("p{i}"),
5546                    vec![("org_id".into(), Value::Str(format!("o{i}")))],
5547                )
5548                .unwrap();
5549            }
5550            // Backfill fires with emit_deltas=true → events queued.
5551            db.create_rule(fk_rule()).unwrap();
5552
5553            // Should receive at least one edge-fired event from the backfill.
5554            let mut received = 0usize;
5555            while sub.try_recv().is_some() {
5556                received += 1;
5557            }
5558            assert!(
5559                received > 0,
5560                "subscribe before backfill must receive edge-fire events (got 0)"
5561            );
5562        }
5563        let _ = std::fs::remove_dir_all(&dir);
5564    }
5565
5566    /// Companion: when a view IS defined, the delta path fires and view values update.
5567    #[test]
5568    fn delta_copy_fires_when_view_exists() {
5569        use core_rules::ViewSource;
5570        DELTA_COPY_COUNT.with(|c| c.set(0));
5571        let dir = tmp_dir("delta-copy-with-view");
5572        {
5573            let mut db = GraphDb::open(&dir).unwrap();
5574            db.insert_node("Org", "o1", vec![]).unwrap();
5575            db.insert_node(
5576                "Person",
5577                "p1",
5578                vec![("org_id".into(), Value::Str("o1".into()))],
5579            )
5580            .unwrap();
5581            // Declare a Degree view so is_empty() returns false.
5582            db.create_view(ViewDef {
5583                name: "degree_out".into(),
5584                label: "Person".into(),
5585                view_prop: "degree_out".into(),
5586                source: ViewSource::Degree {
5587                    edge_type: "WORKS_AT".into(),
5588                    direction: Direction::Out,
5589                },
5590            })
5591            .unwrap();
5592            db.create_rule(fk_rule()).unwrap();
5593
5594            // At least one delta copy should have happened (CreateRule backfill).
5595            let copies = DELTA_COPY_COUNT.with(|c| c.get());
5596            assert!(
5597                copies > 0,
5598                "expected delta copy to fire when a view is defined"
5599            );
5600
5601            // View value should be computed: p1 has one WORKS_AT out-edge.
5602            let info = db.node_info("p1").unwrap();
5603            let degree = info.props.get("degree_out");
5604            assert!(
5605                degree.is_some(),
5606                "view prop should be written to node props"
5607            );
5608        }
5609        let _ = std::fs::remove_dir_all(&dir);
5610    }
5611
5612    /// Regression: `open_at_with` must call `rebuild_all` after WAL replay so
5613    /// derived-edge-driven view values reflect the as-of state rather than just
5614    /// the initial backfill written at `CreateView` time.
5615    ///
5616    /// History (6 WAL frames, indices 0..=5):
5617    ///   0: insert Org "o1"
5618    ///   1: create_view "employee_count" (Degree / WORKS_AT / In) on Org
5619    ///   2: create_rule fk_rule (WORKS_AT, Person→Org via org_id)
5620    ///   3: insert Person "p1" → rule fires WORKS_AT p1→o1 (degree = 1)  ← mid
5621    ///   4: insert Person "p2" → rule fires WORKS_AT p2→o1 (degree = 2)
5622    ///   5: insert Person "p3" → rule fires WORKS_AT p3→o1 (degree = 3)  ← latest
5623    ///
5624    /// Without `rebuild_all`, the as-of instance's "emp" view stays at the
5625    /// initial backfill value (0) instead of reflecting the replayed derived edges.
5626    #[test]
5627    fn open_at_derived_edge_view_values_correct() {
5628        use core_rules::ViewSource;
5629        let dir = tmp_dir("open-at-view-rebuild");
5630        {
5631            let mut db = GraphDb::open(&dir).unwrap();
5632            // frame 0
5633            db.insert_node("Org", "o1", vec![]).unwrap();
5634            // frame 1: create view — initial backfill sees 0 derived edges (none fired yet)
5635            db.create_view(ViewDef {
5636                name: "employee_count".into(),
5637                label: "Org".into(),
5638                view_prop: "emp".into(),
5639                source: ViewSource::Degree {
5640                    edge_type: "WORKS_AT".into(),
5641                    direction: Direction::In,
5642                },
5643            })
5644            .unwrap();
5645            // frame 2: create rule — no Persons yet; backfill is a no-op
5646            db.create_rule(fk_rule()).unwrap();
5647            // frame 3: p1 — rule fires WORKS_AT p1→o1; degree = 1
5648            db.insert_node(
5649                "Person",
5650                "p1",
5651                vec![("org_id".into(), Value::Str("o1".into()))],
5652            )
5653            .unwrap();
5654            // frame 4: p2 — degree = 2
5655            db.insert_node(
5656                "Person",
5657                "p2",
5658                vec![("org_id".into(), Value::Str("o1".into()))],
5659            )
5660            .unwrap();
5661            // frame 5: p3 — degree = 3
5662            db.insert_node(
5663                "Person",
5664                "p3",
5665                vec![("org_id".into(), Value::Str("o1".into()))],
5666            )
5667            .unwrap();
5668            // Sanity: normal open sees degree = 3.
5669            assert_eq!(
5670                db.get_view_prop("o1", "emp").cloned(),
5671                Some(Value::Int(3)),
5672                "normal db must show degree 3 after 3 derived edges"
5673            );
5674        } // WAL flushed
5675
5676        // Re-open normally to get the authoritative reference value.
5677        let normal_db = GraphDb::open(&dir).unwrap();
5678        let normal_emp = normal_db.get_view_prop("o1", "emp").cloned();
5679        assert_eq!(
5680            normal_emp,
5681            Some(Value::Int(3)),
5682            "re-opened normal db must show degree 3"
5683        );
5684
5685        // Latest as-of (commit 5 = frames 0..=5): must match the normal open.
5686        let aof_latest = GraphDb::open_at(&dir, 5).unwrap();
5687        assert_eq!(
5688            aof_latest.get_view_prop("o1", "emp").cloned(),
5689            normal_emp,
5690            "open_at latest: derived-edge view must equal normal open (rebuild_all required)"
5691        );
5692
5693        // Mid-history as-of (commit 3 = frames 0..=3): only p1; degree = 1.
5694        let aof_mid = GraphDb::open_at(&dir, 3).unwrap();
5695        assert_eq!(
5696            aof_mid.get_view_prop("o1", "emp").cloned(),
5697            Some(Value::Int(1)),
5698            "open_at mid-history: only p1 exists at frame 3, degree must be 1"
5699        );
5700
5701        let _ = std::fs::remove_dir_all(&dir);
5702    }
5703
5704    /// Pin: subscribe_* on an as-of instance must return Err(ReadOnly) —
5705    /// as-of instances never commit, so distribute_events never runs and any
5706    /// subscription would wait forever.
5707    #[test]
5708    fn subscribe_on_as_of_returns_read_only_error() {
5709        let dir = tmp_dir("sub-as-of-read-only");
5710        {
5711            let mut db = GraphDb::open(&dir).unwrap();
5712            db.insert_node("Org", "o1", vec![]).unwrap();
5713            db.create_rule(fk_rule()).unwrap();
5714        }
5715        let mut aof = GraphDb::open_at(&dir, 0).unwrap();
5716
5717        assert!(
5718            matches!(
5719                aof.subscribe_all_rules(),
5720                Err(core_storage::GraphError::ReadOnly)
5721            ),
5722            "subscribe_all_rules on as-of must return ReadOnly"
5723        );
5724        assert!(
5725            matches!(
5726                aof.subscribe_writes(),
5727                Err(core_storage::GraphError::ReadOnly)
5728            ),
5729            "subscribe_writes on as-of must return ReadOnly"
5730        );
5731        assert!(
5732            matches!(
5733                aof.subscribe_rule("works_at"),
5734                Err(core_storage::GraphError::ReadOnly)
5735            ),
5736            "subscribe_rule on as-of must return ReadOnly"
5737        );
5738        let _ = std::fs::remove_dir_all(&dir);
5739    }
5740
5741    /// Regression: a failed dense WAL rewrite must not leave speculative
5742    /// interns in `syms`. If it does, the next successful mutation logs an
5743    /// `Intern` record with an inflated id; replay (which never saw the
5744    /// orphans) assigns a smaller id and the WAL becomes unreplayable.
5745    #[test]
5746    fn dense_rewrite_error_rolls_back_speculative_interns() {
5747        let dir = tmp_dir("dense-rewrite-rollback");
5748        {
5749            let mut db = GraphDb::open(&dir).unwrap();
5750            db.insert_node("Person", "a", vec![]).unwrap();
5751
5752            // Bypass MutPreview validation to hit the rewrite's own error path
5753            // (same shape as an id-exhaustion failure mid-rewrite). The
5754            // InsertEdge arm interns the edge type before it resolves keys.
5755            let err = db.rewrite_wal_dense(vec![WalRecord::InsertEdge {
5756                edge_type: "ORPHAN_TYPE".into(),
5757                src_key: "missing".into(),
5758                dst_key: "a".into(),
5759            }]);
5760            assert!(err.is_err(), "rewrite of a missing src key must fail");
5761            assert_eq!(
5762                db.syms.get("ORPHAN_TYPE"),
5763                None,
5764                "failed rewrite must roll back speculative interns"
5765            );
5766
5767            // A later successful mutation must produce a replayable WAL.
5768            db.set_prop("a", "later_field", Value::Int(2)).unwrap();
5769        }
5770        let db = GraphDb::open(&dir).expect("WAL must replay after failed rewrite");
5771        assert_eq!(db.get_prop("a", "later_field"), Some(&Value::Int(2)));
5772        let _ = std::fs::remove_dir_all(&dir);
5773    }
5774}