Skip to main content

core_api/
db.rs

1use crate::ingest::{IngestOptions, IngestReport};
2use crate::roles::{RoleDef, RolesFile, WriteScope};
3use crate::subscription::{
4    event_matches, DbEvent, SubEntry, SubFilter, SubInner, Subscription, DEFAULT_SUB_CAPACITY,
5};
6use core_query::cypher::ast::{ret_val_label, ArithOp};
7use core_query::cypher::{
8    execute, execute_union, is_subscribable, is_write_tokens, lex, parse, parse_read, parse_write,
9    plan, MatchDeleteNodeStmt, NodePat, Operand, Params, Pattern, PlanOp, Query, RetItem, RetVal,
10    WriteStatement,
11};
12use core_query::{eval_filter, expand, neighborhood, Dir, Filter, GraphView, ResultSet};
13use core_rules::{
14    decode_rule_def, ef_max, evaluate, BuildProgress, EngineEdgeDelta, GraphMut, NodeView,
15    Predicate, RuleDef, RuleEngine, ViewDef, ViewStore,
16};
17use core_storage::fs::{FileId, Fs, FsIntrospect, RealFs};
18use core_storage::fulltext::FulltextIndex;
19use core_storage::property_index::PropertyIndex;
20use core_storage::v8::encode::{
21    archived_hnsw_to_owned, archived_rules_meta_to_owned, archived_to_idmap, archived_to_interner,
22    archived_views_to_owned, decode_last_change_bytes, decode_meta, encode_v8, V8Meta,
23};
24use core_storage::v8::seam::TopologyView;
25use core_storage::wal::{decode_all, encode_record, WalRecord};
26use core_storage::EdgePropsView;
27use core_storage::{
28    namespace_of_value, ColumnStore, Direction, EdgeProps, GraphError, IdMap, Interner, Result,
29    Topology, Value,
30};
31pub use core_storage::{valid_namespace, NS_DEFAULT, NS_MAX_LEN, NS_PROP};
32
33/// Index of [`NS_DEFAULT`] in `GraphDb::ns_names` — always zero, so the
34/// open-time pass over a store with no `ns` column fills `node_ns` with one
35/// constant and allocates no names.
36const NS_DEFAULT_IDX: u32 = 0;
37use serde::{Deserialize, Serialize};
38use std::collections::{BTreeMap, BTreeSet, HashMap};
39use std::sync::Arc;
40
41/// Print a timing checkpoint when MUSHROOMDB_TRACE_OPEN is set.
42/// Zero-cost when the env var is absent (the var check is O(1) after first call).
43macro_rules! trace_open {
44    ($phase:literal, $t:expr) => {
45        if std::env::var("MUSHROOMDB_TRACE_OPEN").is_ok() {
46            eprintln!(
47                "[MUSHROOMDB_TRACE_OPEN] {:40} {:>9.3?}",
48                $phase,
49                $t.elapsed()
50            );
51        }
52    };
53}
54
55/// Print a migration phase checkpoint when MUSHROOMDB_TRACE_MIGRATE is set.
56/// Zero-cost when the env var is absent (the var check is O(1) after first call).
57macro_rules! trace_migrate {
58    ($phase:literal, $t:expr) => {
59        if std::env::var("MUSHROOMDB_TRACE_MIGRATE").is_ok() {
60            eprintln!(
61                "[MUSHROOMDB_TRACE_MIGRATE] {:40} {:>9.3?}",
62                $phase,
63                $t.elapsed()
64            );
65        }
66    };
67}
68
69// Test-only: counts how many times `pending_deltas_since().to_vec()` actually
70// executes (i.e., at least one view is defined). Used to verify the fast-path
71// guard skips the allocation when `view_store.is_empty()`.
72#[cfg(test)]
73thread_local! {
74    static DELTA_COPY_COUNT: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
75}
76
77// Per-thread count of query-subscription `execute` calls in `distribute_events`.
78//
79// Incremented each time a query subscription actually runs its plan (i.e.,
80// the label-skip fast-path did not fire). Because `distribute_events` is
81// called synchronously on the writer thread, this thread-local correctly
82// isolates each test thread's count even when integration tests run in
83// parallel. Read via [`query_sub_exec_count`].
84thread_local! {
85    static QUERY_SUB_EXECS_TL: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
86}
87
88/// Return the number of query-subscription re-executions logged on this
89/// thread since the process started (or since last reset via
90/// [`reset_query_sub_exec_count`]).
91///
92/// Primarily for integration tests that verify the label-skip fast-path.
93#[doc(hidden)]
94pub fn query_sub_exec_count() -> usize {
95    QUERY_SUB_EXECS_TL.with(|c| c.get())
96}
97
98/// Reset the per-thread query-subscription execution counter to zero.
99#[doc(hidden)]
100pub fn reset_query_sub_exec_count() {
101    QUERY_SUB_EXECS_TL.with(|c| c.set(0));
102}
103
104/// Internal state for a single `subscribe_query` subscription.
105///
106/// On every commit, `distribute_events` re-executes `ops` against the current
107/// graph state, diffs the result against `prev_rows`, and pushes
108/// `DbEvent::QueryRowAdded` / `QueryRowRemoved` events to `inner`.
109///
110/// **Full re-run per commit; use LIMIT to bound execution cost.**
111/// (Differential evaluation is roadmap / Phase 5.)
112pub(crate) struct QuerySubEntry {
113    /// Compiled plan for the subscribed Cypher query.
114    ops: Vec<PlanOp>,
115    /// Column names from the first execution (fixed for the subscription lifetime).
116    columns: Vec<String>,
117    /// Serialized (JSON) row key → row data, representing the result set at
118    /// the end of the last commit. Used to diff against the new result.
119    prev_row_map: std::collections::HashMap<String, Vec<Option<Value>>>,
120    /// Weak pointer to the subscriber queue; dead Weak → subscription dropped.
121    inner: std::sync::Weak<SubInner>,
122    /// Interned label sym captured at subscribe time from the plan's leading scan
123    /// (`ScanLabel`, `IndexScan`, or `IndexIntersect` with a concrete label).
124    ///
125    /// `None` means the plan has an `Expand` op (or no recognizable leading scan
126    /// with a concrete label), and this subscription must re-execute on every
127    /// commit without skipping. This is the conservative v0.4.3 boundary: Expand
128    /// queries are never skipped because edges can alter join results regardless
129    /// of which node labels were written.
130    scan_label: Option<u32>,
131}
132
133/// A post-commit mutation notification.
134///
135/// Emitted from `log_then_apply` after the WAL append, fsync, and
136/// in-memory `apply` all succeed. Never emitted for rejected operations
137/// (validation errors, [`GraphError::RuleOwned`], duplicate keys, no-op
138/// deletes/removes). Event payloads carry user keys and rule names, never
139/// internal ids.
140///
141/// **Replay:** [`GraphDb::open`] / [`GraphDb::open_with`] replay the WAL via
142/// `apply` only. Emission lives exclusively in `log_then_apply`, so
143/// recovery is silent even if a sink were installed (it cannot be: the
144/// sink is in-memory and set after open).
145///
146/// **Ordering:** a `Batch` WAL frame emits one event per inner record, then
147/// [`MutationEvent::BatchApplied`]. An ingest commit emits those same inner
148/// events, then [`MutationEvent::Ingested`] (not `BatchApplied`). An empty
149/// or all-noop batch writes no WAL and emits nothing (including no summary).
150///
151/// **Derived edges:** rule-created or retracted edges are not individually
152/// evented — they are recoverable from the triggering mutation plus the live
153/// rule set. Only the triggering record is emitted.
154///
155/// **Wire form:** externally tagged snake_case JSON
156/// (`{"node_inserted":{"label":"A","key":"k"}}`).
157#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
158#[serde(rename_all = "snake_case")]
159pub enum MutationEvent {
160    NodeInserted {
161        label: String,
162        key: String,
163    },
164    PropSet {
165        key: String,
166        field: String,
167    },
168    PropRemoved {
169        key: String,
170        field: String,
171    },
172    EdgeInserted {
173        edge_type: String,
174        src: String,
175        dst: String,
176    },
177    EdgeDeleted {
178        edge_type: String,
179        src: String,
180        dst: String,
181    },
182    NodeDeleted {
183        key: String,
184    },
185    RuleCreated {
186        name: String,
187    },
188    RuleDeleted {
189        name: String,
190    },
191    RuleRebuilt {
192        name: String,
193    },
194    BatchApplied {
195        ops: usize,
196    },
197    Ingested {
198        label: String,
199        inserted: usize,
200    },
201}
202
203fn event_from_record(rec: &WalRecord, intern: &Interner, ids: &IdMap) -> Option<MutationEvent> {
204    match rec {
205        WalRecord::InsertNode { label, key, .. } => Some(MutationEvent::NodeInserted {
206            label: label.clone(),
207            key: key.clone(),
208        }),
209        WalRecord::InsertNodeId { label, key, .. } => Some(MutationEvent::NodeInserted {
210            label: intern.resolve(*label)?.to_string(),
211            key: key.clone(),
212        }),
213        WalRecord::SetProp { key, field, .. } => Some(MutationEvent::PropSet {
214            key: key.clone(),
215            field: field.clone(),
216        }),
217        WalRecord::SetPropId { id, field, .. } => Some(MutationEvent::PropSet {
218            key: ids.key_of(*id)?.to_string(),
219            field: intern.resolve(*field)?.to_string(),
220        }),
221        WalRecord::RemoveProp { key, field } => Some(MutationEvent::PropRemoved {
222            key: key.clone(),
223            field: field.clone(),
224        }),
225        WalRecord::InsertEdge {
226            edge_type,
227            src_key,
228            dst_key,
229        } => Some(MutationEvent::EdgeInserted {
230            edge_type: edge_type.clone(),
231            src: src_key.clone(),
232            dst: dst_key.clone(),
233        }),
234        WalRecord::InsertEdgeId { etype, src, dst } => Some(MutationEvent::EdgeInserted {
235            edge_type: intern.resolve(*etype)?.to_string(),
236            src: ids.key_of(*src)?.to_string(),
237            dst: ids.key_of(*dst)?.to_string(),
238        }),
239        WalRecord::DeleteEdge {
240            edge_type,
241            src_key,
242            dst_key,
243        } => Some(MutationEvent::EdgeDeleted {
244            edge_type: edge_type.clone(),
245            src: src_key.clone(),
246            dst: dst_key.clone(),
247        }),
248        WalRecord::DeleteNode { key } => Some(MutationEvent::NodeDeleted { key: key.clone() }),
249        WalRecord::CreateRule { def_bytes } => {
250            let def: RuleDef = decode_rule_def(def_bytes).ok()?;
251            Some(MutationEvent::RuleCreated { name: def.name })
252        }
253        WalRecord::DeleteRule { name } => Some(MutationEvent::RuleDeleted { name: name.clone() }),
254        WalRecord::RebuildRule { name } => Some(MutationEvent::RuleRebuilt { name: name.clone() }),
255        WalRecord::Batch(_)
256        | WalRecord::CreateView { .. }
257        | WalRecord::DeleteView { .. }
258        | WalRecord::EnableFulltext { .. }
259        | WalRecord::DisableFulltext { .. }
260        | WalRecord::EnableIndex { .. }
261        | WalRecord::DisableIndex { .. }
262        | WalRecord::Intern { .. }
263        // History markers are no-ops for mutation events — they carry no new
264        // state and rules re-derive deterministically on replay.
265        | WalRecord::DerivedEdgeAdded { .. }
266        | WalRecord::DerivedEdgeRetracted { .. }
267        // RenameNode carries no node/edge count change; no special event.
268        | WalRecord::RenameNode { .. } => None,
269    }
270}
271
272/// Database-wide counters plus per-rule budget/fire stats.
273#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
274pub struct Stats {
275    pub nodes_live: usize,
276    pub nodes_tombstoned: usize,
277    pub edges: u64,
278    pub rules: Vec<RuleStats>,
279    /// How many writes hit the rule-chaining depth cap with work still pending,
280    /// since this handle was opened. Non-zero means some derived edges beyond
281    /// the cap are stale and no single later write will repair them: split the
282    /// rule chain or shorten it. Never persisted, so it resets on reopen.
283    #[serde(default)]
284    pub chain_truncations: u64,
285    /// The oldest commit index history still reaches (the WAL horizon floor).
286    /// `0` means nothing has been pruned and history is complete; a non-zero
287    /// value means events before that commit were pruned and are gone.
288    #[serde(default)]
289    pub history_floor: u64,
290    /// Live node counts per namespace, in name order. Always carries
291    /// `default` — a store is at least its default namespace — so a
292    /// single-tenant store reads `[{"name":"default", …}]` and a reader can
293    /// tell "no namespaces in use" from one entry.
294    #[serde(default)]
295    pub namespaces: Vec<NamespaceStats>,
296}
297
298/// Live node count for one namespace; one entry of [`Stats::namespaces`].
299#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
300pub struct NamespaceStats {
301    pub name: String,
302    pub nodes_live: usize,
303}
304
305/// One rule's provenance size, trip latch, and fire counter.
306///
307/// `tripped` is a one-way latch: once set, the engine adds no new edges for
308/// that rule until [`GraphDb::rebuild_rule`] (and only if the full desired
309/// set then fits). `fires` counts `on_node_changed` evaluations plus
310/// backfill/rebuild participant ticks (rebuild counts even when it is a
311/// provenance no-op).
312#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
313pub struct RuleStats {
314    pub name: String,
315    pub edges: u64,
316    pub tripped: bool,
317    pub fires: u64,
318    /// Whether this rule uses the approximate IVF-Flat candidate path.
319    pub approximate: bool,
320    /// `Some` while this rule's vector index is still being built.
321    ///
322    /// The rule derives **no** edges until it is `None`: the backfill is one
323    /// commit that runs after the index is whole, so a caller never sees a
324    /// partial edge set. Absent from the JSON when the rule is not building,
325    /// which is every rule created over a corpus at or below
326    /// [`core_rules::HNSW_BUILD_BATCH`] vectors.
327    #[serde(default, skip_serializing_if = "Option::is_none")]
328    pub building: Option<BuildProgress>,
329}
330
331/// One entry in the slow-query ring buffer.
332#[derive(Debug, Clone, Serialize)]
333pub struct SlowQueryEntry {
334    /// Execution time in whole milliseconds.
335    pub ms: u64,
336    /// The Cypher query string that was slow.
337    pub query: String,
338    /// The commit sequence number at the time the query ran.
339    pub at_commit: u64,
340}
341
342/// Snapshot of the slow-query log returned by [`GraphDb::slow_query_snapshot`].
343#[derive(Debug, Clone, Serialize)]
344pub struct SlowQuerySnapshot {
345    /// Current threshold in milliseconds (0 = disabled).
346    pub threshold_ms: u64,
347    /// Total number of slow queries ever recorded (not capped by ring size).
348    pub count: u64,
349    /// Most-recent slow queries (up to 16), oldest first.
350    pub last: Vec<SlowQueryEntry>,
351}
352
353/// Internal ring-buffer state protected by a `Mutex` so `query(&self)` can
354/// write to it without a mutable borrow.
355struct SlowQueryLog {
356    entries: std::collections::VecDeque<SlowQueryEntry>,
357    total: u64,
358}
359
360/// Maximum number of entries kept in the slow-query ring buffer.
361const SLOW_QUERY_RING_CAP: usize = 16;
362
363/// Wire summary of a [`Predicate`]. JSON only — `Explanation` is never
364/// bincode-persisted (WAL/snapshots store `RuleDef` bytes, not this type).
365#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
366pub struct PredicateSummary {
367    pub kind: String,
368    pub fields: Vec<String>,
369    pub min: Option<f64>,
370    pub tolerance: Option<f64>,
371    pub km: Option<f64>,
372    pub parts: Option<Vec<PredicateSummary>>,
373    /// True when the owning rule has `approximate=true` (IVF-Flat candidate path).
374    /// Always false for predicates reported without rule context (sub-predicates in `parts`).
375    #[serde(default)]
376    pub approximate: bool,
377}
378
379impl From<&Predicate> for PredicateSummary {
380    fn from(p: &Predicate) -> Self {
381        match p {
382            Predicate::KeyMatch { field } => PredicateSummary {
383                kind: "key_match".into(),
384                fields: vec![field.clone()],
385                min: None,
386                tolerance: None,
387                km: None,
388                parts: None,
389                approximate: false,
390            },
391            Predicate::FieldEqual { field } => PredicateSummary {
392                kind: "field_equal".into(),
393                fields: vec![field.clone()],
394                min: None,
395                tolerance: None,
396                km: None,
397                parts: None,
398                approximate: false,
399            },
400            Predicate::Overlap { field, min } => PredicateSummary {
401                kind: "overlap".into(),
402                fields: vec![field.clone()],
403                min: Some(*min),
404                tolerance: None,
405                km: None,
406                parts: None,
407                approximate: false,
408            },
409            Predicate::NumericWithin { field, tolerance } => PredicateSummary {
410                kind: "numeric_within".into(),
411                fields: vec![field.clone()],
412                min: None,
413                tolerance: Some(*tolerance),
414                km: None,
415                parts: None,
416                approximate: false,
417            },
418            Predicate::GeoRadius { field, km } => PredicateSummary {
419                kind: "geo_radius".into(),
420                fields: vec![field.clone()],
421                min: None,
422                tolerance: None,
423                km: Some(*km),
424                parts: None,
425                approximate: false,
426            },
427            Predicate::VectorSimilar { field, min } => PredicateSummary {
428                kind: "vector_similar".into(),
429                fields: vec![field.clone()],
430                min: Some(*min),
431                tolerance: None,
432                km: None,
433                parts: None,
434                approximate: false,
435            },
436            Predicate::All(inner) => {
437                let parts: Vec<PredicateSummary> = inner.iter().map(Self::from).collect();
438                let mut fields = Vec::new();
439                for part in &parts {
440                    for f in &part.fields {
441                        if !fields.contains(f) {
442                            fields.push(f.clone());
443                        }
444                    }
445                }
446                PredicateSummary {
447                    kind: "all".into(),
448                    fields,
449                    min: None,
450                    tolerance: None,
451                    km: None,
452                    parts: Some(parts),
453                    approximate: false,
454                }
455            }
456            Predicate::Any(inner) => {
457                let parts: Vec<PredicateSummary> = inner.iter().map(Self::from).collect();
458                let mut fields = Vec::new();
459                for part in &parts {
460                    for f in &part.fields {
461                        if !fields.contains(f) {
462                            fields.push(f.clone());
463                        }
464                    }
465                }
466                PredicateSummary {
467                    kind: "any".into(),
468                    fields,
469                    min: None,
470                    tolerance: None,
471                    km: None,
472                    parts: Some(parts),
473                    approximate: false,
474                }
475            }
476        }
477    }
478}
479
480/// Snapshot of a live node's key, label, and columnar properties.
481///
482/// `props` is a [`BTreeMap`] so field order is deterministic (sorted by name)
483/// regardless of insert order or the columnar store's `HashMap` iteration.
484///
485/// Deliberately does not derive `Serialize`: `Value`'s serde form is
486/// internally tagged. Wire JSON is built by `value_to_json` in the server.
487#[derive(Debug, Clone, PartialEq)]
488pub struct NodeInfo {
489    pub key: String,
490    pub label: String,
491    pub props: BTreeMap<String, Value>,
492}
493
494/// Counts returned by [`GraphDb::delete_node`].
495#[derive(Debug, Clone, PartialEq, Eq, Default)]
496pub struct DeleteReport {
497    /// Number of manual (user-inserted) edges removed.
498    pub manual_edges: u64,
499    /// Number of derived (rule-owned) edges retracted.
500    pub derived_edges: u64,
501}
502
503/// One directed edge incident on a node, with provenance membership.
504///
505/// `derived` is true iff `(edge_type, src, dst)` is in the rule engine's
506/// Plan-8 `by_node` provenance index.
507#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
508pub struct EdgeInfo {
509    pub edge_type: String,
510    pub src_key: String,
511    pub dst_key: String,
512    pub derived: bool,
513}
514
515/// One directed edge incident on a node at a point in WAL history, with the
516/// rule that derived it when it is rule-owned.
517///
518/// Returned by [`GraphDb::edges_at`] (sorted by `(edge_type, src_key, dst_key)`)
519/// and by [`GraphDb::what_if_set_prop`].
520#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
521pub struct EdgeAt {
522    pub edge_type: String,
523    pub src_key: String,
524    pub dst_key: String,
525    /// `true` when a rule wrote the edge (`DerivedEdgeAdded` in the WAL, or a
526    /// live provenance entry).
527    pub derived: bool,
528    /// The rule that derived the edge. `None` for a manual edge.
529    pub rule: Option<String>,
530}
531
532/// The derived edges a hypothetical property change would retract and derive.
533///
534/// Returned by [`GraphDb::what_if_set_prop`]. Both lists are sorted by
535/// `(edge_type, src_key, dst_key)` and every entry is rule-derived.
536#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
537pub struct WhatIf {
538    /// Derived edges that exist now and would be retracted.
539    pub lost: Vec<EdgeAt>,
540    /// Derived edges that do not exist now and would be derived.
541    pub gained: Vec<EdgeAt>,
542}
543
544/// An edge with mask-aware endpoint visibility.
545///
546/// Returned by [`GraphDb::node_edges_masked`] in [`crate::mask::MaskMode::Stub`]
547/// mode — hidden endpoints carry `*_restricted: true`.
548#[derive(Debug, Clone, PartialEq, Eq)]
549pub struct MaskedEdge {
550    pub edge_type: String,
551    pub src_key: String,
552    /// `true` when `src_key` is in the DB but hidden from the mask.
553    pub src_restricted: bool,
554    pub dst_key: String,
555    /// `true` when `dst_key` is in the DB but hidden from the mask.
556    pub dst_restricted: bool,
557    pub derived: bool,
558}
559
560/// Result of a mask-aware node lookup via [`GraphDb::node_info_masked`].
561///
562/// `None` from that method means the key does not exist (→ 404).
563/// `Some(Restricted)` is only produced when `mask.mode() == MaskMode::Stub`.
564#[derive(Debug, PartialEq)]
565pub enum MaskedNodeResult {
566    Visible(NodeInfo),
567    /// Node exists in the DB but is hidden from this mask.
568    Restricted,
569}
570
571/// One rule-owned edge between two nodes, with the rule name, edge type,
572/// direction (src_key → dst_key), and weight if the rule stores one.
573#[derive(Debug, Clone, PartialEq, Serialize)]
574pub struct Explanation {
575    pub rule: String,
576    pub edge_type: String,
577    pub src_key: String,
578    pub dst_key: String,
579    pub weight: Option<f64>,
580    pub predicate: PredicateSummary,
581    /// For a via-hop rule, the edge type the rule hops over to reach its
582    /// candidates. `None` for a plain two-node rule. A via-hop rule whose
583    /// `via_edge` is itself rule-derived is the chaining case: the hop edge
584    /// was written by another rule in the same commit.
585    #[serde(default)]
586    pub via_edge: Option<String>,
587}
588
589/// Report returned by [`GraphDb::backup_to`].
590#[derive(Debug, Clone)]
591pub struct BackupReport {
592    /// Filenames copied into the destination directory (sorted ascending).
593    pub files: Vec<String>,
594    /// Total bytes written across all copied files.
595    pub bytes: u64,
596    /// `true` when the destination opened cleanly and passed post-copy checks.
597    ///
598    /// For stores that have a `snapshot.bin` this means: all V8 section CRCs
599    /// matched **and** the destination opened without error.
600    ///
601    /// For WAL-only stores (no `snapshot.bin`) there is no snapshot to
602    /// CRC-check; `verified` is `true` when the destination opened and
603    /// replayed the WAL without error (record-level checksums in the WAL
604    /// provide the integrity signal, not section CRCs).
605    pub verified: bool,
606}
607
608/// One directed edge in export form, with optional rule attribution for derived edges.
609///
610/// Returned by [`GraphDb::all_edges_for_export`].
611///
612/// Does not derive `Eq`/`Ord`: `weight` is an `f64` and NaN breaks a total
613/// order. Callers that need a stable edge ordering already sort by
614/// `(edge_type, src, dst)` explicitly (see `all_edges_for_export`).
615#[derive(Debug, Clone, PartialEq, PartialOrd)]
616pub struct ExportEdge {
617    pub edge_type: String,
618    pub src: String,
619    pub dst: String,
620    pub derived: bool,
621    /// Rule name that created this edge, if derived. `None` for manual edges.
622    pub rule: Option<String>,
623    /// The creating rule's declared `weight_prop`, read off this edge, when
624    /// derived and numeric (`Int`/`Float`). `None` for manual edges, derived
625    /// edges whose rule declares no `weight_prop`, or a non-numeric value.
626    pub weight: Option<f64>,
627}
628
629/// One edge type's shape, as [`GraphDb::edge_type_census`] counts it.
630///
631/// Deliberately per *type* and not per edge: everything here is a summary a
632/// caller can print in one line, and none of it costs a record per edge.
633#[derive(Debug, Clone, PartialEq, Eq)]
634pub struct EdgeTypeCensus {
635    pub edge_type: String,
636    /// Directed edges of this type. Counted the way
637    /// [`GraphDb::edge_count`] counts: each edge once, from its source.
638    pub edges: u64,
639    /// Every label seen on a source of this type, sorted.
640    pub src_labels: Vec<String>,
641    /// Every label seen on a destination of this type, sorted.
642    pub dst_labels: Vec<String>,
643    /// The rules that declare this `edge_type`, sorted. Empty for a type
644    /// written by hand.
645    pub rules: Vec<String>,
646    /// `(src key, dst key)` of the first edge of this type in the store's own
647    /// id order — a real pair to quote in an example.
648    pub sample: Option<(String, String)>,
649}
650
651/// Construct the standard write-query result set (columns: created, properties_set, deleted).
652fn write_result_set() -> ResultSet {
653    ResultSet::new(vec![
654        "created".into(),
655        "properties_set".into(),
656        "deleted".into(),
657    ])
658}
659
660fn resolve_merge_set_value(op: &Operand, params: &BTreeMap<String, Value>) -> Result<Value> {
661    match op {
662        Operand::Lit(v) => Ok(v.clone()),
663        Operand::Param(name) => params
664            .get(name)
665            .cloned()
666            .ok_or_else(|| GraphError::QueryError {
667                detail: format!("missing parameter `{name}`"),
668            }),
669        _ => Err(GraphError::QueryError {
670            detail: "ON CREATE/ON MATCH SET value must be a literal or $parameter".into(),
671        }),
672    }
673}
674
675fn operand_node_vars(op: &Operand, out: &mut Vec<String>) {
676    match op {
677        Operand::Prop { var, .. } | Operand::Var(var) => {
678            if !out.contains(var) {
679                out.push(var.clone());
680            }
681        }
682        Operand::FuncCall { args, .. } => {
683            for arg in args {
684                operand_node_vars(arg, out);
685            }
686        }
687        Operand::BinArith { left, right, .. } => {
688            operand_node_vars(left, out);
689            operand_node_vars(right, out);
690        }
691        Operand::Case { branches, default } => {
692            // Branch conditions reference vars already bound (and mask-filtered)
693            // by the MATCH phase, so collecting from the value operands + ELSE
694            // is sufficient for RETURN-projection var discovery.
695            for (_, value) in branches {
696                operand_node_vars(value, out);
697            }
698            if let Some(d) = default {
699                operand_node_vars(d, out);
700            }
701        }
702        Operand::Index { base, index } => {
703            operand_node_vars(base, out);
704            operand_node_vars(index, out);
705        }
706        Operand::Lit(_) | Operand::Param(_) => {}
707    }
708}
709
710fn ret_node_vars(items: &[RetItem]) -> Vec<String> {
711    let mut out = Vec::new();
712    for item in items {
713        match &item.value {
714            RetVal::Var(v) | RetVal::Prop { var: v, .. } => {
715                if !out.contains(v) {
716                    out.push(v.clone());
717                }
718            }
719            RetVal::FuncCall { args, .. } => {
720                for arg in args {
721                    operand_node_vars(arg, &mut out);
722                }
723            }
724            RetVal::ScalarExpr(op) => operand_node_vars(op, &mut out),
725            RetVal::Agg { .. } => {}
726        }
727    }
728    out
729}
730
731fn add_var(out: &mut Vec<String>, v: &str) {
732    if !out.iter().any(|x| x == v) {
733        out.push(v.to_string());
734    }
735}
736
737fn pattern_node_vars(pats: &[Pattern]) -> Vec<String> {
738    let mut out = Vec::new();
739    for p in pats {
740        if let Some(v) = &p.start.var {
741            add_var(&mut out, v);
742        }
743        for (_, dest) in &p.chain {
744            if let Some(v) = &dest.var {
745                add_var(&mut out, v);
746            }
747        }
748    }
749    out
750}
751
752fn pattern_rel_vars(pats: &[Pattern]) -> Vec<String> {
753    let mut out = Vec::new();
754    for p in pats {
755        for (rel, _) in &p.chain {
756            if rel.hops.is_none() {
757                if let Some(v) = &rel.var {
758                    add_var(&mut out, v);
759                }
760            }
761        }
762    }
763    out
764}
765
766fn rel_type_alias(var: &str) -> String {
767    format!("__rt_{var}")
768}
769
770fn ret_column_name(item: &RetItem) -> String {
771    if let Some(alias) = &item.alias {
772        return alias.clone();
773    }
774    // The same naming rule the planner and the executor use, so a
775    // write-statement RETURN names its columns exactly as a read query does.
776    // An aggregate is not legal in a write-statement RETURN; it keeps the
777    // placeholder it always had.
778    ret_val_label(&item.value).unwrap_or_else(|| "<agg>".to_string())
779}
780
781fn eval_set_return_operand<F: Fs>(
782    db: &GraphDb<F>,
783    match_rs: &ResultSet,
784    row: usize,
785    rel_vars: &[String],
786    op: &Operand,
787    params: &BTreeMap<String, Value>,
788) -> Result<Option<Value>> {
789    match op {
790        Operand::Lit(v) => Ok(Some(v.clone())),
791        Operand::Param(name) => params.get(name).cloned().ok_or_else(|| GraphError::QueryError {
792            detail: format!("missing parameter `{name}`"),
793        }).map(Some),
794        Operand::Var(name) if rel_vars.iter().any(|r| r == name) => Err(GraphError::QueryError {
795            detail: format!(
796                "cannot return relationship variable '{name}' bare; return its properties ({name}.field) instead"
797            ),
798        }),
799        Operand::Var(name) => Ok(match_rs.get(row, name).cloned()),
800        Operand::Prop { var, field } => {
801            if rel_vars.iter().any(|r| r == var) {
802                return Ok(None);
803            }
804            let Some(Value::Str(key)) = match_rs.get(row, var) else {
805                return Ok(None);
806            };
807            Ok(db.get_prop(key, field))
808        }
809        Operand::FuncCall { name, args } => {
810            eval_set_return_func(db, match_rs, row, rel_vars, name, args, params)
811        }
812        Operand::BinArith { op, left, right } => {
813            let lv = eval_set_return_operand(db, match_rs, row, rel_vars, left, params)?;
814            let rv = eval_set_return_operand(db, match_rs, row, rel_vars, right, params)?;
815            eval_set_return_arith(op, lv, rv)
816        }
817        // CASE is supported in read-query RETURN; in a write-statement RETURN
818        // projection (CREATE/MERGE/SET … RETURN) it is not yet wired.
819        Operand::Case { .. } => Err(GraphError::QueryError {
820            detail: "CASE is not supported in a write-statement RETURN projection; \
821                     use a read query"
822                .into(),
823        }),
824        // Same as CASE: a list subscript is supported in a read-query RETURN
825        // but not yet in a write-statement RETURN projection.
826        Operand::Index { .. } => Err(GraphError::QueryError {
827            detail: "a list subscript is not supported in a write-statement RETURN \
828                     projection; use a read query"
829                .into(),
830        }),
831    }
832}
833
834fn eval_set_return_arith(
835    op: &ArithOp,
836    lv: Option<Value>,
837    rv: Option<Value>,
838) -> Result<Option<Value>> {
839    match (lv, rv) {
840        (None, _) | (_, None) => Ok(None),
841        (Some(Value::Int(a)), Some(Value::Int(b))) => {
842            let result = match op {
843                ArithOp::Sub => a.saturating_sub(b),
844                ArithOp::Mul => a.saturating_mul(b),
845                ArithOp::Add => a.saturating_add(b),
846                ArithOp::Div => {
847                    if b == 0 {
848                        return Err(GraphError::QueryError {
849                            detail: "division by zero".into(),
850                        });
851                    }
852                    a.checked_div(b).unwrap_or(i64::MAX)
853                }
854            };
855            Ok(Some(Value::Int(result)))
856        }
857        (Some(lv), Some(rv)) => {
858            let a = match &lv {
859                Value::Float(f) => *f,
860                Value::Int(i) => *i as f64,
861                _ => {
862                    return Err(GraphError::QueryError {
863                        detail: format!("arithmetic operand must be numeric, got {lv:?}"),
864                    })
865                }
866            };
867            let b = match &rv {
868                Value::Float(f) => *f,
869                Value::Int(i) => *i as f64,
870                _ => {
871                    return Err(GraphError::QueryError {
872                        detail: format!("arithmetic operand must be numeric, got {rv:?}"),
873                    })
874                }
875            };
876            let result = match op {
877                ArithOp::Sub => a - b,
878                ArithOp::Mul => a * b,
879                ArithOp::Add => a + b,
880                ArithOp::Div => {
881                    if b == 0.0 {
882                        return Err(GraphError::QueryError {
883                            detail: "division by zero".into(),
884                        });
885                    }
886                    a / b
887                }
888            };
889            Ok(Some(Value::Float(result)))
890        }
891    }
892}
893
894fn eval_set_return_func<F: Fs>(
895    db: &GraphDb<F>,
896    match_rs: &ResultSet,
897    row: usize,
898    rel_vars: &[String],
899    name: &str,
900    args: &[Operand],
901    params: &BTreeMap<String, Value>,
902) -> Result<Option<Value>> {
903    let norm = name.to_ascii_lowercase();
904    if norm == "type" {
905        if args.len() != 1 {
906            return Err(GraphError::QueryError {
907                detail: format!("type() requires exactly 1 argument, got {}", args.len()),
908            });
909        }
910        let Operand::Var(rel) = &args[0] else {
911            return Err(GraphError::QueryError {
912                detail: "type() argument must be a relationship variable (e.g. type(r))".into(),
913            });
914        };
915        return Ok(match_rs.get(row, &rel_type_alias(rel)).cloned());
916    }
917    if norm == "key" {
918        if args.len() != 1 {
919            return Err(GraphError::QueryError {
920                detail: format!("key() requires exactly 1 argument, got {}", args.len()),
921            });
922        }
923        let Operand::Var(var) = &args[0] else {
924            return Err(GraphError::QueryError {
925                detail: "key() argument must be a node variable (e.g. key(n))".into(),
926            });
927        };
928        if rel_vars.iter().any(|r| r == var) {
929            return Err(GraphError::QueryError {
930                detail: format!("key() argument `{var}` is a relationship, not a node"),
931            });
932        }
933        // MATCH rows bind node variables to their key string, so the column
934        // value *is* the key.
935        return Ok(match_rs.get(row, var).cloned());
936    }
937    let mut vals = Vec::with_capacity(args.len());
938    for arg in args {
939        vals.push(eval_set_return_operand(
940            db, match_rs, row, rel_vars, arg, params,
941        )?);
942    }
943    match norm.as_str() {
944        "tolower" => {
945            if vals.len() != 1 {
946                return Err(GraphError::QueryError {
947                    detail: format!("toLower() requires exactly 1 argument, got {}", vals.len()),
948                });
949            }
950            Ok(vals[0].clone().map(|val| match val {
951                Value::Str(s) => Value::Str(s.to_ascii_lowercase()),
952                other => other,
953            }))
954        }
955        "toupper" => {
956            if vals.len() != 1 {
957                return Err(GraphError::QueryError {
958                    detail: format!("toUpper() requires exactly 1 argument, got {}", vals.len()),
959                });
960            }
961            Ok(vals[0].clone().map(|val| match val {
962                Value::Str(s) => Value::Str(s.to_ascii_uppercase()),
963                other => other,
964            }))
965        }
966        "size" => match vals.first().cloned().flatten() {
967            None => Ok(None),
968            Some(Value::Str(s)) => Ok(Some(Value::Int(s.len() as i64))),
969            Some(Value::List(items)) => Ok(Some(Value::Int(items.len() as i64))),
970            Some(_) => Ok(None),
971        },
972        "coalesce" => Ok(vals.into_iter().flatten().next()),
973        "abs" => match vals.first().cloned().flatten() {
974            None => Ok(None),
975            Some(Value::Int(n)) => Ok(Some(Value::Int(n.saturating_abs()))),
976            Some(Value::Float(f)) => Ok(Some(Value::Float(f.abs()))),
977            Some(_) => Ok(None),
978        },
979        "round" => match vals.first().cloned().flatten() {
980            None => Ok(None),
981            Some(Value::Float(f)) => Ok(Some(Value::Float(f.round()))),
982            Some(Value::Int(n)) => Ok(Some(Value::Int(n))),
983            Some(_) => Ok(None),
984        },
985        "decay" => {
986            if vals.len() != 3 {
987                return Err(GraphError::QueryError {
988                    detail: format!("decay() requires exactly 3 arguments, got {}", vals.len()),
989                });
990            }
991            match (vals[0].clone(), vals[1].clone(), vals[2].clone()) {
992                (None, _, _) | (_, None, _) | (_, _, None) => Ok(None),
993                (Some(b), Some(a), Some(h)) => {
994                    let numeric = |v: Value| -> Result<f64> {
995                        match v {
996                            Value::Int(n) => Ok(n as f64),
997                            Value::Float(f) => Ok(f),
998                            other => Err(GraphError::QueryError {
999                                detail: format!(
1000                                    "decay() requires numeric arguments, got {other:?}"
1001                                ),
1002                            }),
1003                        }
1004                    };
1005                    let b = numeric(b)?;
1006                    let a = numeric(a)?;
1007                    let h = numeric(h)?;
1008                    if h <= 0.0 {
1009                        return Err(GraphError::QueryError {
1010                            detail: "decay() requires halflife > 0".into(),
1011                        });
1012                    }
1013                    Ok(Some(Value::Float(b * 0.5f64.powf(a / h))))
1014                }
1015            }
1016        }
1017        _ => Err(GraphError::QueryError {
1018            detail: format!(
1019                "unknown function `{name}`; supported: toLower, toUpper, size, coalesce, type, abs, round, decay, key"
1020            ),
1021        }),
1022    }
1023}
1024
1025fn eval_set_return_item<F: Fs>(
1026    db: &GraphDb<F>,
1027    match_rs: &ResultSet,
1028    row: usize,
1029    rel_vars: &[String],
1030    item: &RetItem,
1031    params: &BTreeMap<String, Value>,
1032) -> Result<Option<Value>> {
1033    match &item.value {
1034        RetVal::Var(v) => eval_set_return_operand(
1035            db,
1036            match_rs,
1037            row,
1038            rel_vars,
1039            &Operand::Var(v.clone()),
1040            params,
1041        ),
1042        RetVal::Prop { var, field } => eval_set_return_operand(
1043            db,
1044            match_rs,
1045            row,
1046            rel_vars,
1047            &Operand::Prop {
1048                var: var.clone(),
1049                field: field.clone(),
1050            },
1051            params,
1052        ),
1053        RetVal::FuncCall { name, args } => {
1054            eval_set_return_func(db, match_rs, row, rel_vars, name, args, params)
1055        }
1056        RetVal::ScalarExpr(op) => eval_set_return_operand(db, match_rs, row, rel_vars, op, params),
1057        RetVal::Agg { .. } => Err(GraphError::QueryError {
1058            detail: "aggregates are not supported in MATCH … SET … RETURN".into(),
1059        }),
1060    }
1061}
1062
1063/// Project user RETURN from original MATCH rows after SET. No rematch.
1064fn project_set_return_rows<F: Fs>(
1065    db: &GraphDb<F>,
1066    rel_vars: &[String],
1067    match_rs: &ResultSet,
1068    returns: &[RetItem],
1069    params: &BTreeMap<String, Value>,
1070) -> Result<ResultSet> {
1071    let columns: Vec<String> = returns.iter().map(ret_column_name).collect();
1072    let mut out = ResultSet::new(columns);
1073    for row in 0..match_rs.len() {
1074        let mut cells = Vec::with_capacity(returns.len());
1075        for item in returns {
1076            cells.push(eval_set_return_item(
1077                db, match_rs, row, rel_vars, item, params,
1078            )?);
1079        }
1080        out.push_row(cells);
1081    }
1082    Ok(out)
1083}
1084
1085/// Single construction point for a `GraphMut` view over the split-borrowed graph fields.
1086/// Callers use `std::mem::take` on the engine before calling this, then restore it after.
1087/// Extract a `Vec<f64>` from a `Value::List` whose items are all numeric.
1088/// Returns `None` for non-list values or lists with non-numeric elements.
1089/// Extra candidates pulled from an approximate index before re-scoring, over and
1090/// above the `k` asked for.
1091///
1092/// The index orders candidates by `f32` distances, which agree with the exact
1093/// `f64` cosine to about 1e-6. Re-scoring can therefore only reshuffle
1094/// candidates inside a band that narrow — it cannot move a hit past one that is
1095/// further away by more than 1e-6 — so the only way a true top-`k` member can be
1096/// lost is if the index ranked it just outside `k` on the `f32` order. Fetching
1097/// `k + 16` covers any such band up to 16 members wide, which at 1e-6 means 16
1098/// vectors within a millionth of each other in cosine: a duplicate cluster, and
1099/// then the members are interchangeable anyway. `min` is applied to the exact
1100/// score, never to the index's, so a hit sitting on the threshold is decided
1101/// exactly.
1102const VECTOR_RESCORE_MARGIN: usize = 16;
1103
1104/// Cosine similarity between an already-unit query and node `id`'s `field`
1105/// vector, read from the **`f64`** properties. `None` when the node has no
1106/// numeric-list vector there, or its norm is zero.
1107///
1108/// The single definition of the score this API reports. Both the brute-force
1109/// scan and the re-scoring step that follows an index lookup go through it, so
1110/// the two paths cannot disagree — which is the property
1111/// `index_and_brute_force_agree_on_scores` pins.
1112fn exact_vector_similarity(
1113    view: &GraphView<'_>,
1114    id: u32,
1115    field: &str,
1116    q_unit: &[f64],
1117) -> Option<f64> {
1118    let v = view.prop(id, field)?;
1119    let xs = value_as_float_list(&v.into_value())?;
1120    let v_norm: f64 = xs.iter().map(|x| x * x).sum::<f64>().sqrt();
1121    if v_norm == 0.0 {
1122        return None;
1123    }
1124    Some(
1125        q_unit
1126            .iter()
1127            .zip(xs.iter())
1128            .map(|(a, b)| a * (b / v_norm))
1129            .sum(),
1130    )
1131}
1132
1133fn value_as_float_list(v: &Value) -> Option<Vec<f64>> {
1134    match v {
1135        Value::List(items) => items
1136            .iter()
1137            .map(|item| match item {
1138                Value::Float(f) => Some(*f),
1139                Value::Int(i) => Some(*i as f64),
1140                _ => None,
1141            })
1142            .collect(),
1143        _ => None,
1144    }
1145}
1146
1147fn make_graph_mut<'a>(
1148    ids: &'a IdMap,
1149    syms: &'a mut Interner,
1150    labels: &'a [u32],
1151    props: core_storage::v8::seam::ColumnsView<'a>,
1152    topo: &'a mut Topology,
1153    base: &'a Option<std::sync::Arc<core_storage::v8::MappedBase>>,
1154    edge_props: &'a mut EdgeProps,
1155) -> GraphMut<'a> {
1156    GraphMut {
1157        ids,
1158        syms,
1159        labels,
1160        props,
1161        topo,
1162        base_topo: base_csr(base),
1163        edge_props,
1164    }
1165}
1166
1167/// The archived CSR of an open V8 snapshot, for the rule engine's graph reads.
1168///
1169/// A store opened from a snapshot keeps its edges in the mapping and its
1170/// overlay empty, so a rule that reads the graph's shape has to see both.
1171fn base_csr(
1172    base: &Option<std::sync::Arc<core_storage::v8::MappedBase>>,
1173) -> Option<&core_storage::v8::layout::ArchivedCsr> {
1174    base.as_ref().map(|b| {
1175        b.topology()
1176            .expect("base topology section bounds validated at open")
1177    })
1178}
1179
1180/// Build a `ColumnsView` from the disjoint `props` overlay and optional V8 base.
1181///
1182/// Takes explicit field references rather than `&self` so the caller can hold
1183/// simultaneous mutable borrows of other fields (e.g. `syms`, `topo`).
1184fn build_props_view<'a>(
1185    props: &'a ColumnStore,
1186    base: &'a Option<std::sync::Arc<core_storage::v8::MappedBase>>,
1187) -> core_storage::v8::seam::ColumnsView<'a> {
1188    match base {
1189        None => core_storage::v8::seam::ColumnsView::owned(props),
1190        Some(b) => {
1191            let archived = b
1192                .columns()
1193                .expect("base columns section bounds validated at open");
1194            core_storage::v8::seam::ColumnsView::with_base_cached(props, archived, b.mixed_cache())
1195                .with_shared_strings(base_string_table(b))
1196        }
1197    }
1198}
1199
1200/// The base columns section paired with the string table that resolves its
1201/// string ids — what `ViewStore` needs to read a neighbour's string property
1202/// out of a V9 snapshot.
1203fn base_columns(
1204    base: &Option<std::sync::Arc<core_storage::v8::MappedBase>>,
1205) -> Option<core_storage::v8::seam::BaseColumns<'_>> {
1206    base.as_ref().map(|b| core_storage::v8::seam::BaseColumns {
1207        cols: b
1208            .columns()
1209            .expect("base columns section bounds validated at open"),
1210        strings: base_string_table(b),
1211    })
1212}
1213
1214/// The shared string table of a V9 base, or `None` for a pre-V9 one.
1215///
1216/// Every `ColumnsView` built over a base must carry it: without it a V9
1217/// snapshot's string columns, whose own tables are empty, read back as absent.
1218fn base_string_table(
1219    base: &core_storage::v8::MappedBase,
1220) -> Option<&core_storage::v8::layout::ArchivedStringTable> {
1221    base.string_table()
1222        .transpose()
1223        .expect("base strings section bounds validated at open")
1224}
1225
1226fn build_topo_view<'a>(
1227    overlay: &'a Topology,
1228    base: &'a Option<std::sync::Arc<core_storage::v8::MappedBase>>,
1229) -> core_storage::v8::seam::TopologyView<'a> {
1230    match base {
1231        None => core_storage::v8::seam::TopologyView::owned(overlay),
1232        Some(b) => {
1233            let archived_csr = b
1234                .topology()
1235                .expect("base topology section bounds validated at open");
1236            core_storage::v8::seam::TopologyView::with_base(overlay, archived_csr)
1237        }
1238    }
1239}
1240
1241/// When [`GraphDb`] calls `Fs::sync` after a WAL append.
1242///
1243/// Default is [`Strict`](FsyncPolicy::Strict): every `log_then_apply_with`
1244/// fsyncs (single `insert_node` / `set_prop`). Ingest and `write_batch`
1245/// emit one `WalRecord::Batch` and fsync once at that frame (Batched).
1246/// [`Relaxed`](FsyncPolicy::Relaxed) skips WAL sync; [`GraphDb::snapshot`]
1247/// is still durable via `write_atomic`. Crash-recovery DST stays Strict.
1248#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
1249pub enum FsyncPolicy {
1250    /// Every WAL commit calls `fs.sync` (today's behavior).
1251    #[default]
1252    Strict,
1253    /// Sync only at a `Batch` frame end. Single-op path stays Strict unless
1254    /// this policy is set on the database.
1255    Batched,
1256    /// Never call `fs.sync`. [`GraphDb::snapshot`] still syncs via `write_atomic`.
1257    Relaxed,
1258}
1259
1260/// A precondition for a compare-and-set batch write.
1261///
1262/// All preconditions in a [`GraphDb::write_batch_cas`] or
1263/// [`crate::SharedDb::submit_batch_cas`] call are checked atomically before
1264/// any operation in the batch is applied.  If any precondition fails, the
1265/// entire batch is rejected with [`GraphError::CasConflict`] and no WAL frame
1266/// is written.
1267///
1268/// # Touch definition
1269///
1270/// A node's last-change commit (`last_changed`) is updated when any of the
1271/// following state-changing WAL records touch it:
1272///
1273/// - `InsertNode` / `InsertNodeId` — the newly-inserted node.
1274/// - `SetProp` / `SetPropId` / `RemoveProp` — the property-bearing node.
1275/// - `InsertEdge` / `InsertEdgeId` / `DeleteEdge` — **both** src and dst
1276///   endpoints (an edge change touches both sides).
1277/// - `DeleteNode` — the node is tombstoned; `last_changed` returns `None`
1278///   for deleted keys so the pre-deletion entry is never observed.
1279///
1280/// History markers (`DerivedEdgeAdded` / `DerivedEdgeRetracted`) are
1281/// state no-ops.  The underlying mutation that triggered rule firing already
1282/// updated the relevant nodes' last-change entries.  Rule-management records
1283/// (`CreateRule`, `DeleteRule`, `RebuildRule`) and view/full-text declarations
1284/// do not touch any node's last-change.
1285#[derive(Debug, Clone, PartialEq, Eq)]
1286pub enum Precondition {
1287    /// The node's last-change commit must equal `expected`.
1288    ///
1289    /// Fails with [`GraphError::CasConflict`] when:
1290    /// - The node does not exist (`last_changed` returns `None`), or
1291    /// - The recorded commit seq does not match `expected`.
1292    NodeUnchangedSince { key: String, expected: u64 },
1293    /// The node must not exist (not inserted, or already deleted).
1294    ///
1295    /// Fails with [`GraphError::CasConflict`] (expected=`u64::MAX`,
1296    /// actual=`last_changed(key).unwrap_or(0)`) when the node is live.
1297    NodeAbsent { key: String },
1298}
1299
1300pub struct GraphDb<F: Fs> {
1301    fs: F,
1302    ids: IdMap,
1303    syms: Interner,
1304    topo: Topology,
1305    props: ColumnStore,
1306    labels: Vec<u32>, // node id -> label symbol
1307    /// Namespace names by index; index [`NS_DEFAULT_IDX`] is always
1308    /// [`NS_DEFAULT`]. Derived beside [`Self::node_ns`], never persisted.
1309    ///
1310    /// A private table rather than the shared [`Interner`]: interning
1311    /// `"default"` at open would add a symbol to the store's symbol table and
1312    /// change the bytes of the next snapshot of a store that has no namespaces
1313    /// at all.
1314    ns_names: Vec<String>,
1315    /// Namespace index per dense node id, into [`Self::ns_names`];
1316    /// [`NS_DEFAULT_IDX`] for a node with no `ns` property.
1317    ///
1318    /// Derived: built by one pass over the `ns` column at open (which reads
1319    /// nothing when the column does not exist) and maintained at every node
1320    /// insert. Never written to a snapshot or the WAL, because the property it
1321    /// mirrors already is. A namespace cannot change, so no other record shape
1322    /// can move a node between namespaces.
1323    node_ns: Vec<u32>,
1324    edge_props: EdgeProps,
1325    engine: RuleEngine,
1326    view_store: ViewStore,
1327    /// Incremental inverted index for full-text-lite search.
1328    /// Rebuild-on-open: populated from WAL replay + rebuild_all at open end.
1329    fulltext: FulltextIndex,
1330    /// Opt-in equality index over scalar node properties.
1331    /// Rebuild-on-open: declarations replay from the WAL, postings rebuild at
1332    /// open end (mirrors `fulltext`).
1333    prop_index: PropertyIndex,
1334    event_sink: Option<Box<dyn Fn(MutationEvent) + Send + Sync>>,
1335    /// WAL fsync cadence. Default [`FsyncPolicy::Strict`].
1336    fsync: FsyncPolicy,
1337    /// Monotonically increasing per-commit counter.  A single `log_then_apply_with`
1338    /// call increments this once; all events emitted from that call share the same
1339    /// `commit_seq` value.
1340    commit_seq: u64,
1341    /// RBAC role definitions loaded from `roles.json` at open.
1342    ///
1343    /// `Some(roles)` — loaded successfully (may be empty when no roles are defined).
1344    /// `None` — `roles.json` was present but corrupt; `mask_for_role` returns
1345    /// `Err` for any request (fail-loud, never silently grant empty visibility).
1346    roles: Option<Vec<RoleDef>>,
1347    /// Memo for [`mask_for_role`](GraphDb::mask_for_role), keyed by
1348    /// `(role, commit_seq)` — a scoped reader between two writes resolves once.
1349    ///
1350    /// Shared by `Arc` with every [`ReaderSnapshot`](crate::reader::ReaderSnapshot)
1351    /// taken from this handle. Replaced (not cleared) whenever the role
1352    /// definitions change or the store is reloaded, which `commit_seq` does not
1353    /// record; see [`RoleMaskCache`](crate::mask::RoleMaskCache).
1354    role_masks: Arc<crate::mask::RoleMaskCache>,
1355    /// Live subscriptions.  Entries with a dead `Weak` are pruned on the next
1356    /// distribute_events call.
1357    subscriptions: Vec<SubEntry>,
1358    /// Live query subscriptions. Re-executed on every commit when non-empty.
1359    /// Dead `Weak` entries are pruned inside `distribute_events`.
1360    query_subscriptions: Vec<QuerySubEntry>,
1361    /// Queue capacity for new subscriptions created by this db.  Default is
1362    /// [`DEFAULT_SUB_CAPACITY`]; can be overridden via [`set_sub_capacity`]
1363    /// to test Lagged behaviour with small queues.
1364    sub_capacity: usize,
1365    /// True for as-of instances opened via [`GraphDb::open_at`].
1366    /// Every mutation method and `snapshot()` returns [`GraphError::ReadOnly`]
1367    /// when this flag is set.
1368    read_only: bool,
1369    /// Total WAL commit count at the time [`open_at`] was called.
1370    /// 0 for normal (non-as-of) instances.
1371    total_wal_commits: u64,
1372    /// Immutable mmap-backed base snapshot (V8).  When `Some`, `self.topo` is
1373    /// the WAL-replay overlay (empty at open time, populated by apply()) and
1374    /// reads go through a merged `TopologyView`.  `self.props` is always
1375    /// fully materialized (base + WAL replay) for HNSW/IVF and view compat.
1376    base: Option<Arc<core_storage::v8::MappedBase>>,
1377    // ── MVCC epoch reader state ───────────────────────────────────────────────
1378    /// Most-recent full overlay clone.  Initialized at end of `open_with` /
1379    /// `open_at_with`; refreshed every `FOLD_EVERY_K` commits.
1380    /// `None` only between struct creation and the first fold.
1381    fold_overlay: Option<Arc<crate::reader::FrozenOverlay>>,
1382    /// Per-commit deltas accumulated since the last fold.
1383    delta_tail: Vec<Arc<crate::reader::CommitDelta>>,
1384    /// How many commits have occurred since the last fold.
1385    commits_since_fold: usize,
1386    /// When true, `log_then_apply_with` buffers event notifications instead of
1387    /// firing them immediately.  Used by the group-commit drain thread to defer
1388    /// events until after the group fsync (R2: durability before notification).
1389    /// Cleared to false once the drain thread flushes or discards the buffer.
1390    defer_events: bool,
1391    /// Buffered events accumulated while `defer_events` is true.
1392    deferred_events: Vec<DeferredEvent>,
1393    /// Set to true by the group-commit drain thread when a group fsync fails
1394    /// after WAL truncation.  All subsequent mutation attempts return an IO
1395    /// error until the database is reopened.
1396    degraded: bool,
1397    /// Set to `true` after `ensure_v8_base_sections_loaded` has read provenance,
1398    /// HNSW, and IVF sections from the mmap base into the engine's retained
1399    /// fields.  `false` on all opens until first use; always `true` for non-V8
1400    /// opens (base is None, fast-path sets flag immediately).
1401    v8_sections_loaded: std::sync::atomic::AtomicBool,
1402    /// Serializes the one-time section population in `ensure_v8_base_sections_loaded`.
1403    v8_sections_mutex: std::sync::Mutex<()>,
1404    /// Per-node last-change commit sequence.  `last_change[node_id] = seq` means
1405    /// the node was last modified by commit `seq`.
1406    ///
1407    /// Loaded from V8 section 11 at open; updated on every state-changing commit
1408    /// and WAL replay frame.  V5-V7 stores start with an empty map; pre-WAL-horizon
1409    /// nodes return `None` from `last_changed` until they are next mutated.
1410    ///
1411    /// See [`Precondition`] for the full touch definition.
1412    last_change: HashMap<u32, u64>,
1413    /// WAL archive retention policy set by [`set_wal_archive_retention`].
1414    /// `None` = unlimited (keep all archives); `Some(N)` = keep N newest archives,
1415    /// pruning older ones at snapshot time.  0 is treated as unlimited.
1416    wal_archive_retention: Option<u32>,
1417    /// Global frame index of the first commit that is still reachable through
1418    /// surviving archives.  Persisted to `wal.floor` sidecar when pruning occurs.
1419    /// Default 0 = all history reachable.
1420    wal_horizon_floor: u64,
1421    /// True when the surviving archive chain forms a continuous WAL history
1422    /// starting from the store's first commit (the genesis chain).
1423    ///
1424    /// `open_at` may replay archive-resident commits from empty state only when
1425    /// this flag is true AND `wal_horizon_floor == 0`.  Cleared whenever:
1426    ///   - a WAL-truncating snapshot (`keep_wal=false`) is taken after archives
1427    ///     already exist (breaks the chain for subsequent archives), or
1428    ///   - any archive is pruned (floor advances past zero).
1429    ///
1430    /// Persisted via the `wal.genesis` marker file; loaded from it at open.
1431    archive_genesis_chain: bool,
1432    /// Transient write-authz context set by `write_batch_authz` /
1433    /// `query_write_authz` for the duration of ONE mutation call.
1434    /// Always `None` at rest.  Never serialized, never WAL-replayed.
1435    pending_write_authz: Option<WriteAuthz>,
1436    /// Slow-query threshold in milliseconds.  0 = disabled.
1437    /// Seeded from `MUSHROOMDB_SLOW_QUERY_MS` at open; override via
1438    /// [`GraphDb::set_slow_query_threshold_ms`] (tests must use the setter
1439    /// — env vars are process-global and race parallel test threads).
1440    slow_query_threshold_ms: u64,
1441    /// Ring buffer of recent slow queries (interior-mutable so `query(&self)`
1442    /// can record entries without requiring `&mut self`).
1443    slow_queries: std::sync::Mutex<SlowQueryLog>,
1444    /// Instant at which the database was opened (used by `/metrics` uptime).
1445    started_at: std::time::Instant,
1446    // ── Multi-process state (cross-process lock + WAL tailing) ────────────────
1447    /// Byte offset of the WAL prefix already applied to in-memory state.
1448    ///
1449    /// Advanced by exactly the encoded length of every frame this handle
1450    /// appends, and by the decoded byte count of every tail
1451    /// [`refresh`](GraphDb::refresh) absorbs. Rewound by
1452    /// [`set_wal_consumed`](GraphDb::set_wal_consumed) when the group-commit
1453    /// drain thread truncates a failed group. Compared against the WAL's
1454    /// on-disk length to decide staleness.
1455    wal_consumed: u64,
1456    /// Identity of the snapshot this handle's base state came from, as
1457    /// `(len, mtime_nanos)`. A different value means another process replaced
1458    /// the snapshot and the WAL no longer continues our state: refresh reloads.
1459    snapshot_ident: Option<(u64, u64)>,
1460    /// The options this handle was opened with. Replayed verbatim when
1461    /// `refresh` has to rebuild from disk.
1462    open_opts: OpenOptions,
1463    /// True when this handle holds the cross-process write lock for its whole
1464    /// lifetime (a plain read-write open). Per-write lock acquisition is a
1465    /// no-op on such a handle, and never releases the lock.
1466    holds_lifetime_lock: bool,
1467    /// True between a failed lock acquisition and the end of the write scope
1468    /// that failed. Makes every WAL-appending mutation in that scope return
1469    /// [`GraphError::Busy`] instead of writing.
1470    lock_denied: bool,
1471    /// True for an as-of view opened via [`GraphDb::open_at`]. Such a view is
1472    /// pinned to one commit, so it is never stale and never refreshes — later
1473    /// commits by any process are deliberately invisible to it.
1474    pinned: bool,
1475}
1476
1477/// One group of deferred event notifications, held until the group fsync
1478/// completes.  Replayed by [`GraphDb::flush_deferred_events`].
1479struct DeferredEvent {
1480    rec: core_storage::WalRecord,
1481    engine_deltas: Vec<EngineEdgeDelta>,
1482    seq: u64,
1483    ingest: Option<(String, usize)>,
1484}
1485
1486/// Options for [`GraphDb::open_with_options`].
1487#[derive(Clone, Copy, Debug)]
1488pub struct OpenOptions {
1489    /// Rewrite an old-format snapshot to the current VERSION after a
1490    /// successful load (default `true`). The old snapshot is kept as
1491    /// `snapshot.bin.bak` until the next clean open at the current version,
1492    /// at which point the `.bak` is deleted.
1493    ///
1494    /// Set to `false` to open a store without touching any on-disk files
1495    /// (useful for read-only inspection of a store at an older format).
1496    pub auto_migrate: bool,
1497
1498    /// Write the valid WAL prefix back over a torn tail on open (default
1499    /// `true`). Truncating a genuinely torn tail is correct crash recovery.
1500    ///
1501    /// Set to `false` for an unattended reader. The valid prefix is still
1502    /// decoded and replayed in memory, but nothing is written: a reader that
1503    /// opens while another process is mid-append would otherwise discard a
1504    /// frame that writer believes durable. `mushroomdb recall`, which runs on
1505    /// every prompt, passes `false` for exactly this reason.
1506    pub repair_wal: bool,
1507
1508    /// Open without ever writing to the store (default `false`).
1509    ///
1510    /// A read-only handle:
1511    /// - returns [`GraphError::ReadOnly`] from every mutation and from
1512    ///   `snapshot()`;
1513    /// - performs no disk write at open — no WAL repair write-back and no
1514    ///   auto-migration rewrite, whatever the other two flags say;
1515    /// - never takes the cross-process write lock, so it opens immediately even
1516    ///   while another process is writing, and never makes a writer wait.
1517    ///
1518    /// [`refresh`](GraphDb::refresh) and [`is_stale`](GraphDb::is_stale) work
1519    /// normally, so a read-only handle can follow another process's commits.
1520    pub read_only: bool,
1521}
1522
1523impl Default for OpenOptions {
1524    fn default() -> Self {
1525        Self {
1526            auto_migrate: true,
1527            repair_wal: true,
1528            read_only: false,
1529        }
1530    }
1531}
1532
1533/// How long a writer polls for the cross-process write lock before giving up
1534/// with [`GraphError::Busy`].
1535///
1536/// Long enough to ride out another process's commit (a batch apply plus one
1537/// fsync), short enough that a stuck peer surfaces as an error rather than a
1538/// hang.
1539pub const WRITE_LOCK_WAIT: std::time::Duration = std::time::Duration::from_secs(2);
1540
1541/// Refusal when a `MERGE` create cannot choose a namespace.
1542///
1543/// A role bound to two or more namespaces cannot have its create arm land in
1544/// `default`, and the statement did not name `ns`. The role must name one.
1545pub const MERGE_CREATE_NEEDS_ONE_NAMESPACE: &str =
1546    "role-bound token: MERGE create requires the role to name one namespace";
1547
1548/// Interval between poll attempts while waiting for the cross-process lock.
1549pub(crate) const LOCK_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(10);
1550
1551/// Why `load_from_disk` is running, which decides whether it may repair.
1552#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1553enum LoadOrigin {
1554    /// A fresh open. Crash recovery is this handle's job: a torn WAL tail is
1555    /// the signature of a crash and truncating it is correct, and archives
1556    /// orphaned by an interrupted prune can be swept.
1557    Open,
1558    /// A reload driven by [`GraphDb::refresh`], because another process
1559    /// replaced the snapshot. Nothing here is crash recovery — the store is
1560    /// live and someone else is writing it — so this origin writes nothing.
1561    Reload,
1562}
1563
1564/// Authorization context carried by `write_batch_authz` / `query_write_authz`.
1565///
1566/// `None` at the call site = full authority (today's zero-cost behavior).
1567/// `Some(WriteAuthz)` = role-scoped: the decision table (plan §"authz decision
1568/// table") is evaluated per-op inside `commit_logged_batch` BEFORE any WAL
1569/// record is built.  A denial returns an error with no WAL frame written.
1570///
1571/// The mask is ALWAYS `Omit`-mode: role-token paths must never acknowledge
1572/// hidden-node existence to callers.
1573#[derive(Clone, Debug)]
1574pub struct WriteAuthz {
1575    pub role: String,
1576    pub scope: WriteScope,
1577    /// Resolved by `mask_for_role` under the same write guard as the mutation.
1578    /// Always `Omit`-mode — never `Stub`.
1579    pub mask: crate::mask::NodeMask,
1580}
1581
1582/// Write `bytes` to `snapshot.bin.bak` atomically with full fsync.
1583///
1584/// Uses [`RealFs::write_atomic`] which applies `F_FULLFSYNC` on macOS and
1585/// `sync_all` on other platforms, then renames the `.tmp` file into place and
1586/// syncs the directory entry. This is the only correct path for writing the
1587/// `.bak` — plain `std::fs::write + sync_all` misses both `F_FULLFSYNC` and
1588/// the directory sync.
1589pub fn write_snapshot_bak(dir: &std::path::Path, bytes: &[u8]) -> crate::Result<()> {
1590    use core_storage::fs::{FileId, Fs as _};
1591    RealFs::new(dir)
1592        .map_err(core_storage::GraphError::Io)?
1593        .write_atomic(FileId::SnapshotBak, bytes)
1594        .map_err(core_storage::GraphError::Io)
1595}
1596
1597/// Return the on-disk snapshot format version without decoding the full snapshot.
1598///
1599/// Reads only the 6-byte header (magic + version LE). Returns `None` when no
1600/// snapshot file exists (WAL-only store). Returns an error if the header is
1601/// malformed.
1602pub fn snapshot_version_at(dir: &std::path::Path) -> crate::Result<Option<u16>> {
1603    use std::io::Read as _;
1604    let path = dir.join("snapshot.bin");
1605    let mut header = [0u8; 6];
1606    let n = match std::fs::File::open(&path) {
1607        Ok(mut f) => f.read(&mut header).map_err(core_storage::GraphError::Io)?,
1608        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1609        Err(e) => return Err(core_storage::GraphError::Io(e)),
1610    };
1611    core_storage::snapshot::peek_version(&header[..n])
1612}
1613
1614/// Options for [`GraphDb::snapshot_with`].
1615#[derive(Debug, Clone, Default)]
1616pub struct SnapshotOptions {
1617    /// When `true`, the WAL is preserved after the snapshot write.
1618    /// Pre-snapshot commits remain reachable via [`GraphDb::open_at`].
1619    /// When `false` (the default), the WAL is truncated to a minimal
1620    /// baseline so cold-start replay stays fast.
1621    pub keep_wal: bool,
1622    /// When `true`, the current WAL is renamed to `wal.<commit_seq>.archive`
1623    /// before a fresh WAL baseline is written (history-preserving snapshot).
1624    ///
1625    /// This is the feature opt-in: `false` (the default) leaves the existing
1626    /// truncation / keep-wal behaviour byte-identical.  `archive_wal` takes
1627    /// precedence over `keep_wal` when both are set.
1628    ///
1629    /// Archives can be scanned by [`GraphDb::node_history`],
1630    /// [`GraphDb::edge_history`], [`GraphDb::was_linked`], and
1631    /// [`GraphDb::open_at`], extending the reachable history horizon across
1632    /// snapshot boundaries.
1633    pub archive_wal: bool,
1634}
1635
1636/// Derive the scan-label sym for the commit-skip fast-path.
1637///
1638/// Walks `ops` to find the plan's leading scan op (`ScanLabel`, `IndexScan`,
1639/// or `IndexIntersect`) with a concrete label string, then interns it.
1640///
1641/// Returns `None` in all cases where skipping is unsafe:
1642/// - Any `Expand` op is present (edge traversal; edges change results regardless
1643///   of node labels).
1644/// - The leading scan has no label (`ScanLabel { label: None }` — full scan).
1645/// - No recognizable leading scan op is found.
1646///
1647/// This is the conservative v0.4.3 boundary. The caller stores the result in
1648/// [`QuerySubEntry::scan_label`] at subscribe time; `None` means always execute.
1649fn extract_scan_label(ops: &[PlanOp], syms: &mut Interner) -> Option<u32> {
1650    // Any Expand → must always re-execute (edges can change join results).
1651    if ops.iter().any(|op| matches!(op, PlanOp::Expand { .. })) {
1652        return None;
1653    }
1654    for op in ops {
1655        match op {
1656            PlanOp::ScanLabel {
1657                label: Some(label), ..
1658            } => return Some(syms.intern(label)),
1659            PlanOp::IndexScan {
1660                label: Some(label), ..
1661            } => return Some(syms.intern(label)),
1662            PlanOp::IndexIntersect {
1663                label: Some(label), ..
1664            } => return Some(syms.intern(label)),
1665            _ => {}
1666        }
1667    }
1668    None
1669}
1670
1671/// How an as-of read is restricted — the argument to
1672/// [`GraphDb::query_at_scoped`].
1673///
1674/// Every variant is resolved against the graph **as it was at the requested
1675/// commit**, not against the current graph.
1676#[derive(Debug, Clone, Copy)]
1677pub enum AsOfScope<'a> {
1678    /// Everything the named role may see. The role *definition* is the current
1679    /// one — `roles.json` is a sidecar and has no past version — but its
1680    /// `keys` and `labels` are resolved against the as-of graph.
1681    Role(&'a str),
1682    /// An explicit node-key allow-list. Keys that did not exist at that commit
1683    /// resolve to nothing.
1684    Keys(&'a [String]),
1685    /// A role intersected with a client-supplied allow-list. The intersection
1686    /// is the never-widen rule: a client mask can only narrow a role.
1687    RoleAndKeys(&'a str, &'a [String]),
1688    /// Every live node in one namespace, as the graph was at that commit.
1689    ///
1690    /// A namespace cannot change — it is set at insert and immutable — so the
1691    /// answer is simply "the nodes that existed then and are in this
1692    /// namespace". A name no node uses resolves to nothing, never to
1693    /// everything.
1694    Namespace(&'a str),
1695}
1696
1697impl GraphDb<RealFs> {
1698    /// Open the database at `dir` with default options.
1699    ///
1700    /// Equivalent to `open_with_options(dir, OpenOptions::default())`.
1701    /// Old-format snapshots (V5, V6) are automatically migrated to the
1702    /// current version on a successful load (see [`OpenOptions::auto_migrate`]).
1703    pub fn open(dir: &std::path::Path) -> Result<Self> {
1704        Self::open_with_options(dir, OpenOptions::default())
1705    }
1706
1707    /// Open the database at `dir` with explicit options.
1708    ///
1709    /// When `opts.auto_migrate` is `true` (the default) and the on-disk
1710    /// snapshot is an older format version, this function:
1711    ///   1. Copies the current `snapshot.bin` to `snapshot.bin.bak` (atomic
1712    ///      + fsynced) before any modification.
1713    ///   2. Rewrites `snapshot.bin` at the current format version via
1714    ///      [`GraphDb::snapshot_with`] with `keep_wal: true` (WAL preserved).
1715    ///
1716    /// If migration fails the error is returned and the original files are
1717    /// intact (the `.bak` was written before the new snapshot was attempted).
1718    ///
1719    /// A clean open that finds the snapshot already at the current version
1720    /// deletes any leftover `.bak` file.
1721    ///
1722    /// WAL-only stores (no snapshot) are never auto-migrated on open.
1723    ///
1724    /// `opts.repair_wal` controls the other write this function can make; see
1725    /// [`OpenOptions::repair_wal`]. With both flags `false` the open touches
1726    /// no file on disk.
1727    pub fn open_with_options(dir: &std::path::Path, opts: OpenOptions) -> Result<Self> {
1728        Self::open_dir(dir, opts, true)
1729    }
1730
1731    /// Open without taking the cross-process write lock for the handle's
1732    /// lifetime.
1733    ///
1734    /// Only [`SharedDb`](crate::SharedDb) uses this: a long-lived server holds
1735    /// its handle open indefinitely, so it takes the lock per write instead of
1736    /// keeping every other process out of the store for as long as it runs.
1737    pub(crate) fn open_unlocked(dir: &std::path::Path) -> Result<Self> {
1738        Self::open_dir(dir, OpenOptions::default(), false)
1739    }
1740
1741    fn open_dir(dir: &std::path::Path, opts: OpenOptions, hold_lock: bool) -> Result<Self> {
1742        // Header-only peek — 6 bytes, no full decode.
1743        let snap_version = snapshot_version_at(dir)?;
1744
1745        // Full load: decode snapshot + replay WAL + rebuild indexes.
1746        let mut db = Self::open_generic(RealFs::new(dir)?, opts, hold_lock)?;
1747
1748        // A read-only handle writes nothing at open, so it never migrates —
1749        // the old-format snapshot is loaded and left exactly as it is.
1750        if opts.auto_migrate && !opts.read_only {
1751            match snap_version {
1752                Some(ver) if ver < core_storage::snapshot::VERSION => {
1753                    let _tm = std::time::Instant::now();
1754                    // Copy the original snapshot to .bak at OS level — no in-memory
1755                    // buffer required for a 2+ GiB file.
1756                    //
1757                    // Crash-safety: snapshot.bin remains intact (write_atomic inside
1758                    // snapshot_with uses a .tmp+rename) until the V8 write succeeds.
1759                    // A torn .bak on crash is acceptable because the original
1760                    // snapshot.bin is the authoritative source until after the rename.
1761                    std::fs::copy(dir.join("snapshot.bin"), dir.join("snapshot.bin.bak"))
1762                        .map_err(core_storage::GraphError::Io)?;
1763                    trace_migrate!("bak copy done", _tm);
1764                    // Rewrite snapshot at current version; keep WAL intact.
1765                    db.snapshot_with(SnapshotOptions {
1766                        keep_wal: true,
1767                        ..SnapshotOptions::default()
1768                    })?;
1769                    trace_migrate!("snapshot_with done", _tm);
1770                }
1771                Some(_) => {
1772                    // Already current version: remove any leftover .bak.
1773                    let bak = dir.join("snapshot.bin.bak");
1774                    if bak.exists() {
1775                        std::fs::remove_file(&bak).map_err(core_storage::GraphError::Io)?;
1776                    }
1777                }
1778                None => {
1779                    // WAL-only store — nothing to migrate on open.
1780                }
1781            }
1782        }
1783
1784        Ok(db)
1785    }
1786
1787    /// Open a read-only view of the database as it existed after `commit`.
1788    ///
1789    /// Commit indices are 0-based over the current WAL: commit 0 is the state
1790    /// after the first WAL frame, commit N-1 is the state after the N-th (most
1791    /// recent) frame.  Call [`GraphDb::open`] to read the full current state.
1792    ///
1793    /// **Replay base.** [`GraphDb::snapshot`] truncates the WAL when it runs,
1794    /// so as-of can only reach commits recorded in the current WAL (those
1795    /// written after the most recent snapshot, or all commits if no snapshot
1796    /// was ever taken).  Commit 0 in `open_at` always refers to the first
1797    /// frame in the WAL that exists on disk, not the first ever write to the
1798    /// database.  When the on-disk snapshot recorded that it truncated the
1799    /// WAL (V7, default `keep_wal: false`), it is loaded as the base state
1800    /// before frame replay, so the as-of view includes all pre-snapshot data.
1801    /// Snapshots written with `keep_wal: true` (and legacy V5/V6 snapshots)
1802    /// are ignored and replay is WAL-only, as before.
1803    ///
1804    /// **Read-only.** Every mutation method and `snapshot()` on the returned
1805    /// instance returns [`GraphError::ReadOnly`].  Queries, `explain()`, and
1806    /// `stats()` work normally.
1807    ///
1808    /// # Errors
1809    /// - [`GraphError::CommitOutOfRange`] if `commit >= wal_commit_count` (including
1810    ///   when the WAL is empty after a snapshot).
1811    pub fn open_at(dir: &std::path::Path, commit: u64) -> Result<Self> {
1812        Self::open_at_with(RealFs::new(dir)?, commit)
1813    }
1814
1815    /// Run a **read-only** Cypher query against the graph as it existed at
1816    /// `commit` — the "time-travel" / agent-replay query. Opens a temporal view
1817    /// of this store's directory at that commit and executes the read there.
1818    ///
1819    /// The current instance is unaffected. Write statements are rejected (the
1820    /// temporal view is read-only). `commit` is a 0-based WAL commit index;
1821    /// `commit == wal_commit_count` (or `open_at`'s range) yields the newest
1822    /// state. Prefer this over holding many historical instances open.
1823    ///
1824    /// # Errors
1825    /// - [`GraphError::CommitOutOfRange`] if `commit` is past the WAL horizon.
1826    /// - A query error for a malformed or write query.
1827    pub fn query_at(
1828        &self,
1829        commit: u64,
1830        cypher: &str,
1831        params: &std::collections::BTreeMap<String, Value>,
1832    ) -> Result<ResultSet> {
1833        let temporal = self.open_at_for_read(commit, cypher)?;
1834        temporal.query(cypher, params)
1835    }
1836
1837    /// Run a **read-only** Cypher query at `commit`, restricted by `scope`.
1838    ///
1839    /// The **graph** is as of `commit`; the **role definition** is as it is
1840    /// now, because `roles.json` is a sidecar and is never a WAL record — it
1841    /// has no past version to read. A role's `keys` and `labels` are resolved
1842    /// against the commit-`commit` graph, so a role that may see a label sees
1843    /// exactly the nodes that carried it then, and an explicit key that did
1844    /// not exist yet resolves to nothing.
1845    ///
1846    /// [`AsOfScope::RoleAndKeys`] intersects the two: a client allow-list can
1847    /// only narrow what a role may see, never widen it.
1848    ///
1849    /// Write statements are rejected, exactly as [`GraphDb::query_at`] rejects
1850    /// them.
1851    ///
1852    /// # Errors
1853    /// - [`GraphError::CommitOutOfRange`] if `commit` is outside the retained
1854    ///   range; the error carries that range.
1855    /// - [`GraphError::KeyNotFound`] with a `role:` prefix for an unknown role,
1856    ///   or [`GraphError::Corrupt`] when `roles.json` was corrupt at open.
1857    /// - A query error for a malformed or write query.
1858    pub fn query_at_scoped(
1859        &self,
1860        commit: u64,
1861        cypher: &str,
1862        params: &std::collections::BTreeMap<String, Value>,
1863        scope: AsOfScope<'_>,
1864    ) -> Result<ResultSet> {
1865        let temporal = self.open_at_for_read(commit, cypher)?;
1866        let mask = temporal.mask_at_scope(scope)?;
1867        temporal.query_masked(cypher, params, &mask)
1868    }
1869
1870    /// As [`GraphDb::query_at_scoped`], with `namespace` intersected into
1871    /// whatever `scope` resolves to.
1872    ///
1873    /// This is what a surface needs when a caller passes `namespace` beside a
1874    /// `role` or a client mask on a time-travel read: [`AsOfScope`] names one
1875    /// restriction, and the namespace is a second one that composes with it
1876    /// rather than replacing it. The intersection is the never-widen rule — a
1877    /// namespace can only narrow what the scope already allows — and both legs
1878    /// are resolved against the graph as it was at `commit`.
1879    ///
1880    /// `AsOfScope::Namespace(ns)` is still the way to ask for a namespace alone.
1881    pub fn query_at_scoped_in_namespace(
1882        &self,
1883        commit: u64,
1884        cypher: &str,
1885        params: &std::collections::BTreeMap<String, Value>,
1886        scope: AsOfScope<'_>,
1887        namespace: &str,
1888    ) -> Result<ResultSet> {
1889        let temporal = self.open_at_for_read(commit, cypher)?;
1890        let mask = temporal
1891            .mask_at_scope(scope)?
1892            .intersect(&temporal.mask_for_namespace(namespace));
1893        temporal.query_masked(cypher, params, &mask)
1894    }
1895
1896    /// Open the temporal view for a time-travel read and refuse write Cypher.
1897    ///
1898    /// Shared by [`GraphDb::query_at`] and [`GraphDb::query_at_scoped`] so both
1899    /// resolve the commit and reject writes identically.
1900    fn open_at_for_read(&self, commit: u64, cypher: &str) -> Result<Self> {
1901        let dir = self.fs.dir().to_path_buf();
1902        let temporal = Self::open_at(&dir, commit)?;
1903        if is_write_tokens(&lex(cypher).map_err(|e| GraphError::QueryError {
1904            detail: format!("lex: {e}"),
1905        })?) {
1906            return Err(GraphError::QueryError {
1907                detail: "query_at is read-only: write statements are not permitted in a \
1908                         time-travel query"
1909                    .into(),
1910            });
1911        }
1912        Ok(temporal)
1913    }
1914}
1915
1916impl<F: Fs> GraphDb<F> {
1917    /// Open over an arbitrary [`Fs`], repairing a torn WAL tail as usual.
1918    pub fn open_with(fs: F) -> Result<Self> {
1919        Self::open_with_repair(fs, true)
1920    }
1921
1922    /// As [`GraphDb::open_with`], but `repair_wal: false` decodes the valid WAL
1923    /// prefix without writing the truncation back. See
1924    /// [`OpenOptions::repair_wal`].
1925    pub fn open_with_repair(fs: F, repair_wal: bool) -> Result<Self> {
1926        Self::open_generic(
1927            fs,
1928            OpenOptions {
1929                repair_wal,
1930                ..OpenOptions::default()
1931            },
1932            true,
1933        )
1934    }
1935
1936    /// Shared open path.
1937    ///
1938    /// `hold_lock` requests the cross-process write lock for the whole handle
1939    /// lifetime — the right behaviour for a plain read-write `GraphDb`, whose
1940    /// owner writes through it directly. [`SharedDb`](crate::SharedDb) passes
1941    /// `false` and takes the lock per write instead, so that a long-lived
1942    /// server does not keep every other process out of the store.
1943    ///
1944    /// A read-only open never takes the lock regardless of `hold_lock`.
1945    fn open_generic(fs: F, opts: OpenOptions, hold_lock: bool) -> Result<Self> {
1946        let mut db = Self::new_empty(fs, opts);
1947        db.read_only = opts.read_only;
1948        if hold_lock && !opts.read_only {
1949            if !db.poll_lock(WRITE_LOCK_WAIT)? {
1950                return Err(GraphError::Busy { holder: None });
1951            }
1952            db.holds_lifetime_lock = true;
1953        }
1954        db.load_from_disk(LoadOrigin::Open)?;
1955        Ok(db)
1956    }
1957
1958    /// A handle with no state loaded: every field at its empty value, the
1959    /// filesystem and options in place. Only [`load_from_disk`] makes it
1960    /// usable.
1961    fn new_empty(fs: F, opts: OpenOptions) -> Self {
1962        Self {
1963            fs,
1964            ids: IdMap::new(),
1965            syms: Interner::new(),
1966            topo: Topology::new(),
1967            props: ColumnStore::new(),
1968            labels: Vec::new(),
1969            ns_names: vec![NS_DEFAULT.to_string()],
1970            node_ns: Vec::new(),
1971            edge_props: EdgeProps::new(),
1972            engine: RuleEngine::new(),
1973            view_store: ViewStore::new(),
1974            fulltext: FulltextIndex::new(),
1975            prop_index: PropertyIndex::new(),
1976            event_sink: None,
1977            fsync: FsyncPolicy::Strict,
1978            commit_seq: 0,
1979            roles: Some(vec![]),
1980            role_masks: Arc::new(crate::mask::RoleMaskCache::new()),
1981            subscriptions: Vec::new(),
1982            query_subscriptions: Vec::new(),
1983            sub_capacity: DEFAULT_SUB_CAPACITY,
1984            read_only: false,
1985            total_wal_commits: 0,
1986            base: None,
1987            fold_overlay: None,
1988            delta_tail: Vec::new(),
1989            commits_since_fold: 0,
1990            defer_events: false,
1991            deferred_events: Vec::new(),
1992            degraded: false,
1993            v8_sections_loaded: std::sync::atomic::AtomicBool::new(false),
1994            v8_sections_mutex: std::sync::Mutex::new(()),
1995            last_change: HashMap::new(),
1996            wal_archive_retention: None,
1997            wal_horizon_floor: 0,
1998            archive_genesis_chain: false,
1999            pending_write_authz: None,
2000            slow_query_threshold_ms: std::env::var("MUSHROOMDB_SLOW_QUERY_MS")
2001                .ok()
2002                .and_then(|v| v.parse().ok())
2003                .unwrap_or(100),
2004            slow_queries: std::sync::Mutex::new(SlowQueryLog {
2005                entries: std::collections::VecDeque::new(),
2006                total: 0,
2007            }),
2008            started_at: std::time::Instant::now(),
2009            wal_consumed: 0,
2010            snapshot_ident: None,
2011            open_opts: opts,
2012            holds_lifetime_lock: false,
2013            lock_denied: false,
2014            pinned: false,
2015        }
2016    }
2017
2018    /// Return every field describing stored graph state to its empty value,
2019    /// leaving this handle's own identity alone.
2020    ///
2021    /// Preserved on purpose: the filesystem, open options, lock ownership, the
2022    /// event sink and subscriptions, fsync policy, degraded flag, and the
2023    /// slow-query configuration and log. A caller that registered a sink or a
2024    /// subscription keeps it across a reload.
2025    fn reset_for_reload(&mut self) {
2026        self.ids = IdMap::new();
2027        self.syms = Interner::new();
2028        self.topo = Topology::new();
2029        self.props = ColumnStore::new();
2030        self.labels = Vec::new();
2031        self.ns_names = vec![NS_DEFAULT.to_string()];
2032        self.node_ns = Vec::new();
2033        self.edge_props = EdgeProps::new();
2034        self.engine = RuleEngine::new();
2035        self.view_store = ViewStore::new();
2036        self.fulltext = FulltextIndex::new();
2037        self.prop_index = PropertyIndex::new();
2038        self.commit_seq = 0;
2039        self.roles = Some(vec![]);
2040        // A fresh cache, not a cleared one: any reader snapshot still holding
2041        // the old `Arc` keeps it to itself, so nothing it memoised against the
2042        // pre-reload store can be read back through this handle.
2043        self.role_masks = Arc::new(crate::mask::RoleMaskCache::new());
2044        self.total_wal_commits = 0;
2045        self.base = None;
2046        self.fold_overlay = None;
2047        self.delta_tail = Vec::new();
2048        self.commits_since_fold = 0;
2049        self.deferred_events = Vec::new();
2050        self.v8_sections_loaded
2051            .store(false, std::sync::atomic::Ordering::Release);
2052        self.last_change = HashMap::new();
2053        self.wal_horizon_floor = 0;
2054        self.archive_genesis_chain = false;
2055        self.pending_write_authz = None;
2056        self.wal_consumed = 0;
2057        self.snapshot_ident = None;
2058    }
2059
2060    /// Load the snapshot base and replay the WAL into an empty handle — the
2061    /// whole of what opening a store does after the struct exists.
2062    ///
2063    /// Split out of the open path so that [`refresh`](GraphDb::refresh) can
2064    /// rebuild a handle in place, without ownership of `F`, when another
2065    /// process replaces the snapshot underneath it.
2066    ///
2067    /// `origin` decides whether the two repair writes this function can make
2068    /// are appropriate; see [`LoadOrigin`].
2069    fn load_from_disk(&mut self, origin: LoadOrigin) -> Result<usize> {
2070        // Both writes below are crash recovery, and only an open is entitled to
2071        // perform them. A read-only handle promises to touch nothing, and a
2072        // reload driven by `refresh` is looking at a store another process is
2073        // actively writing: what looks like a torn tail there is a peer
2074        // mid-append, and what looks like an orphaned archive may be one that
2075        // peer is about to reference.
2076        let may_repair = origin == LoadOrigin::Open && !self.open_opts.read_only;
2077        let repair_wal = self.open_opts.repair_wal && may_repair;
2078        let db = self;
2079        db.wal_horizon_floor = db.fs.read_horizon_floor()?;
2080        db.archive_genesis_chain = db.fs.has_genesis_marker();
2081        // Opening cleanup: remove orphaned archives — archives whose frames all
2082        // fall below the horizon floor.  Orphans arise when a crash interrupted
2083        // the retention-prune sequence after the floor was written but before
2084        // all surplus archives were deleted.  Safe to delete: floor already
2085        // accounts for their frames.
2086        if may_repair {
2087            db.cleanup_orphaned_archives()?;
2088        }
2089        let _t0 = std::time::Instant::now();
2090        // Peek 6 bytes to determine snapshot version without reading the full
2091        // file. For RealFs this is a true partial read (O(1)); for SimFs the
2092        // default impl reads all bytes and truncates (still correct).
2093        let snap_header = db.fs.read_prefix(FileId::Snapshot, 6)?;
2094        // V8 and V9 share the mmap-able container; V9 only adds section 12.
2095        let is_v8 = snap_header.len() >= 6
2096            && &snap_header[0..4] == b"GDB1"
2097            && matches!(
2098                u16::from_le_bytes([snap_header[4], snap_header[5]]),
2099                core_storage::snapshot::VERSION_8 | core_storage::snapshot::VERSION_9
2100            );
2101        if is_v8 {
2102            // V8: map the file zero-copy (RealFs) or read full bytes (SimFs).
2103            // No 2.4GB heap Vec is allocated on RealFs.
2104            let mapped = Arc::new(
2105                if let Some(snap_path) = db.fs.snapshot_path() {
2106                    core_storage::v8::MappedBase::map(&snap_path)
2107                } else {
2108                    let snap_bytes = db.fs.read(FileId::Snapshot)?;
2109                    core_storage::v8::MappedBase::from_bytes(snap_bytes)
2110                }
2111                .map_err(|e| GraphError::Corrupt {
2112                    detail: format!("v8: mmap open: {e:?}"),
2113                })?,
2114            );
2115            db.restore_v8_base(Arc::clone(&mapped))?;
2116            trace_open!("restore_v8_base", _t0);
2117            db.base = Some(mapped);
2118            trace_open!("base assigned", _t0);
2119        } else if !snap_header.is_empty() {
2120            // Legacy V5-V7: full read required for decode.
2121            let snap_bytes = db.fs.read(FileId::Snapshot)?;
2122            if let Some(state) = core_storage::snapshot::decode(&snap_bytes)? {
2123                db.restore_snapshot_state(state)?;
2124            }
2125        }
2126        // else: snap_header is empty = no snapshot file, fresh store.
2127        //
2128        // Seed commit_seq from the highest seq persisted in last_change so that
2129        // WAL-replay frames (which start at commit_seq+1) always exceed any seq
2130        // already stored in the snapshot.  Without this, a db with one snapshot
2131        // commit would save last_change["a"]=1, then on reopen the first WAL
2132        // frame would replay at seq=1 again — colliding and making WAL-tail
2133        // mutations indistinguishable from the snapshot baseline.
2134        //
2135        // Safety invariant (seq-recycling):
2136        //   Recycled seqs (those below the seeded baseline) were NEVER stored in
2137        //   last_change because they belonged to a previous db lifetime — a new
2138        //   db starts at commit_seq=0 with an empty last_change.  Therefore no
2139        //   CAS precondition can carry a recycled seq as its `expected` value
2140        //   and accidentally match a live node's last_change entry.
2141        //
2142        // `expected:0` on a deleted-then-reinserted node:
2143        //   After deletion, last_changed() returns None; callers that call
2144        //   last_changed() and then use NodeUnchangedSince get None.unwrap_or(0)
2145        //   = 0.  The reinserted node gets seq > 0, so a subsequent CAS with
2146        //   expected=0 correctly conflicts.  The only way to observe actual=0 in
2147        //   a CasConflict would be a caller that invented expected=0 without ever
2148        //   calling last_changed() — unreachable via the documented API contract.
2149        if let Some(&max_seq) = db.last_change.values().max() {
2150            db.commit_seq = db.commit_seq.max(max_seq);
2151        }
2152        let bytes = db.fs.read(FileId::Wal)?;
2153        let (records, valid_len) = decode_all(&bytes);
2154        // The valid prefix is replayed either way; `repair_wal` only decides
2155        // whether the truncation is written back. A reader that races a live
2156        // appender must not persist a truncation the writer never asked for.
2157        if valid_len < bytes.len() && repair_wal {
2158            db.fs.write_atomic(FileId::Wal, &bytes[..valid_len])?;
2159        }
2160        // WAL-present path: build indexes eagerly BEFORE replay so that the
2161        // first replayed record does not trigger the lazy-init guard (which
2162        // would call reindex_all_load_state on an empty graph, defeating the
2163        // point of restoring IVF/HNSW blobs from the snapshot).
2164        if !records.is_empty() {
2165            db.ensure_v8_base_sections_loaded();
2166            trace_open!("lazy sections loaded (WAL path)", _t0);
2167        }
2168        let replayed = db.apply_frames(records)?;
2169        // The cursor sits at the end of the valid prefix, not the end of the
2170        // file: a torn or still-being-written tail is unconsumed by definition
2171        // and stays visible to `is_stale` until it decodes.
2172        db.wal_consumed = valid_len as u64;
2173        db.snapshot_ident = db.fs.snapshot_ident().map_err(GraphError::Io)?;
2174        trace_open!("wal replay done", _t0);
2175        // Rebuild view values after WAL replay only when there is no V8 base.
2176        // With a V8 base, view values are correct in the snapshot and are updated
2177        // incrementally during WAL replay (on_edge_changed / on_prop_changed).
2178        // A full rebuild would read overlay-only props (empty after restore_v8_base)
2179        // and overwrite correct base values with wrong results (e.g. NeighborAgg
2180        // Sum reads no "score" in overlay → writes 0.0, shadowing the correct
2181        // base value).
2182        if db.base.is_none() {
2183            let topo_view = TopologyView::owned(&db.topo);
2184            db.view_store
2185                .rebuild_all(&mut db.props, &topo_view, &db.ids, &db.syms, &db.labels);
2186        }
2187        // Rebuild full-text index after WAL replay.  Corrects drift from
2188        // per-record incremental apply during replay.
2189        db.fulltext.rebuild_all(
2190            &db.ids,
2191            &db.labels,
2192            &db.syms,
2193            build_props_view(&db.props, &db.base),
2194        );
2195        db.prop_index.rebuild_all(
2196            &db.ids,
2197            &db.labels,
2198            &db.syms,
2199            build_props_view(&db.props, &db.base),
2200        );
2201        // Namespaces: one pass over the `ns` column, after the snapshot is
2202        // restored and the WAL replayed. Replay maintains `node_ns` record by
2203        // record as well; this pass is what makes a snapshot-only open right,
2204        // and it reads nothing on a store with no `ns` column.
2205        db.rebuild_node_ns();
2206        // A mid-build snapshot's HNSW blob carries `complete == false`.
2207        // Register it so `serve`'s ticker sees work without waiting for a write.
2208        db.register_outstanding_index_builds();
2209        // Load roles sidecar. Missing file = no roles (Some(vec![])).
2210        // Corrupt/unparseable = poisoned (None); mask_for_role will fail-loud.
2211        db.roles = Self::load_roles_from_fs(&db.fs)?;
2212        // Capture the initial MVCC fold so reader() is ready immediately.
2213        db.fold_now();
2214        trace_open!("open_with complete", _t0);
2215        Ok(replayed)
2216    }
2217
2218    /// Apply decoded WAL frames to in-memory state, exactly as the open-path
2219    /// replay does — same `apply` calls, same per-frame delta drain, same
2220    /// commit-seq and last-change bookkeeping. Rules therefore fire and derived
2221    /// edges appear identically whether a frame arrives at open, from a local
2222    /// commit, or from another process by way of [`refresh`](GraphDb::refresh).
2223    ///
2224    /// Returns the number of frames applied.
2225    ///
2226    /// Deltas are drained and discarded per frame: replayed frames are already
2227    /// reflected on disk, so they are not news to a subscriber, and draining
2228    /// inside the loop keeps `pending_deltas` O(1) over a large WAL (I-2).
2229    fn apply_frames(&mut self, records: Vec<WalRecord>) -> Result<usize> {
2230        if records.is_empty() {
2231            return Ok(0);
2232        }
2233        // Materialize any state retained in the mmap base before the first
2234        // frame lands, so a replayed record cannot trip the lazy-init guard and
2235        // rebuild indexes from an empty graph. Both calls are idempotent.
2236        self.ensure_v8_base_sections_loaded();
2237        self.engine.consume_retained_state_eager(
2238            &self.ids,
2239            &self.syms,
2240            &self.labels,
2241            build_props_view(&self.props, &self.base),
2242        );
2243        let applied = records.len();
2244        for rec in records {
2245            self.apply(&rec)?;
2246            let _ = self.engine.drain_deltas();
2247            // Track commit_seq during replay so last_change entries are
2248            // consistent with the seqs assigned by log_then_apply_with on
2249            // subsequent live commits.  After N replayed frames, commit_seq=N;
2250            // live commits begin at N+1.
2251            self.commit_seq += 1;
2252            let replay_seq = self.commit_seq;
2253            self.update_last_change_from_rec(&rec, replay_seq);
2254        }
2255        // Enforce I-2: if the per-frame drain above is ever removed or skipped,
2256        // this assert catches the regression in debug builds immediately.
2257        debug_assert_eq!(
2258            self.engine.pending_delta_count(),
2259            0,
2260            "pending_deltas non-empty after replay — \
2261             per-frame drain must run inside the loop to keep memory O(1)"
2262        );
2263        // T2 note: the per-frame drain IS the suppression seam for replay.
2264        // Any future as-of replay path (Plan-15 T2) must drain here to feed
2265        // replaying subscribers; the mechanism is already in place.
2266        let _ = self.engine.drain_deltas(); // belt-and-braces no-op after loop drain
2267        Ok(applied)
2268    }
2269
2270    // ── Multi-process safety: cross-process write lock + WAL tailing ──────────
2271    //
2272    // mushroomdb is many-readers / one-writer across processes. Writers take an
2273    // advisory exclusive lock on the store's `LOCK` file; readers never do.
2274    // Every handle tracks how much of the WAL it has consumed, so it can pick
2275    // up another process's commits by decoding only the new tail rather than
2276    // reopening. See `docs/site/concurrency.md`.
2277
2278    /// Whether the store on disk has moved ahead of (or out from under) this
2279    /// handle's in-memory state.
2280    ///
2281    /// True when the WAL's length differs from this handle's cursor — another
2282    /// process committed, or is mid-append — or when the snapshot file's
2283    /// identity changed. Costs two metadata lookups and reads no file contents,
2284    /// so it is cheap enough for a read path to call.
2285    ///
2286    /// Always false for an as-of view from [`GraphDb::open_at`]: such a view is
2287    /// pinned to one commit and later commits are deliberately invisible to it.
2288    pub fn is_stale(&self) -> Result<bool> {
2289        if self.pinned {
2290            return Ok(false);
2291        }
2292        if self.fs.wal_len().map_err(GraphError::Io)? != self.wal_consumed {
2293            return Ok(true);
2294        }
2295        Ok(self.fs.snapshot_ident().map_err(GraphError::Io)? != self.snapshot_ident)
2296    }
2297
2298    /// Bring this handle up to date with every commit other processes have made,
2299    /// and return how many frames were applied.
2300    ///
2301    /// The WAL tail is decoded from this handle's cursor and applied through the
2302    /// same path the open replay uses, so rules fire and derived edges appear
2303    /// exactly as they would on a fresh open. Interners, id maps and indexes
2304    /// stay valid for the same reason.
2305    ///
2306    /// A frame another process is still writing is left alone: a trailing
2307    /// partial frame is a wait, not a corruption, and the handle stays stale
2308    /// until that frame is complete. Nothing is written to disk, so a read-only
2309    /// handle can refresh freely.
2310    ///
2311    /// When the snapshot file's identity changed, or the WAL is shorter than
2312    /// this handle's cursor, the WAL no longer continues our state — another
2313    /// process snapshotted or archived. The handle is then rebuilt from disk
2314    /// with the options it was opened with, and the return value is the number
2315    /// of frames in the new WAL.
2316    ///
2317    /// Returns 0 for an as-of view, which never follows later commits.
2318    ///
2319    /// # Errors
2320    ///
2321    /// An error here leaves the handle **degraded**: it got partway through
2322    /// applying the tail, or partway through a reload, so its in-memory state
2323    /// no longer matches any point on disk. Further mutations are refused and
2324    /// the handle must be reopened. Nothing on disk was damaged — the store
2325    /// itself is fine, and a fresh open recovers it.
2326    pub fn refresh(&mut self) -> Result<u64> {
2327        if self.pinned {
2328            return Ok(0);
2329        }
2330        let disk_ident = self.fs.snapshot_ident().map_err(GraphError::Io)?;
2331        let wal_len = self.fs.wal_len().map_err(GraphError::Io)?;
2332        if disk_ident != self.snapshot_ident || wal_len < self.wal_consumed {
2333            // The WAL no longer continues our state: rebuild from disk. State
2334            // is cleared first, so a failed load leaves an empty handle — mark
2335            // it degraded rather than let a caller read an empty graph as if
2336            // it were the store's contents.
2337            self.reset_for_reload();
2338            return match self.load_from_disk(LoadOrigin::Reload) {
2339                Ok(frames) => Ok(frames as u64),
2340                Err(e) => {
2341                    self.degraded = true;
2342                    Err(e)
2343                }
2344            };
2345        }
2346        if wal_len == self.wal_consumed {
2347            return Ok(0);
2348        }
2349        let tail = self
2350            .fs
2351            .read_range(FileId::Wal, self.wal_consumed)
2352            .map_err(GraphError::Io)?;
2353        let (records, valid_len) = decode_all(&tail);
2354        let applied = match self.apply_frames(records) {
2355            Ok(n) => n,
2356            Err(e) => {
2357                // Some frames landed and some did not, and the cursor cannot
2358                // say how many. Advancing it would skip the rest; leaving it
2359                // would replay what already applied. Neither is recoverable in
2360                // place, so refuse further writes and require a reopen.
2361                self.degraded = true;
2362                return Err(e);
2363            }
2364        };
2365        // Advance by the bytes actually decoded, never by the file length: an
2366        // incomplete trailing frame stays unconsumed for the next refresh.
2367        self.wal_consumed += valid_len as u64;
2368        if applied > 0 {
2369            // Peer commits must reach `reader()` snapshots taken from here on.
2370            // A full fold is what open does; refresh does not build per-commit
2371            // deltas, so there is nothing cheaper that stays correct.
2372            self.fold_now();
2373        }
2374        Ok(applied as u64)
2375    }
2376
2377    /// Byte offset of the WAL prefix this handle has applied.
2378    ///
2379    /// Exposed for tests that assert the cursor tracks appended bytes exactly.
2380    #[doc(hidden)]
2381    pub fn wal_consumed(&self) -> u64 {
2382        self.wal_consumed
2383    }
2384
2385    /// Rewind the WAL cursor after the group-commit drain thread truncated a
2386    /// failed group off the tail, so the cursor still describes the file.
2387    pub(crate) fn set_wal_consumed(&mut self, len: u64) {
2388        self.wal_consumed = len;
2389    }
2390
2391    /// One non-blocking attempt at the cross-process write lock.
2392    ///
2393    /// Takes `&self` so a caller can poll for the lock *before* it acquires the
2394    /// in-process write guard. That ordering is what keeps a busy peer in
2395    /// another process from stalling this process's readers.
2396    ///
2397    /// A handle that owns the lock for its lifetime always succeeds.
2398    pub(crate) fn try_cross_process_lock(&self) -> Result<bool> {
2399        if self.holds_lifetime_lock {
2400            return Ok(true);
2401        }
2402        self.fs.try_lock_exclusive().map_err(GraphError::Io)
2403    }
2404
2405    /// Poll for the cross-process write lock until `wait` elapses.
2406    ///
2407    /// One attempt is always made, so a zero wait is a single try. Returns
2408    /// `false` when the lock is still held elsewhere at the deadline; nothing
2409    /// has been written and retrying later is safe.
2410    ///
2411    /// Only the plain-`GraphDb` open path uses this, where the caller owns the
2412    /// handle outright. [`SharedDb`](crate::SharedDb) polls
2413    /// [`try_cross_process_lock`](GraphDb::try_cross_process_lock) itself so
2414    /// that it holds no in-process guard while it waits.
2415    fn poll_lock(&self, wait: std::time::Duration) -> Result<bool> {
2416        let deadline = std::time::Instant::now() + wait;
2417        loop {
2418            if self.try_cross_process_lock()? {
2419                return Ok(true);
2420            }
2421            let now = std::time::Instant::now();
2422            if now >= deadline {
2423                return Ok(false);
2424            }
2425            std::thread::sleep(LOCK_POLL_INTERVAL.min(deadline.saturating_duration_since(now)));
2426        }
2427    }
2428
2429    /// Open a cross-process write scope, given the outcome of an already-made
2430    /// lock attempt.
2431    ///
2432    /// The caller polls for the lock first — outside any in-process guard — and
2433    /// passes what it got. On success this refreshes, so the writes about to
2434    /// happen land on top of every other process's commits. On failure the
2435    /// handle refuses WAL-appending mutations and `snapshot()` with
2436    /// [`GraphError::Busy`] until [`end_write_lock`](GraphDb::end_write_lock)
2437    /// closes the scope, so a caller holding a guard cannot write behind
2438    /// another process's back.
2439    ///
2440    /// A handle that already owns the lock for its lifetime skips the refresh:
2441    /// no other process can have written, so there is nothing to pick up.
2442    pub(crate) fn enter_write_scope(&mut self, acquired: bool) -> Result<()> {
2443        self.lock_denied = !acquired;
2444        if !acquired || self.holds_lifetime_lock {
2445            return Ok(());
2446        }
2447        if let Err(e) = self.refresh() {
2448            // Do not hold a lock we cannot use: release it and let the caller
2449            // see the underlying failure.
2450            let _ = self.fs.unlock();
2451            self.lock_denied = true;
2452            return Err(e);
2453        }
2454        Ok(())
2455    }
2456
2457    /// Close a cross-process write scope opened by
2458    /// [`enter_write_scope`](GraphDb::enter_write_scope): release the lock and
2459    /// clear the Busy latch. Safe to call when the lock was never taken.
2460    pub(crate) fn end_write_lock(&mut self) {
2461        self.lock_denied = false;
2462        if !self.holds_lifetime_lock {
2463            // Releasing a lock we do not hold is a no-op; a failure to release
2464            // is reported by the OS closing the descriptor at handle drop.
2465            let _ = self.fs.unlock();
2466        }
2467    }
2468
2469    /// As-of replay for [`GraphDb::open_at`]: snapshot base (only when the
2470    /// snapshot truncated the WAL) plus the first `commit + 1` WAL frames;
2471    /// see [`GraphDb::open_at`] for the semantics.  The per-frame drain
2472    /// mirrors `open_with` exactly so pending_delta_count is 0 on exit.
2473    /// Restore all persisted state from a decoded snapshot. Shared by
2474    /// `open_with` and (when the snapshot truncated the WAL) `open_at_with`.
2475    fn restore_snapshot_state(
2476        &mut self,
2477        state: core_storage::snapshot::SnapshotState,
2478    ) -> Result<()> {
2479        self.ids = state.ids;
2480        self.syms = state.syms;
2481        self.topo = state.topo;
2482        self.props = state.props;
2483        self.labels = state.labels;
2484        self.edge_props = state.edge_props;
2485        // Cross-section label integrity for V5/V7 snapshots: same invariants as
2486        // restore_v8_base.  A crafted bincode snapshot with a short `labels` vec,
2487        // out-of-range sym ids, or a sentinel label on a live node would otherwise
2488        // open successfully and panic later in `NodeRef::label()` or
2489        // `neighborhood_masked()`.  Catching it here turns those into typed
2490        // `GraphError::Corrupt` at open time.
2491        {
2492            let ids_len = self.ids.len();
2493            if self.labels.len() != ids_len {
2494                return Err(GraphError::Corrupt {
2495                    detail: format!(
2496                        "snapshot: labels vec has {} entries but id table has {} total slots",
2497                        self.labels.len(),
2498                        ids_len,
2499                    ),
2500                });
2501            }
2502            let syms_len = self.syms.len() as u32;
2503            for (i, &sym) in self.labels.iter().enumerate() {
2504                let is_tombstoned = self.ids.is_tombstoned(i as u32);
2505                if sym == u32::MAX {
2506                    if !is_tombstoned {
2507                        return Err(GraphError::Corrupt {
2508                            detail: format!(
2509                                "snapshot: live node at id slot {i} has sentinel label (u32::MAX)"
2510                            ),
2511                        });
2512                    }
2513                } else if sym >= syms_len {
2514                    return Err(GraphError::Corrupt {
2515                        detail: format!(
2516                            "snapshot: label at id slot {i} references sym {sym} \
2517                             which is out of interner range ({syms_len})"
2518                        ),
2519                    });
2520                }
2521            }
2522        }
2523        let defs: Vec<RuleDef> = state
2524            .rule_defs
2525            .iter()
2526            .map(|b| {
2527                decode_rule_def(b).map_err(|e| GraphError::Corrupt {
2528                    detail: format!("snapshot rule_def deserialize: {e}"),
2529                })
2530            })
2531            .collect::<Result<Vec<_>>>()?;
2532        self.engine =
2533            RuleEngine::from_persist(defs, state.provenance, state.rule_tripped, state.rule_fires);
2534        // Candidate indexes are rebuilt lazily on the first mutation (see
2535        // RuleEngine::on_node_changed).  HNSW blobs and IVF centroids from the
2536        // snapshot are retained without deserializing so that:
2537        //   - clean-open (empty WAL): indexes stay empty; blobs load on first
2538        //     ANN query via ensure_hnsw_loaded, or on first mutation via the
2539        //     lazy-init guard which calls reindex_all_load_state (the scan
2540        //     skips the HNSW build for every side the blob supplies).
2541        //   - WAL-present: open_with calls consume_retained_state_eager before
2542        //     replay so HNSW/IVF are live before any record fires the hooks.
2543        let ivf_bytes = if state.ivf_state.is_empty() {
2544            Vec::new()
2545        } else {
2546            bincode::serialize(&state.ivf_state).expect("IVF state serialize cannot fail")
2547        };
2548        // Store blobs without eagerly deserializing them.
2549        // `self.ids` is the snapshot's id table at this point — WAL replay has
2550        // not run — so its length is the line an interrupted build is detected
2551        // against.
2552        let snapshot_ids = self.ids.len() as u32;
2553        self.engine
2554            .store_snapshot_state(state.hnsw_state, ivf_bytes, snapshot_ids);
2555        // Restore view defs from snapshot (V5).
2556        // The ColumnStore already contains view values from the snapshot;
2557        // use restore_view (no collision check, no backfill) so the store
2558        // is aware of the definitions.  rebuild_all runs after WAL replay.
2559        for def_bytes in &state.view_defs {
2560            let def: ViewDef =
2561                bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
2562                    detail: format!("snapshot view_def deserialize: {e}"),
2563                })?;
2564            self.view_store
2565                .restore_view(def)
2566                .map_err(|e| GraphError::Corrupt {
2567                    detail: format!("snapshot view restore: {e}"),
2568                })?;
2569        }
2570        Ok(())
2571    }
2572
2573    /// Restore all persisted state from a V8 `MappedBase` snapshot, **except**
2574    /// topology (`self.topo` stays empty and serves as the WAL-replay overlay).
2575    ///
2576    /// `self.props` IS fully materialised from the base so that HNSW/IVF blob
2577    /// deserialization and view rebuild have access to all column data.
2578    fn restore_v8_base(&mut self, mapped: Arc<core_storage::v8::MappedBase>) -> Result<()> {
2579        self.ids = archived_to_idmap(mapped.ids().map_err(|e| GraphError::Corrupt {
2580            detail: format!("v8: ids section: {e:?}"),
2581        })?);
2582        self.syms = archived_to_interner(mapped.syms().map_err(|e| GraphError::Corrupt {
2583            detail: format!("v8: syms section: {e:?}"),
2584        })?);
2585
2586        // C1: self.props is left as an empty overlay. Column reads go through
2587        // props_view() (ColumnsView::with_base), which consults the archived base
2588        // section zero-copy. This avoids the O(columns) heap copy at every open.
2589
2590        // self.topo deliberately left as Topology::new() — overlay path.
2591
2592        let meta = decode_meta(mapped.meta_bytes().map_err(|e| GraphError::Corrupt {
2593            detail: format!("v8: meta section: {e:?}"),
2594        })?)
2595        .map_err(|e| GraphError::Corrupt {
2596            detail: format!("v8: meta decode: {e:?}"),
2597        })?;
2598        self.labels = meta.labels;
2599        // Cross-section label integrity: labels must cover every id slot (live
2600        // and tombstoned), every non-sentinel sym must be within the interner's
2601        // bound, and no live (non-tombstoned) node may carry the u32::MAX
2602        // sentinel label.  Without this check, a crafted snapshot where the META
2603        // section (small, CRC-validated) holds a short `labels` vec, out-of-range
2604        // sym ids, or a sentinel label on a live node, would open successfully
2605        // and then panic in `NodeRef::label()`, `neighborhood_masked()`, and
2606        // related read paths.  Catching the inconsistency here converts those
2607        // panics into typed `GraphError::Corrupt` at open time.
2608        {
2609            let ids_len = self.ids.len();
2610            if self.labels.len() != ids_len {
2611                return Err(GraphError::Corrupt {
2612                    detail: format!(
2613                        "v8: labels section has {} entries but id table has {} total slots",
2614                        self.labels.len(),
2615                        ids_len,
2616                    ),
2617                });
2618            }
2619            let syms_len = self.syms.len() as u32;
2620            for (i, &sym) in self.labels.iter().enumerate() {
2621                let is_tombstoned = self.ids.is_tombstoned(i as u32);
2622                if sym == u32::MAX {
2623                    // Sentinel is only valid for tombstoned slots.
2624                    if !is_tombstoned {
2625                        return Err(GraphError::Corrupt {
2626                            detail: format!(
2627                                "v8: live node at id slot {i} has sentinel label (u32::MAX)"
2628                            ),
2629                        });
2630                    }
2631                } else if sym >= syms_len {
2632                    return Err(GraphError::Corrupt {
2633                        detail: format!(
2634                            "v8: label at id slot {i} references sym {sym} \
2635                             which is out of interner range ({syms_len})"
2636                        ),
2637                    });
2638                }
2639            }
2640        }
2641        // C3: self.edge_props stays as an empty overlay.  Reads go through
2642        // edge_props_view() which consults the mmap'd base section zero-copy
2643        // via EdgePropsView::with_base.  No heap decode at open time.
2644
2645        // Restore rule engine.
2646        let (rule_def_bytes, rule_tripped, rule_fires) =
2647            archived_rules_meta_to_owned(mapped.rules_meta_section().map_err(|e| {
2648                GraphError::Corrupt {
2649                    detail: format!("v8: rules_meta section: {e:?}"),
2650                }
2651            })?);
2652        let defs: Vec<RuleDef> = rule_def_bytes
2653            .iter()
2654            .map(|b| {
2655                decode_rule_def(b).map_err(|e| GraphError::Corrupt {
2656                    detail: format!("v8: rule_def deserialize: {e}"),
2657                })
2658            })
2659            .collect::<Result<Vec<_>>>()?;
2660        self.engine = RuleEngine::from_persist(defs, BTreeMap::new(), rule_tripped, rule_fires);
2661        // C4+C5: provenance, HNSW, and IVF sections are NOT read here.
2662        // `ensure_v8_base_sections_loaded` reads them on first use from
2663        // `self.base` (set by the caller immediately after this returns).
2664        // A clean open touches only: header + IDS + SYMS + META + RULES_META.
2665
2666        // Restore view definitions.
2667        let view_defs =
2668            archived_views_to_owned(mapped.views_section().map_err(|e| GraphError::Corrupt {
2669                detail: format!("v8: views section: {e:?}"),
2670            })?);
2671        for def_bytes in &view_defs {
2672            let def: ViewDef =
2673                bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
2674                    detail: format!("v8: view_def deserialize: {e}"),
2675                })?;
2676            self.view_store
2677                .restore_view(def)
2678                .map_err(|e| GraphError::Corrupt {
2679                    detail: format!("v8: view restore: {e}"),
2680                })?;
2681        }
2682        // Load the last-change map from section 11 (small section; load eagerly).
2683        // Pre-Task-3 snapshots lack this section; `last_change_bytes` returns &[]
2684        // in that case and `decode_last_change_bytes` returns an empty map.
2685        let last_change_raw = mapped
2686            .last_change_bytes()
2687            .map_err(|e| GraphError::Corrupt {
2688                detail: format!("v8: last_change section: {e:?}"),
2689            })?;
2690        self.last_change = decode_last_change_bytes(last_change_raw);
2691
2692        // Validate that all deferred sections (provenance, HNSW, IVF) fit within
2693        // the file.  Pure bounds check — no bytes read, no page faults triggered.
2694        // Catches truncated snapshots at open time before the lazy deferred reads.
2695        mapped.validate_section_bounds().map_err(|e| match e {
2696            GraphError::Corrupt { detail } => GraphError::Corrupt {
2697                detail: format!("v8: section bounds: {detail}"),
2698            },
2699            other => other,
2700        })?;
2701        Ok(())
2702    }
2703
2704    /// Read provenance, HNSW, and IVF sections from the mmap base into the
2705    /// engine's retained fields on first call.  Subsequent calls are a no-op
2706    /// (AtomicBool fast-path).
2707    ///
2708    /// Must be called before any code path that reads or mutates engine
2709    /// provenance, HNSW, or IVF state:
2710    /// - WAL replay (before `consume_retained_state_eager`)
2711    /// - First mutation (`log_then_apply_with`)
2712    /// - Read-only paths (`stats`, `explain`, `node_edges`)
2713    /// - Snapshot (`snapshot_with`)
2714    ///
2715    /// No-op for fresh stores and V5-V7 opens (`self.base` is `None`).
2716    fn ensure_v8_base_sections_loaded(&self) {
2717        use std::sync::atomic::Ordering;
2718        if self.v8_sections_loaded.load(Ordering::Acquire) {
2719            return;
2720        }
2721        let _guard = self
2722            .v8_sections_mutex
2723            .lock()
2724            .expect("v8 sections mutex poisoned");
2725        if self.v8_sections_loaded.load(Ordering::Acquire) {
2726            return; // another caller populated while we waited
2727        }
2728        let _t = std::time::Instant::now();
2729        if let Some(base) = &self.base {
2730            // Provenance: raw rkyv bytes; CRC validated inside section_bytes.
2731            // Bounds are already validated at open time (restore_v8_base →
2732            // validate_section_bounds) — unreachable post-validate_section_bounds;
2733            // unwrap_or_default is a safety belt against impossible errors.
2734            let prov_bytes = base
2735                .provenance_raw_bytes()
2736                .map(|b| b.to_vec())
2737                .unwrap_or_default();
2738            self.engine.store_provenance_bytes(prov_bytes);
2739            // HNSW: decode rkyv blobs into owned map.
2740            let hnsw_state = base
2741                .hnsw_section()
2742                .map(archived_hnsw_to_owned)
2743                .unwrap_or_default();
2744            // IVF: raw bincode bytes; deserialized on first mutation/query.
2745            let ivf_bytes = base.ivf_bytes().map(|b| b.to_vec()).unwrap_or_default();
2746            // Called before WAL replay on a WAL-present open (`open_with`) and
2747            // before any write on a clean one, so this is the snapshot's count.
2748            let snapshot_ids = self.ids.len() as u32;
2749            self.engine
2750                .store_snapshot_state(hnsw_state, ivf_bytes, snapshot_ids);
2751        }
2752        self.v8_sections_loaded.store(true, Ordering::Release);
2753        if std::env::var("MUSHROOMDB_TRACE_OPEN").is_ok() {
2754            eprintln!(
2755                "[MUSHROOMDB_TRACE_OPEN] ensure_v8_base_sections_loaded: {:>9.3?}",
2756                _t.elapsed()
2757            );
2758        }
2759    }
2760
2761    /// Return a `TopologyView` that merges the mmap'd base (when present) with
2762    /// the in-memory WAL overlay.  Used by all read paths in db.rs that need
2763    /// the full merged topology without going through `self.view()`.
2764    fn topo_view(&self) -> TopologyView<'_> {
2765        match self.base {
2766            None => TopologyView::owned(&self.topo),
2767            Some(ref base) => {
2768                // SAFETY: base lives as long as self; section bounds validated at open.
2769                // topology() uses access_unchecked; all field reads are bounds-checked in seam.rs.
2770                let archived = base
2771                    .topology()
2772                    .expect("base topology section bounds validated at open");
2773                TopologyView::with_base(&self.topo, archived)
2774            }
2775        }
2776    }
2777
2778    /// Return a `ColumnsView` that merges the mmap'd base columns (when a V8
2779    /// snapshot is open) with the in-memory WAL overlay.  Reads consult the
2780    /// overlay first, then fall through to the archived base section zero-copy.
2781    fn props_view(&self) -> core_storage::v8::seam::ColumnsView<'_> {
2782        match self.base {
2783            None => core_storage::v8::seam::ColumnsView::owned(&self.props),
2784            Some(ref base) => {
2785                // columns() uses access_unchecked; field reads are bounds-checked in seam.rs.
2786                let archived = base
2787                    .columns()
2788                    .expect("base columns section bounds validated at open");
2789                core_storage::v8::seam::ColumnsView::with_base_cached(
2790                    &self.props,
2791                    archived,
2792                    base.mixed_cache(),
2793                )
2794                .with_shared_strings(base_string_table(base))
2795            }
2796        }
2797    }
2798
2799    /// Return an `EdgePropsView` that merges the mmap'd base edge-props section
2800    /// (when a V8 snapshot is open) with the in-memory WAL overlay.
2801    ///
2802    /// Reads consult the overlay first (for post-snapshot mutations), then fall
2803    /// through to the archived base section zero-copy.  Tombstones in the
2804    /// overlay mask deleted-from-base entries.
2805    fn edge_props_view(&self) -> EdgePropsView<'_> {
2806        match self.base {
2807            None => EdgePropsView::owned(&self.edge_props),
2808            Some(ref base) => {
2809                // edge_props_section() uses access_unchecked; field reads bounds-checked in seam.rs.
2810                let archived = base
2811                    .edge_props_section()
2812                    .expect("base edge_props section bounds validated at open");
2813                EdgePropsView::with_base(&self.edge_props, archived)
2814            }
2815        }
2816    }
2817
2818    fn open_at_with(fs: F, commit: u64) -> Result<Self> {
2819        // An as-of view never writes and is pinned to one commit: it takes no
2820        // cross-process lock and does not follow later commits.
2821        let mut db = Self::new_empty(
2822            fs,
2823            OpenOptions {
2824                repair_wal: false,
2825                auto_migrate: false,
2826                read_only: true,
2827            },
2828        );
2829        db.pinned = true; // read_only is set after replay, but pinning is immediate
2830        db.wal_horizon_floor = db.fs.read_horizon_floor()?;
2831        db.archive_genesis_chain = db.fs.has_genesis_marker();
2832        // Same orphaned-archive cleanup as open_with: floor was written first
2833        // during pruning, so a crash may have left stale archives below floor.
2834        db.cleanup_orphaned_archives()?;
2835        // Collect archive frames (oldest-first) and live WAL frames.
2836        // Archives represent pre-snapshot history; the snapshot captures the
2837        // cumulative state at the time of archiving.  Crash-window guarantee:
2838        //   A: crash before rename → WAL intact, no archive. Reopen: normal.
2839        //   B: crash after rename, before new WAL → archive present, WAL
2840        //      absent. Reopen: snapshot loaded (full state), no WAL replay.
2841        //   C: crash after new baseline WAL written → normal post-archive.
2842        let archive_ns = db.fs.list_archives()?;
2843        let mut archive_frames_all: Vec<WalRecord> = Vec::new();
2844        for n in &archive_ns {
2845            let arc_bytes = db.fs.read_archive(*n)?;
2846            let (arc_frames, _) = decode_all(&arc_bytes);
2847            archive_frames_all.extend(arc_frames);
2848        }
2849        let total_archive_frames = archive_frames_all.len() as u64;
2850
2851        let live_bytes = db.fs.read(FileId::Wal)?;
2852        let (live_records, _valid_len) = decode_all(&live_bytes);
2853        let total_surviving = total_archive_frames + live_records.len() as u64;
2854        // Global total including any pruned history below the horizon floor.
2855        let total = db.wal_horizon_floor + total_surviving;
2856
2857        // Horizon and range check.
2858        if commit < db.wal_horizon_floor {
2859            return Err(GraphError::CommitOutOfRange {
2860                commit,
2861                total,
2862                floor: db.wal_horizon_floor,
2863            });
2864        }
2865        if commit >= total {
2866            return Err(GraphError::CommitOutOfRange {
2867                commit,
2868                total,
2869                floor: db.wal_horizon_floor,
2870            });
2871        }
2872
2873        // Local index into surviving frames (0 = first frame of oldest archive).
2874        let local = commit - db.wal_horizon_floor;
2875
2876        if local < total_archive_frames {
2877            // Target commit is in an archive.  Correct replay from empty state
2878            // is only possible when the archive chain is an uninterrupted
2879            // genesis chain (first archive taken from a fresh store, no prior
2880            // WAL truncation) and no archives have been pruned (floor == 0).
2881            //
2882            // If either condition is violated the prefix needed to reconstruct
2883            // the requested state is gone; refuse rather than return wrong data.
2884            if db.wal_horizon_floor > 0 || !db.archive_genesis_chain {
2885                return Err(GraphError::CommitOutOfRange {
2886                    commit,
2887                    total,
2888                    floor: db.wal_horizon_floor,
2889                });
2890            }
2891            // Replay all archive frames up to and including the target commit
2892            // from an empty database state.  Archives must be replayed in order
2893            // so that dense-id intern tables are built up correctly.
2894            for rec in archive_frames_all.into_iter().take((local + 1) as usize) {
2895                db.apply(&rec)?;
2896                let _ = db.engine.drain_deltas();
2897            }
2898        } else {
2899            // Target commit is in the live WAL: load snapshot as base, then
2900            // replay the needed live WAL prefix.
2901            //
2902            // Base state: a truncating snapshot (wal_truncated=true) compacts
2903            // all pre-truncation / pre-archive commits.  Dense-id records in
2904            // the live WAL reference ids/interns that the snapshot provides.
2905            // Peek 6 bytes (same pattern as open_with).
2906            let snap_header = db.fs.read_prefix(FileId::Snapshot, 6)?;
2907            let is_v8 = snap_header.len() >= 6
2908                && &snap_header[0..4] == b"GDB1"
2909                && matches!(
2910                    u16::from_le_bytes([snap_header[4], snap_header[5]]),
2911                    core_storage::snapshot::VERSION_8 | core_storage::snapshot::VERSION_9
2912                );
2913            if is_v8 {
2914                let state = if let Some(snap_path) = db.fs.snapshot_path() {
2915                    let mapped = core_storage::v8::MappedBase::map(&snap_path).map_err(|e| {
2916                        GraphError::Corrupt {
2917                            detail: format!("v8: open_at mmap: {e:?}"),
2918                        }
2919                    })?;
2920                    core_storage::snapshot::decode_v8_from_mapped(&mapped)?
2921                } else {
2922                    let snap_bytes = db.fs.read(FileId::Snapshot)?;
2923                    core_storage::snapshot::decode(&snap_bytes)?
2924                };
2925                if let Some(state) = state {
2926                    if state.wal_truncated {
2927                        db.restore_snapshot_state(state)?;
2928                    }
2929                }
2930            } else if !snap_header.is_empty() {
2931                let snap_bytes = db.fs.read(FileId::Snapshot)?;
2932                if let Some(state) = core_storage::snapshot::decode(&snap_bytes)? {
2933                    if state.wal_truncated {
2934                        db.restore_snapshot_state(state)?;
2935                    }
2936                }
2937            }
2938            // else: snap_header empty = no snapshot file.
2939            let live_local = local - total_archive_frames;
2940            for rec in live_records.into_iter().take((live_local + 1) as usize) {
2941                db.apply(&rec)?;
2942                let _ = db.engine.drain_deltas();
2943            }
2944        }
2945        // Pin: pending_delta_count must be 0 after as-of replay, mirroring T1's
2946        // post-loop assert in open_with.
2947        debug_assert_eq!(
2948            db.engine.pending_delta_count(),
2949            0,
2950            "pending_deltas non-empty after open_at replay — \
2951             per-frame drain must run inside the loop to keep memory O(1)"
2952        );
2953        let _ = db.engine.drain_deltas(); // belt-and-braces no-op
2954                                          // Rebuild view values after WAL replay so derived-edge-driven views
2955                                          // reflect the as-of state.  open_at always uses the legacy path (no V8
2956                                          // base), so topo_view is always owned.
2957        {
2958            let topo_view = TopologyView::owned(&db.topo);
2959            db.view_store
2960                .rebuild_all(&mut db.props, &topo_view, &db.ids, &db.syms, &db.labels);
2961        }
2962        // Rebuild full-text index for as-of view (mirrors open_with pattern).
2963        db.fulltext.rebuild_all(
2964            &db.ids,
2965            &db.labels,
2966            &db.syms,
2967            build_props_view(&db.props, &db.base),
2968        );
2969        db.prop_index.rebuild_all(
2970            &db.ids,
2971            &db.labels,
2972            &db.syms,
2973            build_props_view(&db.props, &db.base),
2974        );
2975        // Namespaces on the temporal handle, built by the same pass the live
2976        // open uses, so an as-of mask narrows by the namespaces of that commit.
2977        db.rebuild_node_ns();
2978        // Load roles sidecar (current roles, not point-in-time).
2979        db.roles = Self::load_roles_from_fs(&db.fs)?;
2980        db.read_only = true;
2981        db.total_wal_commits = total;
2982        // Capture initial fold so reader() is immediately usable.
2983        db.fold_now();
2984        Ok(db)
2985    }
2986
2987    /// Whether this instance is a read-only as-of view.
2988    pub fn is_read_only(&self) -> bool {
2989        self.read_only
2990    }
2991
2992    // ── MVCC epoch reader ─────────────────────────────────────────────────────
2993
2994    /// Clone the current overlay state into a new `FrozenOverlay` and reset
2995    /// the delta tail. Called automatically every `FOLD_EVERY_K` commits and at
2996    /// the end of `open_with` / `open_at_with` to prime the reader.
2997    fn fold_now(&mut self) {
2998        let frozen = crate::reader::FrozenOverlay {
2999            ids: self.ids.clone(),
3000            syms: self.syms.clone(),
3001            topo: self.topo.clone(),
3002            props: self.props.clone(),
3003            labels: self.labels.clone(),
3004            edge_props: self.edge_props.clone(),
3005            roles: self.roles.clone(),
3006            fulltext: self.fulltext.clone(),
3007        };
3008        self.fold_overlay = Some(Arc::new(frozen));
3009        self.delta_tail.clear();
3010        self.commits_since_fold = 0;
3011    }
3012
3013    /// Capture a lock-free reader snapshot of the current db state.
3014    ///
3015    /// The read lock is held only for the duration of this call (to clone a
3016    /// handful of `Arc` handles). Subsequent query operations run without any
3017    /// lock.
3018    pub fn reader(&self) -> crate::reader::ReaderSnapshot {
3019        crate::reader::ReaderSnapshot::new(
3020            self.fold_overlay
3021                .clone()
3022                .expect("fold_overlay is always Some after open_with; call reader() after open"),
3023            self.base.clone(),
3024            self.delta_tail.clone(),
3025            // The snapshot's effective state is exactly this handle's state at
3026            // this commit, so it shares the memo and its version key.
3027            self.commit_seq,
3028            Arc::clone(&self.role_masks),
3029        )
3030    }
3031
3032    /// Total number of WAL commits at the time [`open_at`] was called.
3033    /// Returns 0 for normal (non-as-of) instances.
3034    pub fn total_wal_commits(&self) -> u64 {
3035        self.total_wal_commits
3036    }
3037
3038    /// Apply a record to in-memory state. Used by both live writes and replay,
3039    /// so replay is definitionally identical to the original execution.
3040    fn apply(&mut self, rec: &WalRecord) -> Result<()> {
3041        // Before the record mutates anything: a store restored from a snapshot
3042        // defers building its candidate indexes until the first write, and that
3043        // build is a full node scan. Left where it used to fire — inside the
3044        // engine hook, after `props.set` and the label assignment — the scan
3045        // read the half-applied record and took the in-flight node's vector for
3046        // one the snapshot should have carried, which read as an interrupted
3047        // vector-index build and cost a full `RebuildRule` on the first
3048        // embedded write after every reopen. Hoisted here the scan sees exactly
3049        // the persisted state; the record's own hook then files its vector
3050        // through the ordinary insert path a line later.
3051        self.populate_indexes_before_write();
3052        match rec {
3053            WalRecord::InsertNode { label, key, props } => {
3054                let id = self.ids.try_insert(key)?;
3055                let sym = self.syms.intern(label);
3056                if self.labels.len() <= id as usize {
3057                    // gap slots are sentinels, never valid label symbols
3058                    self.labels.resize(id as usize + 1, u32::MAX);
3059                }
3060                self.labels[id as usize] = sym;
3061                let mut ns_name = NS_DEFAULT.to_string();
3062                for (field, value) in props {
3063                    if field == NS_PROP {
3064                        ns_name = namespace_of_value(Some(value)).to_string();
3065                    }
3066                    self.props.set(id, field, value.clone());
3067                }
3068                self.set_node_ns(id, &ns_name);
3069                // Initialize view values for the new node before the engine runs so
3070                // delta-based increments start from a known zero baseline.
3071                self.view_store
3072                    .init_node_views(id, &mut self.props, &self.syms, &self.labels);
3073                // Fire rules for the newly inserted node.
3074                let cursor = self.engine.pending_delta_count();
3075                let mut eng = std::mem::take(&mut self.engine);
3076                {
3077                    let mut gm = make_graph_mut(
3078                        &self.ids,
3079                        &mut self.syms,
3080                        &self.labels,
3081                        build_props_view(&self.props, &self.base),
3082                        &mut self.topo,
3083                        &self.base,
3084                        &mut self.edge_props,
3085                    );
3086                    eng.on_node_changed(id, None, &mut gm);
3087                }
3088                self.engine = eng;
3089                // Process derived-edge deltas for view maintenance.
3090                // Fast path: skip the O(delta_count) allocation when no views exist.
3091                if !self.view_store.is_empty() {
3092                    #[cfg(test)]
3093                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3094                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3095                    for d in &new_deltas {
3096                        self.view_store.on_edge_changed(
3097                            d.etype_sym,
3098                            d.src_id,
3099                            d.dst_id,
3100                            d.fired,
3101                            &mut self.props,
3102                            &build_topo_view(&self.topo, &self.base),
3103                            &self.ids,
3104                            &self.syms,
3105                            &self.labels,
3106                            base_columns(&self.base),
3107                        );
3108                    }
3109                }
3110                // Full-text index maintenance: index enabled fields for this label.
3111                if self.fulltext.has_label(label) {
3112                    for (field, value) in props {
3113                        if self.fulltext.is_enabled(label, field) {
3114                            self.fulltext.add_tokens(id, field, value);
3115                        }
3116                    }
3117                }
3118                // Property (equality) index maintenance.
3119                if self.prop_index.has_label(label) {
3120                    for (field, value) in props {
3121                        self.prop_index.set(label, field, id, value);
3122                    }
3123                }
3124            }
3125            WalRecord::InsertEdge {
3126                edge_type,
3127                src_key,
3128                dst_key,
3129            } => {
3130                let src = self.ids.get(src_key).ok_or_else(|| GraphError::Corrupt {
3131                    detail: format!("wal replay references unknown key {src_key}"),
3132                })?;
3133                let dst = self.ids.get(dst_key).ok_or_else(|| GraphError::Corrupt {
3134                    detail: format!("wal replay references unknown key {dst_key}"),
3135                })?;
3136                let etype = self.syms.intern(edge_type);
3137                // Skip if the edge is already visible in the merged base+overlay
3138                // view.  This keeps WAL replay idempotent when the WAL contains
3139                // pre-snapshot records that are already encoded in a V8 base
3140                // (keep_wal=true opens and crash-before-truncation scenarios).
3141                if self.base.is_some()
3142                    && self
3143                        .topo_view()
3144                        .neighbors(etype, Direction::Out, src)
3145                        .contains(&dst)
3146                {
3147                    return Ok(());
3148                }
3149                self.topo.add_edge(etype, src, dst);
3150                // View maintenance for manual edge insert.
3151                self.view_store.on_edge_changed(
3152                    etype,
3153                    src,
3154                    dst,
3155                    true,
3156                    &mut self.props,
3157                    &build_topo_view(&self.topo, &self.base),
3158                    &self.ids,
3159                    &self.syms,
3160                    &self.labels,
3161                    base_columns(&self.base),
3162                );
3163                // Rule engine: via-hop rules must update when user edges change.
3164                let cursor = self.engine.pending_delta_count();
3165                let mut eng = std::mem::take(&mut self.engine);
3166                {
3167                    let mut gm = make_graph_mut(
3168                        &self.ids,
3169                        &mut self.syms,
3170                        &self.labels,
3171                        build_props_view(&self.props, &self.base),
3172                        &mut self.topo,
3173                        &self.base,
3174                        &mut self.edge_props,
3175                    );
3176                    eng.on_edge_changed(edge_type, src, dst, &mut gm);
3177                }
3178                self.engine = eng;
3179                if !self.view_store.is_empty() {
3180                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3181                    for d in &new_deltas {
3182                        self.view_store.on_edge_changed(
3183                            d.etype_sym,
3184                            d.src_id,
3185                            d.dst_id,
3186                            d.fired,
3187                            &mut self.props,
3188                            &build_topo_view(&self.topo, &self.base),
3189                            &self.ids,
3190                            &self.syms,
3191                            &self.labels,
3192                            base_columns(&self.base),
3193                        );
3194                    }
3195                }
3196            }
3197            WalRecord::SetProp { key, field, value } => {
3198                let id = self.ids.get(key).ok_or_else(|| GraphError::Corrupt {
3199                    detail: format!("wal replay references unknown key {key}"),
3200                })?;
3201                let old_value = build_props_view(&self.props, &self.base)
3202                    .get(id, field)
3203                    .map(|vr| vr.into_value());
3204                self.props.set(id, field, value.clone());
3205                // Fire rules for the changed field.
3206                let cursor = self.engine.pending_delta_count();
3207                let mut eng = std::mem::take(&mut self.engine);
3208                {
3209                    let mut gm = make_graph_mut(
3210                        &self.ids,
3211                        &mut self.syms,
3212                        &self.labels,
3213                        build_props_view(&self.props, &self.base),
3214                        &mut self.topo,
3215                        &self.base,
3216                        &mut self.edge_props,
3217                    );
3218                    eng.on_node_changed(id, Some((field, old_value)), &mut gm);
3219                }
3220                self.engine = eng;
3221                // Derived-edge deltas → view updates.
3222                if !self.view_store.is_empty() {
3223                    #[cfg(test)]
3224                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3225                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3226                    for d in &new_deltas {
3227                        self.view_store.on_edge_changed(
3228                            d.etype_sym,
3229                            d.src_id,
3230                            d.dst_id,
3231                            d.fired,
3232                            &mut self.props,
3233                            &build_topo_view(&self.topo, &self.base),
3234                            &self.ids,
3235                            &self.syms,
3236                            &self.labels,
3237                            base_columns(&self.base),
3238                        );
3239                    }
3240                }
3241                // Neighbor-aggregate views that read `field` must also update.
3242                self.view_store.on_prop_changed(
3243                    id,
3244                    field,
3245                    &mut self.props,
3246                    &build_topo_view(&self.topo, &self.base),
3247                    &self.ids,
3248                    &self.syms,
3249                    &self.labels,
3250                    base_columns(&self.base),
3251                );
3252                // Full-text index maintenance: update tokens for this field if indexed.
3253                if self.fulltext.field_indexed(field) {
3254                    let label_opt = self.labels.get(id as usize).and_then(|&sym| {
3255                        if sym == u32::MAX {
3256                            None
3257                        } else {
3258                            self.syms.resolve(sym)
3259                        }
3260                    });
3261                    if let Some(label) = label_opt {
3262                        if self.fulltext.is_enabled(label, field) {
3263                            self.fulltext.remove_node_field(id, field);
3264                            self.fulltext.add_tokens(id, field, value);
3265                        }
3266                    }
3267                }
3268                // Property (equality) index maintenance: re-key this node's value.
3269                if self.prop_index.field_indexed(field) {
3270                    let label_opt = self.labels.get(id as usize).and_then(|&sym| {
3271                        if sym == u32::MAX {
3272                            None
3273                        } else {
3274                            self.syms.resolve(sym)
3275                        }
3276                    });
3277                    if let Some(label) = label_opt {
3278                        self.prop_index.set(label, field, id, value);
3279                    }
3280                }
3281            }
3282            WalRecord::Intern { id, text } => {
3283                if let Some(existing) = self.syms.get(text) {
3284                    if existing != *id {
3285                        return Err(GraphError::Corrupt {
3286                            detail: format!(
3287                                "wal intern mismatch for {text:?}: have {existing}, record {id}"
3288                            ),
3289                        });
3290                    }
3291                } else {
3292                    let got = self.syms.intern(text);
3293                    if got != *id {
3294                        return Err(GraphError::Corrupt {
3295                            detail: format!(
3296                                "wal intern assigned {got} for {text:?}, record wanted {id}"
3297                            ),
3298                        });
3299                    }
3300                }
3301            }
3302            WalRecord::InsertNodeId { label, key, props } => {
3303                let id = self.ids.try_insert(key)?;
3304                if self.labels.len() <= id as usize {
3305                    self.labels.resize(id as usize + 1, u32::MAX);
3306                }
3307                self.labels[id as usize] = *label;
3308                let label_str = self
3309                    .syms
3310                    .resolve(*label)
3311                    .ok_or_else(|| GraphError::Corrupt {
3312                        detail: format!("wal InsertNodeId unknown label intern {label}"),
3313                    })?
3314                    .to_string();
3315                let mut ns_name = NS_DEFAULT.to_string();
3316                for (field_sym, value) in props {
3317                    let field =
3318                        self.syms
3319                            .resolve(*field_sym)
3320                            .ok_or_else(|| GraphError::Corrupt {
3321                                detail: format!(
3322                                    "wal InsertNodeId unknown field intern {field_sym}"
3323                                ),
3324                            })?;
3325                    if field == NS_PROP {
3326                        ns_name = namespace_of_value(Some(value)).to_string();
3327                    }
3328                    self.props.set(id, field, value.clone());
3329                }
3330                self.set_node_ns(id, &ns_name);
3331                self.view_store
3332                    .init_node_views(id, &mut self.props, &self.syms, &self.labels);
3333                let cursor = self.engine.pending_delta_count();
3334                let mut eng = std::mem::take(&mut self.engine);
3335                {
3336                    let mut gm = make_graph_mut(
3337                        &self.ids,
3338                        &mut self.syms,
3339                        &self.labels,
3340                        build_props_view(&self.props, &self.base),
3341                        &mut self.topo,
3342                        &self.base,
3343                        &mut self.edge_props,
3344                    );
3345                    eng.on_node_changed(id, None, &mut gm);
3346                }
3347                self.engine = eng;
3348                if !self.view_store.is_empty() {
3349                    #[cfg(test)]
3350                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3351                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3352                    for d in &new_deltas {
3353                        self.view_store.on_edge_changed(
3354                            d.etype_sym,
3355                            d.src_id,
3356                            d.dst_id,
3357                            d.fired,
3358                            &mut self.props,
3359                            &build_topo_view(&self.topo, &self.base),
3360                            &self.ids,
3361                            &self.syms,
3362                            &self.labels,
3363                            base_columns(&self.base),
3364                        );
3365                    }
3366                }
3367                if self.fulltext.has_label(&label_str) {
3368                    for (field_sym, value) in props {
3369                        let Some(field) = self.syms.resolve(*field_sym) else {
3370                            continue;
3371                        };
3372                        if self.fulltext.is_enabled(&label_str, field) {
3373                            self.fulltext.add_tokens(id, field, value);
3374                        }
3375                    }
3376                }
3377                if self.prop_index.has_label(&label_str) {
3378                    for (field_sym, value) in props {
3379                        let Some(field) = self.syms.resolve(*field_sym) else {
3380                            continue;
3381                        };
3382                        self.prop_index.set(&label_str, field, id, value);
3383                    }
3384                }
3385            }
3386            WalRecord::InsertEdgeId { etype, src, dst } => {
3387                // Replay-over-snapshot: dense ids in the pre-snapshot WAL may
3388                // already be tombstoned. Skip rather than attaching edges to
3389                // dead ids (DeleteNode keys the live re-insert, not the old id).
3390                if self.ids.is_tombstoned(*src)
3391                    || self.ids.is_tombstoned(*dst)
3392                    || self.ids.key_of(*src).is_none()
3393                    || self.ids.key_of(*dst).is_none()
3394                {
3395                    return Ok(());
3396                }
3397                // Skip if already visible in the merged view (same idempotency
3398                // guard as InsertEdge above: prevents double-counting when
3399                // pre-snapshot WAL records are replayed over a V8 base).
3400                if self.base.is_some()
3401                    && self
3402                        .topo_view()
3403                        .neighbors(*etype, Direction::Out, *src)
3404                        .contains(dst)
3405                {
3406                    return Ok(());
3407                }
3408                self.topo.add_edge(*etype, *src, *dst);
3409                self.view_store.on_edge_changed(
3410                    *etype,
3411                    *src,
3412                    *dst,
3413                    true,
3414                    &mut self.props,
3415                    &build_topo_view(&self.topo, &self.base),
3416                    &self.ids,
3417                    &self.syms,
3418                    &self.labels,
3419                    base_columns(&self.base),
3420                );
3421                // Rule engine: via-hop rules fire when user via-edges are inserted.
3422                // Resolve etype back to string so on_edge_changed can match rules by name.
3423                if let Some(etype_str) = self.syms.resolve(*etype).map(|s| s.to_string()) {
3424                    let cursor = self.engine.pending_delta_count();
3425                    let mut eng = std::mem::take(&mut self.engine);
3426                    {
3427                        let mut gm = make_graph_mut(
3428                            &self.ids,
3429                            &mut self.syms,
3430                            &self.labels,
3431                            build_props_view(&self.props, &self.base),
3432                            &mut self.topo,
3433                            &self.base,
3434                            &mut self.edge_props,
3435                        );
3436                        eng.on_edge_changed(&etype_str, *src, *dst, &mut gm);
3437                    }
3438                    self.engine = eng;
3439                    if !self.view_store.is_empty() {
3440                        let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3441                        for d in &new_deltas {
3442                            self.view_store.on_edge_changed(
3443                                d.etype_sym,
3444                                d.src_id,
3445                                d.dst_id,
3446                                d.fired,
3447                                &mut self.props,
3448                                &build_topo_view(&self.topo, &self.base),
3449                                &self.ids,
3450                                &self.syms,
3451                                &self.labels,
3452                                base_columns(&self.base),
3453                            );
3454                        }
3455                    }
3456                }
3457            }
3458            WalRecord::SetPropId { id, field, value } => {
3459                if self.ids.is_tombstoned(*id) || self.ids.key_of(*id).is_none() {
3460                    return Ok(());
3461                }
3462                let field_str = self
3463                    .syms
3464                    .resolve(*field)
3465                    .ok_or_else(|| GraphError::Corrupt {
3466                        detail: format!("wal SetPropId unknown field intern {field}"),
3467                    })?
3468                    .to_string();
3469                let old_value = build_props_view(&self.props, &self.base)
3470                    .get(*id, &field_str)
3471                    .map(|vr| vr.into_value());
3472                self.props.set(*id, &field_str, value.clone());
3473                let cursor = self.engine.pending_delta_count();
3474                let mut eng = std::mem::take(&mut self.engine);
3475                {
3476                    let mut gm = make_graph_mut(
3477                        &self.ids,
3478                        &mut self.syms,
3479                        &self.labels,
3480                        build_props_view(&self.props, &self.base),
3481                        &mut self.topo,
3482                        &self.base,
3483                        &mut self.edge_props,
3484                    );
3485                    eng.on_node_changed(*id, Some((field_str.as_str(), old_value)), &mut gm);
3486                }
3487                self.engine = eng;
3488                if !self.view_store.is_empty() {
3489                    #[cfg(test)]
3490                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3491                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3492                    for d in &new_deltas {
3493                        self.view_store.on_edge_changed(
3494                            d.etype_sym,
3495                            d.src_id,
3496                            d.dst_id,
3497                            d.fired,
3498                            &mut self.props,
3499                            &build_topo_view(&self.topo, &self.base),
3500                            &self.ids,
3501                            &self.syms,
3502                            &self.labels,
3503                            base_columns(&self.base),
3504                        );
3505                    }
3506                }
3507                self.view_store.on_prop_changed(
3508                    *id,
3509                    &field_str,
3510                    &mut self.props,
3511                    &build_topo_view(&self.topo, &self.base),
3512                    &self.ids,
3513                    &self.syms,
3514                    &self.labels,
3515                    base_columns(&self.base),
3516                );
3517                if self.fulltext.field_indexed(&field_str) {
3518                    let label_opt = self.labels.get(*id as usize).and_then(|&sym| {
3519                        if sym == u32::MAX {
3520                            None
3521                        } else {
3522                            self.syms.resolve(sym)
3523                        }
3524                    });
3525                    if let Some(label) = label_opt {
3526                        if self.fulltext.is_enabled(label, &field_str) {
3527                            self.fulltext.remove_node_field(*id, &field_str);
3528                            self.fulltext.add_tokens(*id, &field_str, value);
3529                        }
3530                    }
3531                }
3532                if self.prop_index.field_indexed(&field_str) {
3533                    let label_opt = self.labels.get(*id as usize).and_then(|&sym| {
3534                        if sym == u32::MAX {
3535                            None
3536                        } else {
3537                            self.syms.resolve(sym)
3538                        }
3539                    });
3540                    if let Some(label) = label_opt {
3541                        self.prop_index.set(label, &field_str, *id, value);
3542                    }
3543                }
3544            }
3545            WalRecord::CreateRule { def_bytes } => {
3546                let def: RuleDef = decode_rule_def(def_bytes).map_err(|e| GraphError::Corrupt {
3547                    detail: format!("CreateRule def_bytes deserialize failed: {e}"),
3548                })?;
3549                // Replay-over-snapshot idempotency: the rule was captured in the snapshot
3550                // so the engine already has it; silently skip to avoid a spurious
3551                // RuleInvalid error in the crash window between snapshot write and WAL
3552                // truncation.
3553                if self.engine.rules().any(|r| r.name == def.name) {
3554                    return Ok(());
3555                }
3556                let cursor = self.engine.pending_delta_count();
3557                let mut eng = std::mem::take(&mut self.engine);
3558                let result = {
3559                    let mut gm = make_graph_mut(
3560                        &self.ids,
3561                        &mut self.syms,
3562                        &self.labels,
3563                        build_props_view(&self.props, &self.base),
3564                        &mut self.topo,
3565                        &self.base,
3566                        &mut self.edge_props,
3567                    );
3568                    eng.create_rule(def, &mut gm)
3569                };
3570                self.engine = eng;
3571                result.map_err(|e| GraphError::RuleInvalid { detail: e })?;
3572                // Derived-edge fires from backfill → view updates.
3573                // Fast path: skip O(edge_count) allocation when no views exist.
3574                if !self.view_store.is_empty() {
3575                    #[cfg(test)]
3576                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3577                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3578                    for d in &new_deltas {
3579                        self.view_store.on_edge_changed(
3580                            d.etype_sym,
3581                            d.src_id,
3582                            d.dst_id,
3583                            d.fired,
3584                            &mut self.props,
3585                            &build_topo_view(&self.topo, &self.base),
3586                            &self.ids,
3587                            &self.syms,
3588                            &self.labels,
3589                            base_columns(&self.base),
3590                        );
3591                    }
3592                }
3593            }
3594            WalRecord::DeleteRule { name } => {
3595                // Replay-over-snapshot idempotency: the snapshot already captured the
3596                // post-delete state so the rule is absent; silently skip to avoid a
3597                // spurious RuleNotFound error in the crash window between snapshot write
3598                // and WAL truncation.
3599                if !self.engine.rules().any(|r| r.name == *name) {
3600                    return Ok(());
3601                }
3602                let cursor = self.engine.pending_delta_count();
3603                let mut eng = std::mem::take(&mut self.engine);
3604                let result = {
3605                    let mut gm = make_graph_mut(
3606                        &self.ids,
3607                        &mut self.syms,
3608                        &self.labels,
3609                        build_props_view(&self.props, &self.base),
3610                        &mut self.topo,
3611                        &self.base,
3612                        &mut self.edge_props,
3613                    );
3614                    eng.delete_rule(name, &mut gm)
3615                };
3616                self.engine = eng;
3617                result.map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
3618                // Derived-edge retractions → view updates.
3619                if !self.view_store.is_empty() {
3620                    #[cfg(test)]
3621                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3622                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3623                    for d in &new_deltas {
3624                        self.view_store.on_edge_changed(
3625                            d.etype_sym,
3626                            d.src_id,
3627                            d.dst_id,
3628                            d.fired,
3629                            &mut self.props,
3630                            &build_topo_view(&self.topo, &self.base),
3631                            &self.ids,
3632                            &self.syms,
3633                            &self.labels,
3634                            base_columns(&self.base),
3635                        );
3636                    }
3637                }
3638            }
3639            WalRecord::RemoveProp { key, field } => {
3640                // Recovery-safe: unknown key or already-absent field is a
3641                // clean no-op. Crash-window replay over a snapshot that
3642                // already applied this record must not Err.
3643                let Some(id) = self.ids.get(key) else {
3644                    return Ok(());
3645                };
3646                // Read old value through the seam for rule retraction.
3647                let old = build_props_view(&self.props, &self.base)
3648                    .get(id, field)
3649                    .map(|vr| vr.into_value());
3650                self.props.remove(id, field);
3651                // If the base still supplies the value after the overlay removal,
3652                // record a tombstone so ColumnsView::get does not resurrect it.
3653                // This covers both the base-only case AND the both-resident case:
3654                //   base-only (in_overlay=false): old prop was only in base, remove
3655                //     is a no-op on overlay, base still visible → tombstone needed.
3656                //   both-resident (in_overlay=true): overlay had v2, base has v1;
3657                //     removing overlay uncovers v1 → tombstone needed.
3658                // Idempotent on double-replay: second pass sees the tombstone →
3659                // get() returns None → condition is false → no duplicate tombstone.
3660                if build_props_view(&self.props, &self.base)
3661                    .get(id, field)
3662                    .is_some()
3663                {
3664                    self.props.record_prop_tombstone(id, field);
3665                }
3666                let cursor = self.engine.pending_delta_count();
3667                let mut eng = std::mem::take(&mut self.engine);
3668                {
3669                    let mut gm = make_graph_mut(
3670                        &self.ids,
3671                        &mut self.syms,
3672                        &self.labels,
3673                        build_props_view(&self.props, &self.base),
3674                        &mut self.topo,
3675                        &self.base,
3676                        &mut self.edge_props,
3677                    );
3678                    eng.on_node_changed(id, Some((field, old)), &mut gm);
3679                }
3680                self.engine = eng;
3681                // Derived-edge deltas → view updates.
3682                if !self.view_store.is_empty() {
3683                    #[cfg(test)]
3684                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3685                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3686                    for d in &new_deltas {
3687                        self.view_store.on_edge_changed(
3688                            d.etype_sym,
3689                            d.src_id,
3690                            d.dst_id,
3691                            d.fired,
3692                            &mut self.props,
3693                            &build_topo_view(&self.topo, &self.base),
3694                            &self.ids,
3695                            &self.syms,
3696                            &self.labels,
3697                            base_columns(&self.base),
3698                        );
3699                    }
3700                }
3701                // Neighbor-aggregate views that read `field` must also update.
3702                self.view_store.on_prop_changed(
3703                    id,
3704                    field,
3705                    &mut self.props,
3706                    &build_topo_view(&self.topo, &self.base),
3707                    &self.ids,
3708                    &self.syms,
3709                    &self.labels,
3710                    base_columns(&self.base),
3711                );
3712                // Full-text index maintenance: remove tokens for this field.
3713                if self.fulltext.field_indexed(field) {
3714                    self.fulltext.remove_node_field(id, field);
3715                }
3716                // Property (equality) index maintenance: drop this node's entry.
3717                if self.prop_index.field_indexed(field) {
3718                    if let Some(label) = self.labels.get(id as usize).and_then(|&sym| {
3719                        (sym != u32::MAX).then(|| self.syms.resolve(sym)).flatten()
3720                    }) {
3721                        self.prop_index.remove_node(label, field, id);
3722                    }
3723                }
3724            }
3725            WalRecord::DeleteEdge {
3726                edge_type,
3727                src_key,
3728                dst_key,
3729            } => {
3730                // Recovery-safe: unknown keys, unknown etype, or already-
3731                // absent edge is a clean no-op (remove_edge returns false).
3732                let Some(src) = self.ids.get(src_key) else {
3733                    return Ok(());
3734                };
3735                let Some(dst) = self.ids.get(dst_key) else {
3736                    return Ok(());
3737                };
3738                let Some(etype) = self.syms.get(edge_type) else {
3739                    return Ok(());
3740                };
3741                // I3: phantom-tombstone guard.  When a V8 base is present, a
3742                // DeleteEdge WAL record for an edge that was already absorbed into
3743                // the new base (i.e. neither in overlay nor in base) must be skipped.
3744                // Without this guard, remove_edge records a tombstone for an edge
3745                // that no longer exists, incorrectly understating edge_count.
3746                if self.base.is_some()
3747                    && !self
3748                        .topo_view()
3749                        .neighbors(etype, core_storage::topology::Direction::Out, src)
3750                        .contains(&dst)
3751                {
3752                    return Ok(());
3753                }
3754                self.topo.remove_edge(etype, src, dst);
3755                self.edge_props.remove_edge(etype, src, dst);
3756                // View maintenance for manual edge delete (topo already updated above).
3757                self.view_store.on_edge_changed(
3758                    etype,
3759                    src,
3760                    dst,
3761                    false,
3762                    &mut self.props,
3763                    &build_topo_view(&self.topo, &self.base),
3764                    &self.ids,
3765                    &self.syms,
3766                    &self.labels,
3767                    base_columns(&self.base),
3768                );
3769                // Rule engine: via-hop rules must retract when user via-edges are deleted.
3770                let cursor = self.engine.pending_delta_count();
3771                let mut eng = std::mem::take(&mut self.engine);
3772                {
3773                    let mut gm = make_graph_mut(
3774                        &self.ids,
3775                        &mut self.syms,
3776                        &self.labels,
3777                        build_props_view(&self.props, &self.base),
3778                        &mut self.topo,
3779                        &self.base,
3780                        &mut self.edge_props,
3781                    );
3782                    eng.on_edge_changed(edge_type, src, dst, &mut gm);
3783                }
3784                self.engine = eng;
3785                if !self.view_store.is_empty() {
3786                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3787                    for d in &new_deltas {
3788                        self.view_store.on_edge_changed(
3789                            d.etype_sym,
3790                            d.src_id,
3791                            d.dst_id,
3792                            d.fired,
3793                            &mut self.props,
3794                            &build_topo_view(&self.topo, &self.base),
3795                            &self.ids,
3796                            &self.syms,
3797                            &self.labels,
3798                            base_columns(&self.base),
3799                        );
3800                    }
3801                }
3802            }
3803            WalRecord::DeleteNode { key } => {
3804                // Recovery-safe: already-tombstoned / unknown key is a clean
3805                // no-op. Crash-window replay over a snapshot that already
3806                // applied this record cannot recover the retired id from the
3807                // key (`IdMap::get` is None), so every subsequent step is
3808                // skipped. Each step is independently idempotent if invoked
3809                // twice on a still-live id: retraction is a no-op on empty
3810                // provenance, `remove_edge` returns false, `remove_all` is a
3811                // no-op, `ids.delete` returns None, label sentinel is sticky.
3812                let Some(n) = self.ids.get(key) else {
3813                    return Ok(());
3814                };
3815
3816                // (1) Retract derived edges + de-index while props/labels live.
3817                let cursor = self.engine.pending_delta_count();
3818                let mut eng = std::mem::take(&mut self.engine);
3819                {
3820                    let mut gm = make_graph_mut(
3821                        &self.ids,
3822                        &mut self.syms,
3823                        &self.labels,
3824                        build_props_view(&self.props, &self.base),
3825                        &mut self.topo,
3826                        &self.base,
3827                        &mut self.edge_props,
3828                    );
3829                    eng.on_node_removed(n, &mut gm);
3830                }
3831                self.engine = eng;
3832                // Derived-edge retractions → view updates for neighbors.
3833                if !self.view_store.is_empty() {
3834                    #[cfg(test)]
3835                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3836                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3837                    for d in &new_deltas {
3838                        self.view_store.on_edge_changed(
3839                            d.etype_sym,
3840                            d.src_id,
3841                            d.dst_id,
3842                            d.fired,
3843                            &mut self.props,
3844                            &build_topo_view(&self.topo, &self.base),
3845                            &self.ids,
3846                            &self.syms,
3847                            &self.labels,
3848                            base_columns(&self.base),
3849                        );
3850                    }
3851                }
3852
3853                // (2) Sweep ALL remaining edges incident to n, both directions,
3854                // every etype. This cascade is intentionally mask-independent:
3855                // topology integrity requires removing every edge touching the
3856                // deleted node regardless of the caller's visibility scope.
3857                // (The mask limits which nodes a role's read phase can return;
3858                // the WAL delete always executes with full storage authority.)
3859                // Collect then remove so neighbor slices stay valid during
3860                // iteration. Remove from topo first, then call view maintenance
3861                // so Avg/Min/Max recompute sees the correct (reduced) neighbor set.
3862                let etypes: Vec<u32> = self.topo.etypes().collect();
3863                let mut doomed = Vec::new();
3864                for et in &etypes {
3865                    for &dst in self.topo.neighbors(*et, Direction::Out, n).as_ref() {
3866                        doomed.push((*et, n, dst));
3867                    }
3868                    for &src in self.topo.neighbors(*et, Direction::In, n).as_ref() {
3869                        doomed.push((*et, src, n));
3870                    }
3871                }
3872                for (et, s, d) in doomed {
3873                    self.topo.remove_edge(et, s, d);
3874                    self.edge_props.remove_edge(et, s, d);
3875                    // View maintenance: n's own view values will be cleared by
3876                    // remove_all below; only update surviving neighbors.
3877                    self.view_store.on_edge_changed(
3878                        et,
3879                        s,
3880                        d,
3881                        false,
3882                        &mut self.props,
3883                        &build_topo_view(&self.topo, &self.base),
3884                        &self.ids,
3885                        &self.syms,
3886                        &self.labels,
3887                        base_columns(&self.base),
3888                    );
3889                }
3890
3891                // (3) Drop every remaining prop (`ColumnStore::remove_all`).
3892                self.props.remove_all(n);
3893                // Full-text index maintenance: remove all tokens for this node.
3894                self.fulltext.remove_node(n);
3895                // Property (equality) index maintenance: drop all entries for n.
3896                self.prop_index.remove_node_all(n);
3897
3898                // (4) Retire the dense id and stamp the label sentinel.
3899                self.ids.delete(key);
3900                if let Some(slot) = self.labels.get_mut(n as usize) {
3901                    *slot = u32::MAX;
3902                }
3903            }
3904            WalRecord::Batch(inner) => {
3905                // Apply each inner record in order through the same apply path.
3906                // Inner records are validated free of nested Batch by encode_record.
3907                for rec in inner {
3908                    self.apply(rec)?;
3909                }
3910            }
3911            WalRecord::RebuildRule { name } => {
3912                // Replay-over-snapshot idempotency: the snapshot may already
3913                // reflect a later delete_rule, so the rule is absent; skip.
3914                if !self.engine.rules().any(|r| r.name == *name) {
3915                    return Ok(());
3916                }
3917                let cursor = self.engine.pending_delta_count();
3918                let mut eng = std::mem::take(&mut self.engine);
3919                let result = {
3920                    let mut gm = make_graph_mut(
3921                        &self.ids,
3922                        &mut self.syms,
3923                        &self.labels,
3924                        build_props_view(&self.props, &self.base),
3925                        &mut self.topo,
3926                        &self.base,
3927                        &mut self.edge_props,
3928                    );
3929                    eng.rebuild(name, &mut gm)
3930                };
3931                self.engine = eng;
3932                result.map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
3933                // Derived-edge delta changes → view updates.
3934                if !self.view_store.is_empty() {
3935                    #[cfg(test)]
3936                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3937                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3938                    for d in &new_deltas {
3939                        self.view_store.on_edge_changed(
3940                            d.etype_sym,
3941                            d.src_id,
3942                            d.dst_id,
3943                            d.fired,
3944                            &mut self.props,
3945                            &build_topo_view(&self.topo, &self.base),
3946                            &self.ids,
3947                            &self.syms,
3948                            &self.labels,
3949                            base_columns(&self.base),
3950                        );
3951                    }
3952                }
3953            }
3954            WalRecord::CreateView { def_bytes } => {
3955                let def: ViewDef =
3956                    bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
3957                        detail: format!("CreateView def_bytes deserialize failed: {e}"),
3958                    })?;
3959                // Replay-over-snapshot idempotency: view already present → skip.
3960                if self.view_store.has_view(&def.name) {
3961                    return Ok(());
3962                }
3963                self.view_store
3964                    .create_view(
3965                        def,
3966                        &mut self.props,
3967                        &build_topo_view(&self.topo, &self.base),
3968                        &self.ids,
3969                        &self.syms,
3970                        &self.labels,
3971                    )
3972                    .map_err(|e| GraphError::RuleInvalid { detail: e })?;
3973            }
3974            WalRecord::DeleteView { name } => {
3975                // Replay-over-snapshot idempotency: view already absent → skip.
3976                if !self.view_store.has_view(name) {
3977                    return Ok(());
3978                }
3979                self.view_store
3980                    .delete_view(name, &mut self.props, &self.ids, &self.labels, &self.syms)
3981                    .map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
3982            }
3983            WalRecord::EnableFulltext { label, field } => {
3984                // Replay-over-snapshot idempotency: already enabled → skip.
3985                if self.fulltext.is_enabled(label, field) {
3986                    return Ok(());
3987                }
3988                self.fulltext.enable(label, field);
3989                // Backfill: index all live nodes of this label that have the field.
3990                let n = self.ids.len() as u32;
3991                for id in 0..n {
3992                    let Some(&sym) = self.labels.get(id as usize) else {
3993                        continue;
3994                    };
3995                    if sym == u32::MAX {
3996                        continue; // tombstoned
3997                    }
3998                    let Some(lbl) = self.syms.resolve(sym) else {
3999                        continue;
4000                    };
4001                    if lbl != label {
4002                        continue;
4003                    }
4004                    if let Some(value) = build_props_view(&self.props, &self.base)
4005                        .get(id, field)
4006                        .map(|vr| vr.into_value())
4007                    {
4008                        self.fulltext.add_tokens(id, field, &value);
4009                    }
4010                }
4011            }
4012            WalRecord::DisableFulltext { label, field } => {
4013                // Replay-over-snapshot idempotency: already disabled → skip.
4014                if !self.fulltext.is_enabled(label, field) {
4015                    return Ok(());
4016                }
4017                // If another label still indexes this field, the postings column
4018                // is kept — but it must not contain node_ids from the now-disabled
4019                // label.  Remove them before calling disable() so the field_indexed
4020                // guard inside disable() sees the correct post-removal state.
4021                if self.fulltext.field_indexed_by_other(label, field) {
4022                    if let Some(label_sym) = self.syms.get(label) {
4023                        for (node_id, &lsym) in self.labels.iter().enumerate() {
4024                            if lsym == label_sym {
4025                                self.fulltext.remove_node_field(node_id as u32, field);
4026                            }
4027                        }
4028                    }
4029                }
4030                self.fulltext.disable(label, field);
4031            }
4032            WalRecord::EnableIndex { label, field } => {
4033                // Replay-over-snapshot idempotency: already enabled → skip.
4034                if self.prop_index.is_enabled(label, field) {
4035                    return Ok(());
4036                }
4037                self.prop_index.enable(label, field);
4038                // Backfill: index all live nodes of this label that have the field.
4039                let n = self.ids.len() as u32;
4040                for id in 0..n {
4041                    let Some(&sym) = self.labels.get(id as usize) else {
4042                        continue;
4043                    };
4044                    if sym == u32::MAX {
4045                        continue; // tombstoned
4046                    }
4047                    let Some(lbl) = self.syms.resolve(sym) else {
4048                        continue;
4049                    };
4050                    if lbl != label {
4051                        continue;
4052                    }
4053                    if let Some(value) = build_props_view(&self.props, &self.base)
4054                        .get(id, field)
4055                        .map(|vr| vr.into_value())
4056                    {
4057                        self.prop_index.set(label, field, id, &value);
4058                    }
4059                }
4060            }
4061            WalRecord::DisableIndex { label, field } => {
4062                self.prop_index.disable(label, field);
4063            }
4064            // History markers carry no replay state — rules re-derive edges
4065            // deterministically on open/replay. Skip unconditionally.
4066            WalRecord::DerivedEdgeAdded { .. } | WalRecord::DerivedEdgeRetracted { .. } => {}
4067            // ── rename_node ──────────────────────────────────────────────────
4068            WalRecord::RenameNode { old_key, new_key } => {
4069                // Recovery-safe: if old_key is already gone (key was renamed
4070                // by a snapshot or a prior replay frame), skip cleanly.
4071                if self.ids.get(old_key).is_none() {
4072                    return Ok(());
4073                }
4074                // The rename only updates the key-table; the dense id, all
4075                // topo edges, props, labels, and rule state are id-indexed and
4076                // require no change.
4077                self.ids
4078                    .rename(old_key, new_key)
4079                    .map_err(|e| GraphError::Corrupt {
4080                        detail: format!("wal replay RenameNode {old_key}→{new_key}: {e}"),
4081                    })?;
4082            }
4083        }
4084        Ok(())
4085    }
4086
4087    /// Intern `s` in `syms` and emit a WAL `Intern` record so `*Id` records
4088    /// replay on WAL-only `open_at` (no snapshot intern table). Apply is
4089    /// idempotent when the string is already bound. Always emit: after
4090    /// `snapshot()` the WAL is truncated and live intern is not on disk.
4091    fn intern_wal(&mut self, s: &str) -> (u32, WalRecord) {
4092        let id = if let Some(id) = self.syms.get(s) {
4093            id
4094        } else {
4095            self.syms.intern(s)
4096        };
4097        (
4098            id,
4099            WalRecord::Intern {
4100                id,
4101                text: s.to_string(),
4102            },
4103        )
4104    }
4105
4106    /// Rewrite user-facing records into dense-id records. On `Err`, no live
4107    /// state is left mutated: speculative interns made while building the
4108    /// output are rolled back, so a later successful mutation cannot log an
4109    /// `Intern` record whose id replay would never reproduce.
4110    fn rewrite_wal_dense(&mut self, recs: Vec<WalRecord>) -> Result<Vec<WalRecord>> {
4111        let syms_checkpoint = self.syms.len();
4112        let result = self.rewrite_wal_dense_inner(recs);
4113        if result.is_err() {
4114            self.syms.truncate(syms_checkpoint);
4115        }
4116        result
4117    }
4118
4119    fn rewrite_wal_dense_inner(&mut self, recs: Vec<WalRecord>) -> Result<Vec<WalRecord>> {
4120        let mut out = Vec::with_capacity(recs.len());
4121        // Node ids allocated by later apply(InsertNodeId) in this same batch.
4122        let mut pending: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
4123        // Namespace of each node inserted earlier in this same frame, so a SET
4124        // on a node this frame created is measured against the namespace it was
4125        // created in rather than against the store, where it does not exist yet.
4126        let mut pending_ns: std::collections::HashMap<String, String> =
4127            std::collections::HashMap::new();
4128        let mut interned = std::collections::HashSet::<u32>::new();
4129        let mut next = u32::try_from(self.ids.len()).map_err(|_| GraphError::Corrupt {
4130            detail: "id space exhausted".into(),
4131        })?;
4132        let lookup = |ids: &IdMap,
4133                      pending: &std::collections::HashMap<String, u32>,
4134                      key: &str|
4135         -> Option<u32> { ids.get(key).or_else(|| pending.get(key).copied()) };
4136        for rec in recs {
4137            match rec {
4138                WalRecord::InsertNode { label, key, props } => {
4139                    // Namespace validation and normalisation, on the one seam
4140                    // every user-visible node insert passes through: insert_node,
4141                    // a batch, ingest, Cypher CREATE and MERGE all arrive here
4142                    // before the WAL append, and replay never does.
4143                    let (props, ns_name) = Self::normalise_insert_ns(&key, props)?;
4144                    pending_ns.insert(key.clone(), ns_name);
4145                    let (label_id, intern) = self.intern_wal(&label);
4146                    if interned.insert(label_id) {
4147                        out.push(intern);
4148                    }
4149                    let mut props_id = Vec::with_capacity(props.len());
4150                    for (field, value) in props {
4151                        let (field_id, intern) = self.intern_wal(&field);
4152                        if interned.insert(field_id) {
4153                            out.push(intern);
4154                        }
4155                        props_id.push((field_id, value));
4156                    }
4157                    if lookup(&self.ids, &pending, &key).is_none() {
4158                        pending.insert(key.clone(), next);
4159                        next = next.checked_add(1).ok_or_else(|| GraphError::Corrupt {
4160                            detail: "id space exhausted".into(),
4161                        })?;
4162                    }
4163                    out.push(WalRecord::InsertNodeId {
4164                        label: label_id,
4165                        key,
4166                        props: props_id,
4167                    });
4168                }
4169                WalRecord::SetProp { key, field, value } => {
4170                    // A namespace is set at insert and fixed after: the write is
4171                    // refused when it would move the node, and dropped when it
4172                    // names the namespace the node is already in. Checked here
4173                    // so set_prop, a batch, Cypher SET/MERGE and every upsert
4174                    // that merges props get the same answer.
4175                    if field == NS_PROP {
4176                        let Value::Str(ref to) = value else {
4177                            return Err(GraphError::RuleInvalid {
4178                                detail: format!(
4179                                    "node {key}: {NS_PROP} must be a string naming a namespace, \
4180                                     got {value:?}"
4181                                ),
4182                            });
4183                        };
4184                        let from = pending_ns
4185                            .get(&key)
4186                            .cloned()
4187                            .or_else(|| self.namespace_of(&key))
4188                            .unwrap_or_else(|| NS_DEFAULT.to_string());
4189                        let to = to.clone();
4190                        if to != from {
4191                            return Err(GraphError::NamespaceImmutable {
4192                                key: key.clone(),
4193                                from,
4194                                to,
4195                            });
4196                        }
4197                        continue;
4198                    }
4199                    let id =
4200                        lookup(&self.ids, &pending, &key).ok_or_else(|| GraphError::Corrupt {
4201                            detail: format!("dense WAL rewrite missing key {key}"),
4202                        })?;
4203                    let (field_id, intern) = self.intern_wal(&field);
4204                    if interned.insert(field_id) {
4205                        out.push(intern);
4206                    }
4207                    out.push(WalRecord::SetPropId {
4208                        id,
4209                        field: field_id,
4210                        value,
4211                    });
4212                }
4213                WalRecord::InsertEdge {
4214                    edge_type,
4215                    src_key,
4216                    dst_key,
4217                } => {
4218                    let (etype, intern) = self.intern_wal(&edge_type);
4219                    if interned.insert(etype) {
4220                        out.push(intern);
4221                    }
4222                    let src = lookup(&self.ids, &pending, &src_key).ok_or_else(|| {
4223                        GraphError::Corrupt {
4224                            detail: format!("dense WAL rewrite missing src {src_key}"),
4225                        }
4226                    })?;
4227                    let dst = lookup(&self.ids, &pending, &dst_key).ok_or_else(|| {
4228                        GraphError::Corrupt {
4229                            detail: format!("dense WAL rewrite missing dst {dst_key}"),
4230                        }
4231                    })?;
4232                    out.push(WalRecord::InsertEdgeId { etype, src, dst });
4233                }
4234                WalRecord::RenameNode {
4235                    ref old_key,
4236                    ref new_key,
4237                } => {
4238                    // Track the rename in `pending` so subsequent InsertEdge /
4239                    // SetProp records in this batch can resolve the new key.
4240                    let id = lookup(&self.ids, &pending, old_key).ok_or_else(|| {
4241                        GraphError::Corrupt {
4242                            detail: format!(
4243                                "dense WAL rewrite: RenameNode old key {old_key} not found"
4244                            ),
4245                        }
4246                    })?;
4247                    pending.remove(old_key.as_str());
4248                    pending.insert(new_key.clone(), id);
4249                    out.push(rec);
4250                }
4251                // # Symbol-order invariant (load-bearing)
4252                //
4253                // Write-time and replay-time symbol assignment must agree: every
4254                // symbol in a `Batch` frame has to receive the same dense id when
4255                // the frame's records are replayed in order as it received when
4256                // the frame was written.
4257                //
4258                // A rule's backfill interns its `edge_type` lazily
4259                // (`core_rules::engine`, every `g.syms.intern(&def.edge_type)`
4260                // site), and that backfill runs from `apply` — during the
4261                // `CreateRule` record itself, and again from any later
4262                // `InsertNodeId` in the same frame that makes the rule fire. At
4263                // write time the whole batch is rewritten before any of it is
4264                // applied, so a later `InsertEdge` in the same batch would win the
4265                // lower id for its edge type; on replay the rule's lazy intern
4266                // gets there first and steals it, and the `Intern` record fails at
4267                // the `wal intern assigned …` check in `apply`.
4268                //
4269                // Pre-interning the rule's `edge_type` here, and emitting its
4270                // `Intern` record ahead of the `CreateRule` record, makes both
4271                // orders identical. `weight_prop` needs no pre-intern:
4272                // `EdgeProps::set` keys props by `String`, never through the
4273                // interner. `via_edge` needs none either: via-hop rules resolve it
4274                // with `syms.get` and skip when it is absent.
4275                //
4276                // `RebuildRule` and `DeleteRule` need no such handling here:
4277                // `RebuildRule` has no `BatchOp` variant, so it never appears
4278                // inside a `Batch` today — it is only ever issued as its own
4279                // standalone commit (`rebuild_rule`, or the auto-rebuild path
4280                // that logs it as a second commit after the triggering op).
4281                // `DeleteRule` does have a `BatchOp` variant and can appear
4282                // inside a `Batch`, but it carries only a rule `name` — no
4283                // `edge_type` or other symbol that needs pre-interning — so
4284                // only `CreateRule` needs this arm.
4285                WalRecord::CreateRule { ref def_bytes } => {
4286                    let def = decode_rule_def(def_bytes).map_err(|e| GraphError::Corrupt {
4287                        detail: format!("CreateRule def_bytes deserialize failed: {e}"),
4288                    })?;
4289                    let (etype, intern) = self.intern_wal(&def.edge_type);
4290                    if interned.insert(etype) {
4291                        out.push(intern);
4292                    }
4293                    out.push(rec);
4294                }
4295                other => out.push(other),
4296            }
4297        }
4298        Ok(out)
4299    }
4300
4301    fn log_dense(&mut self, recs: Vec<WalRecord>) -> Result<()> {
4302        let recs = self.rewrite_wal_dense(recs)?;
4303        match recs.len() {
4304            0 => Ok(()),
4305            1 => self.log_then_apply(recs.into_iter().next().unwrap()),
4306            _ => self.log_then_apply(WalRecord::Batch(recs)),
4307        }
4308    }
4309
4310    /// Durable write, then notify the event sink. Replay (`apply` during
4311    /// `open`) never enters this function, so it is the replay-silent seam.
4312    fn log_then_apply(&mut self, rec: WalRecord) -> Result<()> {
4313        self.log_then_apply_with(rec, None, self.fsync)
4314    }
4315
4316    /// Whether this frame must fsync under `policy`.
4317    ///
4318    /// Batched contract: user-visible batches (>1 mutation) fsync; single
4319    /// mutations do not. The dense rewrite wraps a single mutation in a
4320    /// `Batch([Intern.., <one *Id record>])`, so `Intern` records are excluded
4321    /// from the count — removing that filter would make every single-op write
4322    /// fsync under Batched (or, if the threshold were raised instead, skip a
4323    /// needed fsync for real two-op batches).
4324    fn wal_needs_sync(policy: FsyncPolicy, rec: &WalRecord) -> bool {
4325        match policy {
4326            FsyncPolicy::Relaxed => false,
4327            FsyncPolicy::Strict => true,
4328            FsyncPolicy::Batched => match rec {
4329                // Intern + one mutation is the single-op rewrite, not a user batch.
4330                WalRecord::Batch(inner) => {
4331                    inner
4332                        .iter()
4333                        .filter(|r| !matches!(r, WalRecord::Intern { .. }))
4334                        .count()
4335                        > 1
4336                }
4337                _ => false,
4338            },
4339        }
4340    }
4341
4342    /// # Apply-infallibility invariant (load-bearing)
4343    ///
4344    /// The ordering is: WAL append → fsync → apply. If `apply` returned `Err`
4345    /// for a `Batch` frame after a successful WAL write, the WAL would contain
4346    /// the full frame while in-memory state would reflect only the ops before
4347    /// the failure. On reopen, WAL replay would then apply the entire batch —
4348    /// diverging permanently from what the pre-crash process had in memory.
4349    ///
4350    /// For `Batch` frames this situation cannot arise because:
4351    /// - All validation runs via `commit_logged_batch`/`MutPreview` **before**
4352    ///   the WAL write. `MutPreview` uses the same `&mut self` that apply will
4353    ///   use, with no concurrent mutation between validation exit and apply entry.
4354    /// - Every `apply` arm for a validated op is either infallible by construction
4355    ///   (`InsertNode`, `RemoveProp`, `DeleteEdge`, `DeleteNode`), has idempotency
4356    ///   guards that return `Ok(())` (`CreateRule`, `DeleteRule`), or is
4357    ///   guaranteed-present by validation (`InsertEdge`/`SetProp` key lookups).
4358    /// - `on_node_changed` and `on_node_removed` return `()` — never `Err`.
4359    ///
4360    /// A `debug_assert!` below fires in debug builds if `apply` ever returns
4361    /// `Err` for a `Batch` frame, making any future regression immediately visible
4362    /// in tests rather than silently diverging crash-recovery behaviour.
4363    fn log_then_apply_with(
4364        &mut self,
4365        rec: WalRecord,
4366        ingest: Option<(String, usize)>,
4367        policy: FsyncPolicy,
4368    ) -> Result<()> {
4369        // Read-only guard: as-of instances must never write the WAL.
4370        if self.read_only {
4371            return Err(GraphError::ReadOnly);
4372        }
4373        // Degraded guard: fsync failure left WAL truncated, or a refresh failed
4374        // partway; in-memory state is ahead of (or out of step with) the
4375        // on-disk WAL, so further mutations would deepen the divergence.
4376        // Reopen the database to recover.  Checked before the lock guard: this
4377        // is the more serious condition and the more useful error.
4378        if self.degraded {
4379            return Err(GraphError::Io(std::io::Error::other(
4380                "database degraded after group-commit fsync failure; reopen required",
4381            )));
4382        }
4383        // Cross-process guard: this write scope asked for the store's write
4384        // lock and did not get it. Writing anyway would append frames on top of
4385        // a WAL another process is extending, so refuse instead.
4386        if self.lock_denied {
4387            return Err(GraphError::Busy { holder: None });
4388        }
4389        // Ensure retained provenance bytes are decoded into the live mutable
4390        // fields before any mutation touches self.engine.provenance.  This is a
4391        // no-op if provenance was never stored (fresh store) or has already been
4392        // consumed (subsequent mutations).  WAL replay calls apply() directly
4393        // and is covered by consume_retained_state_eager before replay.
4394        self.ensure_v8_base_sections_loaded();
4395        self.engine.ensure_provenance_loaded_mut();
4396        // Invariant (I-1): no stale deltas may enter from a previous apply.
4397        // If any engine method ever accumulates deltas before erroring, they would
4398        // contaminate the *next* commit's event stream. This assert fires in debug
4399        // builds, making any future regression visible at the earliest point.
4400        debug_assert_eq!(
4401            self.engine.pending_delta_count(),
4402            0,
4403            "stale engine deltas at log_then_apply_with entry — \
4404             a previous apply arm may have accumulated deltas before erroring; \
4405             the caller must drain_deltas() on any error path before returning"
4406        );
4407        let frame = encode_record(&rec);
4408        self.fs.append(FileId::Wal, &frame)?;
4409        // The cursor advances by exactly the bytes appended: these frames are
4410        // ours and already applied, so a later refresh must not replay them.
4411        self.wal_consumed += frame.len() as u64;
4412        if Self::wal_needs_sync(policy, &rec) {
4413            self.fs.sync(FileId::Wal)?;
4414        }
4415        // Marker writing always needs the engine deltas, but the engine only
4416        // accumulates them when emit_deltas is true (normally gated on subscribers
4417        // or views being present).  Enable emission for this apply if it is
4418        // currently off, then restore the original state unconditionally via an
4419        // RAII guard — this prevents a panic in apply() from leaking the flag.
4420        // The same guard resets the engine's transient chaining state. A panic
4421        // unwinding out of a rule hook would otherwise leave `chain_depth`
4422        // non-zero, which makes every later `begin_chain` decide chaining is
4423        // already running and silently switch it off for good.
4424        struct RestoreEmitDeltas(*mut RuleEngine, bool);
4425        impl Drop for RestoreEmitDeltas {
4426            fn drop(&mut self) {
4427                // SAFETY: pointer into self (GraphDb); guard is dropped within
4428                // this frame before log_then_apply_with returns.
4429                unsafe {
4430                    (*self.0).set_emit_deltas(self.1);
4431                    (*self.0).reset_chain_state();
4432                }
4433            }
4434        }
4435        let original_emit = self.engine.emit_deltas();
4436        if !original_emit {
4437            self.engine.set_emit_deltas(true);
4438        }
4439        // SAFETY: raw pointer into self; guard dropped within this frame.
4440        let _emit_guard = RestoreEmitDeltas(&mut self.engine as *mut _, original_emit);
4441
4442        let apply_result = self.apply(&rec);
4443        // For Batch frames, post-validation apply must be infallible (see above).
4444        // A debug_assert here catches any future change that makes apply fallible
4445        // before the caller notices via silent WAL/memory divergence.
4446        if matches!(&rec, WalRecord::Batch(_)) {
4447            debug_assert!(
4448                apply_result.is_ok(),
4449                "Batch apply returned Err after successful WAL write — \
4450                 the validate-then-apply invariant has been violated; \
4451                 see log_then_apply_with invariant doc"
4452            );
4453        }
4454        if apply_result.is_err() {
4455            // Discard any partial deltas accumulated by the failed apply.
4456            // They must not ride the next commit's event stream (I-1).
4457            // _emit_guard restores emit_deltas on drop automatically.
4458            let _ = self.engine.drain_deltas();
4459            let _ = self.engine.take_rebuild_needed();
4460            apply_result?;
4461        }
4462        self.commit_seq += 1;
4463        let seq = self.commit_seq;
4464        // Update per-node last-change map for the committed record.
4465        // Must happen after commit_seq is incremented so the seq is correct.
4466        self.update_last_change_from_rec(&rec, seq);
4467        // Drain engine deltas and distribute to subscribers before the existing
4468        // MutationEvent sink fires — both happen post-fsync, post-apply.
4469        // _emit_guard restores emit_deltas after this line when it drops.
4470        let engine_deltas = self.engine.drain_deltas();
4471
4472        // Append history-marker WAL records for any derived-edge changes so
4473        // that `edge_history` and `was_linked` can surface rule-attributed
4474        // events. Markers are STATE NO-OPS during replay; they are written
4475        // without an additional fsync (the triggering commit's sync already
4476        // happened; the next commit's sync covers these lazily).
4477        if !engine_deltas.is_empty() {
4478            let markers: Vec<WalRecord> = engine_deltas
4479                .iter()
4480                .map(|d| {
4481                    if d.fired {
4482                        WalRecord::DerivedEdgeAdded {
4483                            rule: d.rule.clone(),
4484                            edge_type: d.edge_type.clone(),
4485                            src_key: d.src_key.clone(),
4486                            dst_key: d.dst_key.clone(),
4487                        }
4488                    } else {
4489                        WalRecord::DerivedEdgeRetracted {
4490                            rule: d.rule.clone(),
4491                            edge_type: d.edge_type.clone(),
4492                            src_key: d.src_key.clone(),
4493                            dst_key: d.dst_key.clone(),
4494                        }
4495                    }
4496                })
4497                .collect();
4498            let marker_frame = if markers.len() == 1 {
4499                markers.into_iter().next().unwrap()
4500            } else {
4501                WalRecord::Batch(markers)
4502            };
4503            // Ignore append errors: markers are best-effort history
4504            // annotations. Losing them does not affect state correctness.
4505            // The cursor only advances when the bytes actually landed.
4506            let marker_bytes = encode_record(&marker_frame);
4507            if self.fs.append(FileId::Wal, &marker_bytes).is_ok() {
4508                self.wal_consumed += marker_bytes.len() as u64;
4509            }
4510        }
4511
4512        // Record MVCC CommitDelta for the epoch reader.  The WAL record is
4513        // stored as-is (including any nested Batch / Intern records); the
4514        // ReaderSnapshot's apply_one function handles all variants.
4515        {
4516            let derived_inserts = engine_deltas
4517                .iter()
4518                .filter(|d| d.fired)
4519                .map(|d| (d.etype_sym, d.src_id, d.dst_id))
4520                .collect();
4521            let derived_deletes = engine_deltas
4522                .iter()
4523                .filter(|d| !d.fired)
4524                .map(|d| (d.etype_sym, d.src_id, d.dst_id))
4525                .collect();
4526            let delta = Arc::new(crate::reader::CommitDelta {
4527                records: vec![rec.clone()],
4528                derived_inserts,
4529                derived_deletes,
4530            });
4531            self.delta_tail.push(delta);
4532            self.commits_since_fold += 1;
4533            if self.commits_since_fold >= crate::reader::FOLD_EVERY_K {
4534                self.fold_now();
4535            }
4536        }
4537
4538        if self.defer_events {
4539            // Group-commit drain thread: hold events until after the group
4540            // fsync so subscribers only observe durable data (R2).
4541            self.deferred_events.push(DeferredEvent {
4542                rec: rec.clone(),
4543                engine_deltas,
4544                seq,
4545                ingest,
4546            });
4547        } else {
4548            self.distribute_events(&rec, &engine_deltas, seq);
4549            self.emit_committed(&rec, ingest);
4550        }
4551        // Drift is only known after apply, so auto-rebuild cannot join the
4552        // triggering op's WAL frame. Issue RebuildRule as a second commit.
4553        // Skip when `rec` is itself RebuildRule: rebuild resets drift, so a
4554        // retrigger loop is impossible if the fit succeeded, but we still
4555        // drain the flag so a leftover cannot re-enter.
4556        // One slice of any outstanding vector-index build rides here too, so a
4557        // store that is being written to finishes its build without anyone
4558        // calling `pump_index_build`. A rule that becomes whole joins the same
4559        // RebuildRule loop below.
4560        let mut rebuilds = self.engine.take_rebuild_needed();
4561        if !matches!(&rec, WalRecord::RebuildRule { .. }) {
4562            // Not after `CreateRule`: that record's own apply already did the
4563            // rule's first slice, and pumping again here would make one
4564            // `create_rule` call do two slices' work under one lock.
4565            // Nothing pending is the overwhelmingly common case and must cost
4566            // a map lookup, not an engine swap: a store being written to has
4567            // long since populated its indexes, so the `pump_index_build`
4568            // entry point owns the not-yet-populated case on its own.
4569            if !matches!(&rec, WalRecord::CreateRule { .. })
4570                && !self.engine.builds_in_progress().is_empty()
4571            {
4572                rebuilds.extend(self.pump_one_slice().into_iter().map(|b| b.rule));
4573            }
4574            let mut failed = Vec::new();
4575            for name in rebuilds {
4576                if self.engine.rules().any(|r| r.name == name) {
4577                    // User op is already durable. A failed second commit must
4578                    // not surface as the caller's error.
4579                    if let Err(e) =
4580                        self.log_then_apply(WalRecord::RebuildRule { name: name.clone() })
4581                    {
4582                        eprintln!(
4583                            "auto-rebuild of rule {name:?} failed after durable user commit: {e}"
4584                        );
4585                        failed.push(name);
4586                    }
4587                }
4588            }
4589            for name in failed {
4590                self.engine.queue_rebuild_needed(name);
4591            }
4592        }
4593        Ok(())
4594    }
4595
4596    /// Install a post-commit hook. Replaces any previous sink.
4597    ///
4598    /// The sink runs inside `log_then_apply` after a successful
4599    /// durable commit, while the caller still holds `&mut self`. When this
4600    /// database is behind a [`crate::SharedDb`], that means the **write
4601    /// guard is held**. The sink must never call `read` / `write` (or any
4602    /// other method) on the same `SharedDb` — the `RwLock` is not
4603    /// re-entrant and doing so deadlocks. The sink is `Send + Sync`;
4604    /// `std::sync::mpsc::Sender` is not `Sync` and will not type-check.
4605    /// Intended examples: `std::sync::mpsc::SyncSender`,
4606    /// `tokio::sync::mpsc::Sender`, `tokio::sync::broadcast::Sender`
4607    /// (non-blocking `send`), or `Arc<Mutex<Vec<MutationEvent>>>`.
4608    pub fn set_event_sink(&mut self, sink: Box<dyn Fn(MutationEvent) + Send + Sync>) {
4609        self.event_sink = Some(sink);
4610    }
4611
4612    /// Whether a post-commit event sink is currently installed.
4613    pub fn has_event_sink(&self) -> bool {
4614        self.event_sink.is_some()
4615    }
4616
4617    /// Set WAL fsync cadence. Default [`FsyncPolicy::Strict`].
4618    pub fn set_fsync_policy(&mut self, p: FsyncPolicy) {
4619        self.fsync = p;
4620    }
4621
4622    /// Return the current WAL fsync cadence.
4623    pub fn fsync_policy(&self) -> FsyncPolicy {
4624        self.fsync
4625    }
4626
4627    // ── Group-commit event deferral ───────────────────────────────────────────
4628
4629    /// Enable or disable deferred event mode.
4630    ///
4631    /// When `true`, event notifications (subscription `DbEvent`s and legacy
4632    /// `MutationEvent` sink calls) are buffered rather than fired immediately.
4633    /// Call [`flush_deferred_events`] after the group fsync to deliver them,
4634    /// or [`discard_deferred_events`] if the fsync failed and the group must
4635    /// be treated as lost.
4636    pub fn set_deferred_events_mode(&mut self, defer: bool) {
4637        self.defer_events = defer;
4638    }
4639
4640    /// Fire all buffered events accumulated since [`set_deferred_events_mode`]
4641    /// was set to true.  Clears the buffer.
4642    ///
4643    /// Called by the drain thread AFTER a successful group fsync, so
4644    /// subscribers observe only data that is durably on disk.
4645    pub fn flush_deferred_events(&mut self) {
4646        let events = std::mem::take(&mut self.deferred_events);
4647        for de in events {
4648            self.distribute_events(&de.rec, &de.engine_deltas, de.seq);
4649            self.emit_committed(&de.rec, de.ingest);
4650        }
4651    }
4652
4653    /// Discard all buffered events without firing them.
4654    ///
4655    /// Called by the drain thread when a group fsync fails: the WAL has been
4656    /// truncated back to the pre-group offset, so the committed-but-unsynced
4657    /// ops must not be observable to subscribers.
4658    pub fn discard_deferred_events(&mut self) {
4659        self.deferred_events.clear();
4660    }
4661
4662    // ── Degraded state ────────────────────────────────────────────────────────
4663
4664    /// Mark this database as degraded.
4665    ///
4666    /// Called by the group-commit drain thread after a group fsync failure and
4667    /// WAL truncation: the in-memory state is now ahead of the on-disk WAL, so
4668    /// further mutations would deepen the divergence.  All subsequent calls to
4669    /// [`log_then_apply_with`] return `Err` until the database is reopened.
4670    pub fn set_degraded(&mut self) {
4671        self.degraded = true;
4672    }
4673
4674    fn emit(&self, ev: MutationEvent) {
4675        if let Some(sink) = &self.event_sink {
4676            sink(ev);
4677        }
4678    }
4679
4680    fn emit_committed(&self, rec: &WalRecord, ingest: Option<(String, usize)>) {
4681        match rec {
4682            WalRecord::Batch(inner) => {
4683                for r in inner {
4684                    if let Some(ev) = event_from_record(r, &self.syms, &self.ids) {
4685                        self.emit(ev);
4686                    }
4687                }
4688                match ingest {
4689                    Some((label, inserted)) => {
4690                        self.emit(MutationEvent::Ingested { label, inserted })
4691                    }
4692                    None => {
4693                        let ops = inner
4694                            .iter()
4695                            .filter(|r| !matches!(r, WalRecord::Intern { .. }))
4696                            .count();
4697                        if ops > 1 {
4698                            self.emit(MutationEvent::BatchApplied { ops });
4699                        }
4700                    }
4701                }
4702            }
4703            other => {
4704                if let Some(ev) = event_from_record(other, &self.syms, &self.ids) {
4705                    self.emit(ev);
4706                }
4707            }
4708        }
4709    }
4710
4711    // -----------------------------------------------------------------------
4712    // Subscription API
4713    // -----------------------------------------------------------------------
4714
4715    /// Distribute post-commit events to all live subscribers.
4716    ///
4717    /// Build a row-key → row-data map from a [`ResultSet`].
4718    ///
4719    /// Each row is serialized to JSON to form its key; a debug fallback is used
4720    /// if serialization fails. Used by both the initial-seed path in
4721    /// [`Self::subscribe_query`] and the per-commit diff path in
4722    /// [`Self::distribute_events`] to keep the two in sync.
4723    fn result_to_row_map(
4724        result: &core_query::ResultSet,
4725    ) -> std::collections::HashMap<String, Vec<Option<Value>>> {
4726        (0..result.len())
4727            .map(|i| {
4728                let row = result.row(i).to_vec();
4729                let key = serde_json::to_string(&row).unwrap_or_else(|_| format!("{row:?}"));
4730                (key, row)
4731            })
4732            .collect()
4733    }
4734
4735    /// Collect the set of label syms touched by a WAL record.
4736    ///
4737    /// Returns `Some(set)` when every record in this commit can be attributed to
4738    /// a known label sym. Returns `None` when the commit must not be skipped:
4739    /// edge records, unresolvable key→label lookups, or any record type not in
4740    /// the explicit handled set.
4741    ///
4742    /// Handled record types and their actions:
4743    /// - `InsertNode`   → look up label in interner (fails → None)
4744    /// - `InsertNodeId` → label sym is carried directly
4745    /// - `SetProp`      → resolve key→id→label (fails → None)
4746    /// - `DeleteNode`   → resolve key→id→label (fails → None)
4747    /// - `Batch`        → recurse into every inner record
4748    /// - `InsertEdge`, `DeleteEdge`, `InsertEdgeId` → always None (edge records)
4749    /// - everything else → None (conservative)
4750    fn commit_touched_labels(
4751        rec: &WalRecord,
4752        syms: &Interner,
4753        ids: &IdMap,
4754        labels: &[u32],
4755    ) -> Option<BTreeSet<u32>> {
4756        let mut out = BTreeSet::new();
4757        if Self::collect_touched_labels(rec, syms, ids, labels, &mut out) {
4758            Some(out)
4759        } else {
4760            None
4761        }
4762    }
4763
4764    fn collect_touched_labels(
4765        rec: &WalRecord,
4766        syms: &Interner,
4767        ids: &IdMap,
4768        labels: &[u32],
4769        out: &mut BTreeSet<u32>,
4770    ) -> bool {
4771        match rec {
4772            // String-key insert: the dense rewrite converts this to
4773            // [Intern, InsertNodeId], so this arm fires only for legacy WAL
4774            // records written before the dense path was added.
4775            WalRecord::InsertNode { label, .. } => {
4776                if let Some(sym) = syms.get(label) {
4777                    out.insert(sym);
4778                    true
4779                } else {
4780                    false
4781                }
4782            }
4783            // Dense-id insert (produced by rewrite_wal_dense for every
4784            // insert_node call in the current codebase).
4785            WalRecord::InsertNodeId { label, .. } => {
4786                out.insert(*label);
4787                true
4788            }
4789            // String-key prop set: dense path converts to [Intern, SetPropId].
4790            WalRecord::SetProp { key, .. } => {
4791                if let Some(sym) = Self::resolve_key_label_sym(key, ids, labels) {
4792                    out.insert(sym);
4793                    true
4794                } else {
4795                    false
4796                }
4797            }
4798            // Dense-id prop set (produced by rewrite_wal_dense for set_prop).
4799            WalRecord::SetPropId { id, .. } => {
4800                if let Some(sym) = labels.get(*id as usize).copied().filter(|&s| s != u32::MAX) {
4801                    out.insert(sym);
4802                    true
4803                } else {
4804                    false
4805                }
4806            }
4807            WalRecord::DeleteNode { key } => {
4808                if let Some(sym) = Self::resolve_key_label_sym(key, ids, labels) {
4809                    out.insert(sym);
4810                    true
4811                } else {
4812                    false
4813                }
4814            }
4815            WalRecord::Batch(inner) => inner
4816                .iter()
4817                .all(|r| Self::collect_touched_labels(r, syms, ids, labels, out)),
4818            // Intern is a pure metadata record — it does not touch any node's
4819            // label and is safe to skip for the label-skip predicate.
4820            WalRecord::Intern { .. } => true,
4821            // Edge records: always re-execute (edges can change join results).
4822            WalRecord::InsertEdge { .. }
4823            | WalRecord::DeleteEdge { .. }
4824            | WalRecord::InsertEdgeId { .. } => false,
4825            _ => false,
4826        }
4827    }
4828
4829    /// Resolve a node key to its label sym via the dense id table.
4830    /// Returns `None` if the key is unknown or the label is a tombstone sentinel.
4831    fn resolve_key_label_sym(key: &str, ids: &IdMap, labels: &[u32]) -> Option<u32> {
4832        let id = ids.get(key)?;
4833        let sym = labels.get(id as usize).copied()?;
4834        (sym != u32::MAX).then_some(sym)
4835    }
4836
4837    /// Distribute post-commit events to all live subscribers.
4838    ///
4839    /// Called from `log_then_apply_with` after apply + fsync, before the
4840    /// legacy MutationEvent sink. Prunes dead `Weak` entries in-place.
4841    ///
4842    /// Query subscriptions (subscribe_query) re-execute their plan on every
4843    /// call and diff the result against the previous run. Zero overhead when
4844    /// no query subscriptions are active.
4845    fn distribute_events(&mut self, rec: &WalRecord, engine_deltas: &[EngineEdgeDelta], seq: u64) {
4846        if self.subscriptions.is_empty() && self.query_subscriptions.is_empty() {
4847            return;
4848        }
4849
4850        if !self.subscriptions.is_empty() {
4851            // Build write events from the WAL record.
4852            let write_events: Vec<DbEvent> =
4853                Self::write_events_from_record(rec, seq, &self.syms, &self.ids);
4854
4855            // Build edge events from engine deltas.  Weight is looked up from
4856            // edge_props at distribution time (after apply), so it's always fresh.
4857            let edge_events: Vec<DbEvent> = engine_deltas
4858                .iter()
4859                .map(|d| {
4860                    if d.fired {
4861                        // The score lives under the rule's declared weight_prop,
4862                        // which is not always the literal "weight".
4863                        let prop = self
4864                            .engine
4865                            .rules()
4866                            .find(|r| r.name == d.rule)
4867                            .and_then(|r| r.weight_prop.as_deref());
4868                        let weight = prop.and_then(|p| {
4869                            self.edge_props
4870                                .get(d.etype_sym, d.src_id, d.dst_id, p)
4871                                .and_then(|v| {
4872                                    if let core_storage::Value::Float(f) = v {
4873                                        Some(*f)
4874                                    } else {
4875                                        None
4876                                    }
4877                                })
4878                        });
4879                        DbEvent::EdgeFired {
4880                            rule: d.rule.clone(),
4881                            src_key: d.src_key.clone(),
4882                            dst_key: d.dst_key.clone(),
4883                            edge_type: d.edge_type.clone(),
4884                            weight,
4885                            commit_seq: seq,
4886                        }
4887                    } else {
4888                        DbEvent::EdgeRetracted {
4889                            rule: d.rule.clone(),
4890                            src_key: d.src_key.clone(),
4891                            dst_key: d.dst_key.clone(),
4892                            edge_type: d.edge_type.clone(),
4893                            commit_seq: seq,
4894                        }
4895                    }
4896                })
4897                .collect();
4898
4899            // Prune dead entries; push matching events to live ones.
4900            self.subscriptions.retain(|entry| {
4901                let Some(inner) = entry.inner.upgrade() else {
4902                    return false;
4903                };
4904                for ev in &write_events {
4905                    if event_matches(ev, &entry.filter) {
4906                        inner.push(ev.clone());
4907                    }
4908                }
4909                for ev in &edge_events {
4910                    if event_matches(ev, &entry.filter) {
4911                        inner.push(ev.clone());
4912                    }
4913                }
4914                true
4915            });
4916
4917            // Turn off delta accumulation if all subscribers dropped and no views remain.
4918            if self.subscriptions.is_empty() && self.view_store.is_empty() {
4919                self.engine.set_emit_deltas(false);
4920            }
4921        }
4922
4923        // Query subscriptions: full re-run per commit, then diff rows.
4924        // IMPORTANT: full re-execution on every commit — use LIMIT to bound cost.
4925        // Differential evaluation is roadmap / Phase 5.
4926        if !self.query_subscriptions.is_empty() {
4927            // Take the list out so we can call self.view() without borrow conflict.
4928            let mut query_subs = std::mem::take(&mut self.query_subscriptions);
4929            let empty_params = BTreeMap::new();
4930            query_subs.retain_mut(|entry| {
4931                let Some(inner) = entry.inner.upgrade() else {
4932                    return false; // subscriber dropped — prune
4933                };
4934                // Label-skip: if the plan has a known scan label and this commit
4935                // can be proven to touch only different labels (and no rule-derived
4936                // edge deltas fired), the result set cannot have changed — skip.
4937                if let Some(scan_sym) = entry.scan_label {
4938                    if engine_deltas.is_empty() {
4939                        let touched =
4940                            Self::commit_touched_labels(rec, &self.syms, &self.ids, &self.labels);
4941                        if touched.map(|t| !t.contains(&scan_sym)).unwrap_or(false) {
4942                            return true; // safe to skip — result set unchanged
4943                        }
4944                    }
4945                }
4946                QUERY_SUB_EXECS_TL.with(|c| c.set(c.get() + 1));
4947                let result = match execute(&self.view(), &entry.ops, &Params(&empty_params)) {
4948                    Ok(r) => r,
4949                    Err(e) => {
4950                        // Keep the subscription alive; skip the diff for this commit.
4951                        // Re-run errors are transient (e.g., planner change) and
4952                        // self-heal when the next commit succeeds.
4953                        eprintln!("[mushroomdb] subscribe_query re-run failed: {e}");
4954                        return true;
4955                    }
4956                };
4957                // Build new row map: serialized-key → row data.
4958                let new_row_map = Self::result_to_row_map(&result);
4959                // Removed rows: in prev but not in new.
4960                for (key, row) in &entry.prev_row_map {
4961                    if !new_row_map.contains_key(key) {
4962                        inner.push(DbEvent::QueryRowRemoved {
4963                            columns: entry.columns.clone(),
4964                            row: row.clone(),
4965                        });
4966                    }
4967                }
4968                // Added rows: in new but not in prev.
4969                for (key, row) in &new_row_map {
4970                    if !entry.prev_row_map.contains_key(key) {
4971                        inner.push(DbEvent::QueryRowAdded {
4972                            columns: entry.columns.clone(),
4973                            row: row.clone(),
4974                        });
4975                    }
4976                }
4977                entry.prev_row_map = new_row_map;
4978                true
4979            });
4980            self.query_subscriptions = query_subs;
4981        }
4982    }
4983
4984    /// Returns `true` if any live subscriber or view definition requires delta
4985    /// accumulation. Used to set `engine.emit_deltas` on subscribe/view DDL.
4986    fn needs_emit_deltas(&self) -> bool {
4987        !self.view_store.is_empty()
4988            || self
4989                .subscriptions
4990                .iter()
4991                .any(|e| e.inner.upgrade().is_some())
4992    }
4993
4994    /// Convert a WAL record into `DbEvent` write events with the given seq.
4995    fn write_events_from_record(
4996        rec: &WalRecord,
4997        seq: u64,
4998        intern: &Interner,
4999        ids: &IdMap,
5000    ) -> Vec<DbEvent> {
5001        match rec {
5002            WalRecord::InsertNode { label, key, .. } => vec![DbEvent::NodeInserted {
5003                label: label.clone(),
5004                key: key.clone(),
5005                commit_seq: seq,
5006            }],
5007            // *Id arms run after a successful apply, so resolution can only
5008            // fail on a programming error. Skip the event rather than emit a
5009            // fabricated "" that clients can't tell from a real empty value
5010            // (mirrors event_from_record returning None).
5011            WalRecord::InsertNodeId { label, key, .. } => intern
5012                .resolve(*label)
5013                .map(|label| DbEvent::NodeInserted {
5014                    label: label.to_string(),
5015                    key: key.clone(),
5016                    commit_seq: seq,
5017                })
5018                .into_iter()
5019                .collect(),
5020            WalRecord::SetProp { key, field, .. } => vec![DbEvent::PropSet {
5021                key: key.clone(),
5022                field: field.clone(),
5023                commit_seq: seq,
5024            }],
5025            WalRecord::SetPropId { id, field, .. } => ids
5026                .key_of(*id)
5027                .zip(intern.resolve(*field))
5028                .map(|(key, field)| DbEvent::PropSet {
5029                    key: key.to_string(),
5030                    field: field.to_string(),
5031                    commit_seq: seq,
5032                })
5033                .into_iter()
5034                .collect(),
5035            WalRecord::RemoveProp { key, field } => vec![DbEvent::PropRemoved {
5036                key: key.clone(),
5037                field: field.clone(),
5038                commit_seq: seq,
5039            }],
5040            WalRecord::InsertEdge {
5041                edge_type,
5042                src_key,
5043                dst_key,
5044            } => vec![DbEvent::EdgeInserted {
5045                edge_type: edge_type.clone(),
5046                src: src_key.clone(),
5047                dst: dst_key.clone(),
5048                commit_seq: seq,
5049            }],
5050            WalRecord::InsertEdgeId { etype, src, dst } => (|| {
5051                Some(DbEvent::EdgeInserted {
5052                    edge_type: intern.resolve(*etype)?.to_string(),
5053                    src: ids.key_of(*src)?.to_string(),
5054                    dst: ids.key_of(*dst)?.to_string(),
5055                    commit_seq: seq,
5056                })
5057            })()
5058            .into_iter()
5059            .collect(),
5060            WalRecord::DeleteEdge {
5061                edge_type,
5062                src_key,
5063                dst_key,
5064            } => vec![DbEvent::EdgeDeleted {
5065                edge_type: edge_type.clone(),
5066                src: src_key.clone(),
5067                dst: dst_key.clone(),
5068                commit_seq: seq,
5069            }],
5070            WalRecord::DeleteNode { key } => vec![DbEvent::NodeDeleted {
5071                key: key.clone(),
5072                commit_seq: seq,
5073            }],
5074            WalRecord::Batch(inner) => inner
5075                .iter()
5076                .flat_map(|r| Self::write_events_from_record(r, seq, intern, ids))
5077                .collect(),
5078            WalRecord::CreateRule { .. }
5079            | WalRecord::DeleteRule { .. }
5080            | WalRecord::RebuildRule { .. }
5081            | WalRecord::CreateView { .. }
5082            | WalRecord::DeleteView { .. }
5083            | WalRecord::EnableFulltext { .. }
5084            | WalRecord::DisableFulltext { .. }
5085            | WalRecord::EnableIndex { .. }
5086            | WalRecord::DisableIndex { .. }
5087            | WalRecord::Intern { .. }
5088            // History markers produce no DbEvent — the engine delta already
5089            // fired the EdgeFired/EdgeRetracted subscription events.
5090            | WalRecord::DerivedEdgeAdded { .. }
5091            | WalRecord::DerivedEdgeRetracted { .. }
5092            | WalRecord::RenameNode { .. } => vec![],
5093        }
5094    }
5095
5096    /// Subscribe to edge-fire and edge-retract events for one named rule.
5097    ///
5098    /// Returns `Err(GraphError::RuleNotFound)` if `rule_name` is not
5099    /// currently registered. Dropping the returned [`Subscription`] handle
5100    /// unregisters the subscriber — no further events are queued, no
5101    /// resources leak.
5102    pub fn subscribe_rule(&mut self, rule_name: &str) -> core_storage::Result<Subscription> {
5103        if self.read_only {
5104            return Err(core_storage::GraphError::ReadOnly);
5105        }
5106        if !self.engine.rules().any(|r| r.name == rule_name) {
5107            return Err(core_storage::GraphError::RuleNotFound {
5108                name: rule_name.to_string(),
5109            });
5110        }
5111        let inner = SubInner::new(self.sub_capacity());
5112        self.subscriptions.push(SubEntry {
5113            filter: SubFilter::Rule(rule_name.to_string()),
5114            inner: std::sync::Arc::downgrade(&inner),
5115        });
5116        self.engine.set_emit_deltas(true);
5117        Ok(Subscription(inner))
5118    }
5119
5120    /// Subscribe to edge-fire and edge-retract events for **all** rules.
5121    ///
5122    /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
5123    /// as-of instances never commit, so `distribute_events` never runs and the
5124    /// subscription would never deliver events.
5125    pub fn subscribe_all_rules(&mut self) -> core_storage::Result<Subscription> {
5126        if self.read_only {
5127            return Err(core_storage::GraphError::ReadOnly);
5128        }
5129        let inner = SubInner::new(self.sub_capacity());
5130        self.subscriptions.push(SubEntry {
5131            filter: SubFilter::AllRules,
5132            inner: std::sync::Arc::downgrade(&inner),
5133        });
5134        self.engine.set_emit_deltas(true);
5135        Ok(Subscription(inner))
5136    }
5137
5138    /// Subscribe to write events: node insert/delete, prop set/remove.
5139    ///
5140    /// Does not include edge-fire / edge-retract (rule-derived edge events).
5141    ///
5142    /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
5143    /// as-of instances never commit, so `distribute_events` never runs and the
5144    /// subscription would never deliver events.
5145    pub fn subscribe_writes(&mut self) -> core_storage::Result<Subscription> {
5146        if self.read_only {
5147            return Err(core_storage::GraphError::ReadOnly);
5148        }
5149        let inner = SubInner::new(self.sub_capacity());
5150        self.subscriptions.push(SubEntry {
5151            filter: SubFilter::Writes,
5152            inner: std::sync::Arc::downgrade(&inner),
5153        });
5154        self.engine.set_emit_deltas(true);
5155        Ok(Subscription(inner))
5156    }
5157
5158    /// Subscribe to incremental Cypher query results.
5159    ///
5160    /// Parses and plans `cypher`; rejects the query if the plan is not in the
5161    /// allowlisted subset (see [`core_query::cypher::is_subscribable`]):
5162    ///   - `MATCH (n:Label) WHERE … RETURN … [LIMIT n]`
5163    ///   - `MATCH (a)-[r:TYPE]->(b) RETURN … [LIMIT n]`  (exactly one hop)
5164    ///
5165    /// SKIP is not supported — it shifts the result window on every commit,
5166    /// causing spurious Added/Removed churn for rows whose data never changed.
5167    /// Multi-hop Expand chains are not supported; each additional MATCH clause
5168    /// widens scope beyond the documented single-scan / single-hop subset.
5169    ///
5170    /// After each successful commit, the plan is **fully re-executed** and the
5171    /// result is diffed against the previous run. Added rows produce
5172    /// [`DbEvent::QueryRowAdded`]; removed rows produce
5173    /// [`DbEvent::QueryRowRemoved`].
5174    ///
5175    /// **Full re-run per commit; use LIMIT to bound execution cost.**
5176    /// The existing 1 M intermediate-row cap applies. Differential evaluation
5177    /// is roadmap / Phase 5.
5178    ///
5179    /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
5180    /// as-of instances never commit, so `distribute_events` never runs and the
5181    /// subscription would never deliver events.
5182    ///
5183    /// Returns `Err(GraphError::QueryError)` if the query fails to parse, plan,
5184    /// or if the plan shape is not in the allowlist.
5185    pub fn subscribe_query(&mut self, cypher: &str) -> Result<Subscription> {
5186        if self.read_only {
5187            return Err(GraphError::ReadOnly);
5188        }
5189        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
5190            detail: format!("lex: {e}"),
5191        })?;
5192        let ast = parse(&tokens).map_err(|e| GraphError::QueryError {
5193            detail: format!("parse: {e}"),
5194        })?;
5195        let ops = plan(&ast).map_err(|e| GraphError::QueryError {
5196            detail: format!("plan: {e}"),
5197        })?;
5198        if !is_subscribable(&ops) {
5199            return Err(GraphError::QueryError {
5200                detail: "subscribe_query only supports allowlisted plan shapes: \
5201                         MATCH (n:Label) WHERE … RETURN … [LIMIT n] or \
5202                         MATCH (a)-[r:TYPE]->(b) RETURN … [LIMIT n] (exactly one hop). \
5203                         Not supported: multi-hop Expand chains, SKIP (creates \
5204                         unstable offset windows), ORDER BY, DISTINCT, aggregates, \
5205                         variable-length paths, OPTIONAL MATCH, WITH, UNWIND. \
5206                         Use LIMIT to bound re-execution cost."
5207                    .to_string(),
5208            });
5209        }
5210        // Execute once to capture initial state (initial rows are not emitted as
5211        // events — the subscriber learns the baseline via the first query call).
5212        let empty_params = BTreeMap::new();
5213        let initial = execute(&self.view(), &ops, &Params(&empty_params)).map_err(|e| {
5214            GraphError::QueryError {
5215                detail: format!("execute: {e}"),
5216            }
5217        })?;
5218        let columns = initial.columns().to_vec();
5219        let prev_row_map = Self::result_to_row_map(&initial);
5220        let inner = SubInner::new(self.sub_capacity());
5221        // Derive the scan-label sym for the commit-skip fast-path.  Any Expand op
5222        // or unrecognized leading scan → None (always re-execute).
5223        let scan_label = extract_scan_label(&ops, &mut self.syms);
5224        self.query_subscriptions.push(QuerySubEntry {
5225            ops,
5226            columns,
5227            prev_row_map,
5228            inner: std::sync::Arc::downgrade(&inner),
5229            scan_label,
5230        });
5231        Ok(Subscription(inner))
5232    }
5233
5234    /// Queue capacity used for new subscriptions.
5235    fn sub_capacity(&self) -> usize {
5236        self.sub_capacity
5237    }
5238
5239    /// Override per-subscriber queue capacity for subsequently created
5240    /// subscriptions on this db instance.
5241    ///
5242    /// Default is [`DEFAULT_SUB_CAPACITY`] (65,536 events). Use a smaller
5243    /// value in tests to exercise the [`DbEvent::Lagged`] path without
5244    /// generating tens of thousands of events.
5245    ///
5246    /// This is a test-support escape hatch. Calling it in production reduces
5247    /// subscriber reliability (more Lagged events). It is hidden from rustdoc
5248    /// to discourage accidental production use.
5249    #[doc(hidden)]
5250    pub fn set_sub_capacity(&mut self, capacity: usize) {
5251        self.sub_capacity = capacity;
5252    }
5253
5254    // -----------------------------------------------------------------------
5255
5256    /// Start an atomic batch.
5257    ///
5258    /// The returned [`BatchBuilder`] borrows `self` mutably until
5259    /// [`BatchBuilder::commit`]. Builder methods queue ops only — no
5260    /// validation, no WAL I/O. `commit` validates every queued op against
5261    /// live state plus preceding ops in this batch (duplicate key inside
5262    /// the batch is `Err`; an edge between two nodes created earlier in
5263    /// the batch is valid; `delete_node` then insert of the same key is a
5264    /// fresh identity). Validation never mutates the database. Any failure
5265    /// leaves WAL bytes and in-memory state identical to before `commit`.
5266    /// On success, one `WalRecord::Batch` frame is appended (one fsync)
5267    /// and each inner record is applied in order so rules fire per record.
5268    /// An empty batch, or a batch of only no-ops, writes zero WAL bytes.
5269    ///
5270    /// **Rule-window limitation:** batch validation cannot see edges that a
5271    /// rule created earlier in the *same* batch will derive at apply time, so
5272    /// a `delete_edge` / `insert_edge` in that window is silently no-oped
5273    /// where sequential calls would return `Err(RuleOwned)`. State integrity
5274    /// is unaffected (idempotent apply, provenance intact). Create rules in
5275    /// their own batch, or sequentially, when later ops may touch derived
5276    /// edges.
5277    pub fn batch(&mut self) -> BatchBuilder<'_, F> {
5278        BatchBuilder {
5279            db: self,
5280            ops: Vec::new(),
5281        }
5282    }
5283
5284    /// Closure-style atomic write batch.
5285    ///
5286    /// Equivalent to calling [`GraphDb::batch`], invoking `build` to queue ops,
5287    /// then committing. All ops queued inside `build` are validated in order and
5288    /// committed as a single `WalRecord::Batch` frame (one fsync). Rules fire
5289    /// once per inner record, in order, after commit — semantically identical to
5290    /// sequential single-op writes.
5291    ///
5292    /// **Error semantics — validate-then-apply.** `build` queues ops without
5293    /// touching the database. [`BatchBuilder::commit`] validates every op against
5294    /// live state plus earlier ops in this batch before writing anything. If op N
5295    /// fails validation (duplicate key, unknown key, rule-owned edge, …) the
5296    /// entire batch is rejected: no WAL bytes are written and no in-memory state
5297    /// changes. The database is identical to its state before `write_batch` was
5298    /// called.
5299    ///
5300    /// **Atomicity is crash-level, NOT isolation-level.** On replay after a crash,
5301    /// a partial (torn) `Batch` frame applies NONE of its ops — the frame is
5302    /// either fully applied or not at all. However, while applying a committed
5303    /// batch, concurrent readers may observe intermediate states as ops are applied
5304    /// sequentially in memory. There is no interactive transaction isolation in v1.
5305    /// This is documented as "crash-atomic write batches; no interactive
5306    /// transactions or read isolation."
5307    ///
5308    /// **Returns** `(nodes_inserted, edges_inserted)`. An empty or all-noop batch
5309    /// writes zero WAL bytes and returns `(0, 0)`.
5310    ///
5311    /// # Example
5312    ///
5313    /// ```rust,ignore
5314    /// let (nodes, edges) = db.write_batch(|b| {
5315    ///     b.insert_node("Person", "alice", vec![("age".into(), Value::Int(30))]);
5316    ///     b.insert_node("Person", "bob", vec![]);
5317    ///     b.insert_edge("KNOWS", "alice", "bob");
5318    ///     b.set_prop("alice", "role", Value::Str("admin".into()));
5319    ///     b.delete_node("old_key");
5320    /// })?;
5321    /// // One fsync; on crash replay: all five ops land or none do.
5322    /// ```
5323    pub fn write_batch<C>(&mut self, build: C) -> Result<(usize, usize)>
5324    where
5325        C: FnOnce(&mut BatchBuilder<'_, F>),
5326    {
5327        let mut b = self.batch();
5328        build(&mut b);
5329        b.commit()
5330    }
5331
5332    /// Insert `rows` as nodes of `label`. One call is one atomic batch:
5333    /// auto-declared KeyMatch rules (if any) first, then the accepted node
5334    /// inserts, so incremental fire sees the new rules. Per-row key problems
5335    /// are collected in [`IngestReport::row_errors`] and skipped; a commit
5336    /// `Err` means nothing was applied.
5337    ///
5338    /// Auto-FK rule names are `auto_fk_<src_label_lowercase>_<field>` so
5339    /// distinct source labels sharing an FK field each get their own rule.
5340    pub fn ingest(
5341        &mut self,
5342        label: &str,
5343        rows: Vec<BTreeMap<String, Value>>,
5344        opts: &IngestOptions,
5345    ) -> Result<IngestReport> {
5346        self.ingest_with_edges(label, rows, opts, &[])
5347    }
5348
5349    /// [`ingest`] plus user edges in the **same** previewed WAL batch.
5350    /// A failing edge rejects the whole request; nothing is applied.
5351    pub fn ingest_with_edges(
5352        &mut self,
5353        label: &str,
5354        rows: Vec<BTreeMap<String, Value>>,
5355        opts: &IngestOptions,
5356        edges: &[(String, String, String)],
5357    ) -> Result<IngestReport> {
5358        crate::ingest::run(self, label, rows, opts, edges)
5359    }
5360
5361    /// Parse `json` as an array of objects and ingest via [`GraphDb::ingest`].
5362    ///
5363    /// JSON `null` fields are silently omitted (not stored, not a row error).
5364    /// Nested objects and arrays-of-objects are a per-row error (row skipped).
5365    /// Parse failures and a top-level value that is not an array of objects
5366    /// return [`GraphError::IngestError`].
5367    pub fn ingest_json(
5368        &mut self,
5369        label: &str,
5370        json: &str,
5371        opts: &IngestOptions,
5372    ) -> Result<IngestReport> {
5373        crate::ingest::run_json(self, label, json, opts)
5374    }
5375
5376    fn commit_logged_batch(
5377        &mut self,
5378        ops: Vec<BatchOp>,
5379        ingest: Option<(String, usize)>,
5380        // Two-source rule: write_batch_authz threads authz here directly (never
5381        // touches pending_write_authz); query_write_authz sets the field instead
5382        // and passes None.  Only one source is non-None per call.
5383        param_authz: Option<WriteAuthz>,
5384    ) -> Result<(usize, usize)> {
5385        // Read-only guard: catches empty-batch calls before the early-return
5386        // that skips log_then_apply_with, ensuring all mutation entry points fail.
5387        if self.read_only {
5388            return Err(GraphError::ReadOnly);
5389        }
5390        // Ensure provenance is decoded before MutPreview accesses it
5391        // (note_delete_rule / is_rule_owned may call engine.provenance()).
5392        self.engine.ensure_provenance_loaded_mut();
5393
5394        // ── Authz pre-check ──────────────────────────────────────────────────
5395        // Evaluate the decision table per-op BEFORE MutPreview so that a denial
5396        // produces no WAL frame (all-or-nothing at the authz boundary extends
5397        // the existing validate-then-apply contract to role-scope checks).
5398        //
5399        // `batch_created` tracks key→label for nodes created by earlier ops in
5400        // THIS batch, so InsertEdgeUpsert can count same-batch placeholder nodes
5401        // as visible without needing to call `self.ids.get` on not-yet-committed
5402        // keys (they won't be there yet).
5403        //
5404        // Two-source rule: param_authz (write_batch_authz path) takes precedence;
5405        // fall back to self.pending_write_authz (query_write_authz/Cypher path).
5406        // Cloning the field copy avoids a simultaneous borrow of self.ids below.
5407        let authz_opt = param_authz.or_else(|| self.pending_write_authz.clone());
5408        if let Some(ref authz) = authz_opt {
5409            let mut batch_created: BTreeMap<String, String> = BTreeMap::new();
5410            for op in &ops {
5411                self.check_single_op_authz(authz, op, &batch_created)?;
5412                // Update batch_created after a passing authz check so that
5413                // subsequent ops in this batch see the nodes as "about to exist".
5414                match op {
5415                    BatchOp::InsertNode { label, key, .. } => {
5416                        // Only track genuinely new nodes (absent from the
5417                        // snapshot at authz-check time). A pre-existing visible
5418                        // key would be a DuplicateKey — not a real creation —
5419                        // so MutPreview handles it. Letting it into batch_created
5420                        // would allow a later SetProp to bypass update_labels
5421                        // via the "batch-created → always updatable" ruling
5422                        // (delete+recreate exploit, fix for I1 review round 2).
5423                        //
5424                        // Accepted edge: for a delete+recreate-with-different-
5425                        // label batch, node_status resolves the pre-delete
5426                        // (store) label for any subsequent update checks. This
5427                        // grants no net-new capability — a role that can delete+
5428                        // create can already place arbitrary props via
5429                        // InsertNode's own props field.
5430                        if self.ids.get(key.as_str()).is_none() {
5431                            batch_created.insert(key.clone(), label.clone());
5432                        }
5433                    }
5434                    BatchOp::InsertEdgeUpsert {
5435                        placeholder_label,
5436                        src_key,
5437                        dst_key,
5438                        ..
5439                    } => {
5440                        // Both endpoints will be created if not already in store.
5441                        for ep_key in [src_key, dst_key] {
5442                            if self.ids.get(ep_key.as_str()).is_none()
5443                                && !batch_created.contains_key(ep_key.as_str())
5444                            {
5445                                batch_created.insert(ep_key.clone(), placeholder_label.clone());
5446                            }
5447                        }
5448                    }
5449                    _ => {}
5450                }
5451            }
5452        }
5453
5454        let recs = {
5455            let mut preview = MutPreview::new(self);
5456            let mut recs = Vec::with_capacity(ops.len());
5457            for op in ops {
5458                match op {
5459                    BatchOp::InsertNode { label, key, props } => {
5460                        preview.check_insert_node(&key)?;
5461                        preview.note_insert_node(&key, &props);
5462                        recs.push(WalRecord::InsertNode { label, key, props });
5463                    }
5464                    BatchOp::InsertEdge {
5465                        edge_type,
5466                        src_key,
5467                        dst_key,
5468                    } => {
5469                        if preview.prepare_insert_edge(&edge_type, &src_key, &dst_key)? {
5470                            preview.note_insert_edge(&edge_type, &src_key, &dst_key);
5471                            recs.push(WalRecord::InsertEdge {
5472                                edge_type,
5473                                src_key,
5474                                dst_key,
5475                            });
5476                        }
5477                    }
5478                    BatchOp::SetProp { key, field, value } => {
5479                        if let Some(view_name) = preview.db.view_store.view_for_prop(&field) {
5480                            return Err(GraphError::ViewPropReadOnly {
5481                                view_name: view_name.to_string(),
5482                            });
5483                        }
5484                        preview.check_live_key(&key)?;
5485                        preview.note_set_prop(&key, &field, &value);
5486                        recs.push(WalRecord::SetProp { key, field, value });
5487                    }
5488                    BatchOp::RemoveProp { key, field } => {
5489                        if preview.prepare_remove_prop(&key, &field)? {
5490                            preview.note_remove_prop(&key, &field);
5491                            recs.push(WalRecord::RemoveProp { key, field });
5492                        }
5493                    }
5494                    BatchOp::DeleteEdge {
5495                        edge_type,
5496                        src_key,
5497                        dst_key,
5498                    } => {
5499                        if preview.prepare_delete_edge(&edge_type, &src_key, &dst_key)? {
5500                            preview.note_delete_edge(&edge_type, &src_key, &dst_key);
5501                            recs.push(WalRecord::DeleteEdge {
5502                                edge_type,
5503                                src_key,
5504                                dst_key,
5505                            });
5506                        }
5507                    }
5508                    BatchOp::DeleteNode { key } => {
5509                        preview.check_live_key(&key)?;
5510                        preview.note_delete_node(&key);
5511                        recs.push(WalRecord::DeleteNode { key });
5512                    }
5513                    BatchOp::CreateRule(def) => {
5514                        preview.check_create_rule(&def)?;
5515                        let def_bytes =
5516                            bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
5517                                detail: format!("serialize rule: {e}"),
5518                            })?;
5519                        preview.note_create_rule(&def);
5520                        recs.push(WalRecord::CreateRule { def_bytes });
5521                    }
5522                    BatchOp::DeleteRule { name } => {
5523                        preview.check_delete_rule(&name)?;
5524                        preview.note_delete_rule(&name);
5525                        recs.push(WalRecord::DeleteRule { name });
5526                    }
5527                    BatchOp::RenameNode { old_key, new_key } => {
5528                        preview.check_rename_node(&old_key, &new_key)?;
5529                        preview.note_rename_node(&old_key, &new_key);
5530                        recs.push(WalRecord::RenameNode { old_key, new_key });
5531                    }
5532                    BatchOp::InsertEdgeUpsert {
5533                        edge_type,
5534                        src_key,
5535                        dst_key,
5536                        placeholder_label,
5537                    } => {
5538                        // Auto-create any missing endpoints as plain InsertNode ops.
5539                        // Rules fire and last-change is updated for each created node.
5540                        for key in [&src_key, &dst_key] {
5541                            if !preview.has_key(key) {
5542                                preview.check_insert_node(key)?;
5543                                preview.note_insert_node(key, &[]);
5544                                recs.push(WalRecord::InsertNode {
5545                                    label: placeholder_label.clone(),
5546                                    key: key.clone(),
5547                                    props: vec![],
5548                                });
5549                            }
5550                        }
5551                        if preview.prepare_insert_edge(&edge_type, &src_key, &dst_key)? {
5552                            preview.note_insert_edge(&edge_type, &src_key, &dst_key);
5553                            recs.push(WalRecord::InsertEdge {
5554                                edge_type,
5555                                src_key,
5556                                dst_key,
5557                            });
5558                        }
5559                    }
5560                }
5561            }
5562            recs
5563        };
5564        if recs.is_empty() {
5565            return Ok((0, 0));
5566        }
5567        // rewrite_wal_dense converts every InsertNode/InsertEdge into its
5568        // *Id form, so only the dense variants can appear in `recs` here.
5569        let recs = self.rewrite_wal_dense(recs)?;
5570        // The rewrite can empty a non-empty batch: a `SET n.ns` naming the
5571        // namespace the node is already in is a no-op and is dropped there. An
5572        // empty `Batch` frame would still take a commit sequence and a WAL
5573        // record, so a batch that turns out to be nothing writes nothing.
5574        if recs.is_empty() {
5575            return Ok((0, 0));
5576        }
5577        let nodes_inserted = recs
5578            .iter()
5579            .filter(|r| matches!(r, WalRecord::InsertNodeId { .. }))
5580            .count();
5581        let edges_inserted = recs
5582            .iter()
5583            .filter(|r| matches!(r, WalRecord::InsertEdgeId { .. }))
5584            .count();
5585        // Ingest / write_batch / query_write: one Batch frame, one fsync per call
5586        // under Strict.  Pass self.fsync directly so Strict stays Strict —
5587        // wal_needs_sync(Strict, _) always returns true regardless of op count.
5588        // Mapping Strict → Batched (the prior bug) caused wal_needs_sync to
5589        // short-circuit on single-op batches and silently skip the fsync.
5590        // Batched fsyncs only for multi-op batches; Relaxed always skips.
5591        self.log_then_apply_with(WalRecord::Batch(recs), ingest, self.fsync)?;
5592        Ok((nodes_inserted, edges_inserted))
5593    }
5594
5595    fn commit_batch(&mut self, ops: Vec<BatchOp>) -> Result<(usize, usize)> {
5596        self.commit_logged_batch(ops, None, None)
5597    }
5598
5599    /// Commit one submission WITHOUT an fsync — for use inside `commit_group`
5600    /// and the group-commit drain thread, which do a single group fsync later.
5601    fn commit_batch_nosync(&mut self, ops: Vec<BatchOp>) -> Result<(usize, usize)> {
5602        // Restore fsync policy even on panic via a raw-pointer drop guard.
5603        // A panic here would poison the RwLock anyway, but the correct policy
5604        // must be in place if the guard is ever unwrapped.
5605        struct RestoreFsync(*mut FsyncPolicy, FsyncPolicy);
5606        impl Drop for RestoreFsync {
5607            fn drop(&mut self) {
5608                // SAFETY: the pointer is valid for the full duration of
5609                // commit_batch_nosync; the guard is dropped before the frame
5610                // returns, and GraphDb outlives this frame.
5611                unsafe {
5612                    *self.0 = self.1;
5613                }
5614            }
5615        }
5616        let saved = self.fsync;
5617        // SAFETY: raw pointer into self; guard dropped within this frame.
5618        let _g = RestoreFsync(&mut self.fsync as *mut FsyncPolicy, saved);
5619        self.fsync = FsyncPolicy::Relaxed;
5620        self.commit_logged_batch(ops, None, None)
5621    }
5622
5623    /// Commit multiple op-batches as a **group**: each submission gets its own
5624    /// WAL `Batch` frame, but there is exactly **one** `Fs::sync` for the whole
5625    /// group (under `Strict` / `Batched` policy; `Relaxed` skips all syncs).
5626    ///
5627    /// # Durability semantics
5628    ///
5629    /// A crash before the group fsync may lose **all** submissions in the group.
5630    /// A crash after the group fsync preserves all of them.  No submission is
5631    /// ever torn: each WAL frame is either fully applied on replay or dropped
5632    /// in its entirety (CRC-protected frame boundaries).
5633    ///
5634    /// Events and subscription notifications fire per-submission immediately
5635    /// after apply, which may be before the group fsync.  From a subscriber's
5636    /// perspective this is equivalent to the `Relaxed` durability window.
5637    /// Submitters using [`SharedDb::submit_batch`] only unblock after the group
5638    /// fsync, so from their perspective durability is fully guaranteed.
5639    ///
5640    /// # MVCC interplay
5641    ///
5642    /// Each submission records its own `CommitDelta`; the fold-every-K counter
5643    /// increments per submission (not per group), preserving existing reader
5644    /// snapshot semantics.
5645    ///
5646    /// # Returns
5647    ///
5648    /// One `Result<(nodes_inserted, edges_inserted)>` per input group element,
5649    /// in order.  Failures are per-submission (validation errors); the group
5650    /// fsync error (if any) is returned as the second tuple element.
5651    pub fn commit_group(
5652        &mut self,
5653        groups: Vec<Vec<BatchOp>>,
5654    ) -> (Vec<Result<(usize, usize)>>, Option<GraphError>) {
5655        let mut results = Vec::with_capacity(groups.len());
5656        for ops in groups {
5657            results.push(self.commit_batch_nosync(ops));
5658        }
5659        let any_ok = results.iter().any(|r| r.is_ok());
5660        let sync_err = if self.fsync != FsyncPolicy::Relaxed && any_ok {
5661            self.fs
5662                .sync(core_storage::fs::FileId::Wal)
5663                .map_err(GraphError::Io)
5664                .err()
5665        } else {
5666            None
5667        };
5668        (results, sync_err)
5669    }
5670
5671    /// Like [`commit_group`] but skips the group fsync entirely.
5672    ///
5673    /// Used by the drain thread to apply submissions under the write lock and
5674    /// then perform the single fsync OUTSIDE the lock (via
5675    /// `core_storage::sync_wal_at`), reducing the write-lock hold time visible
5676    /// to concurrent readers.
5677    pub fn commit_group_nosync(
5678        &mut self,
5679        groups: Vec<Vec<BatchOp>>,
5680    ) -> Vec<Result<(usize, usize)>> {
5681        let mut results = Vec::with_capacity(groups.len());
5682        for ops in groups {
5683            results.push(self.commit_batch_nosync(ops));
5684        }
5685        results
5686    }
5687
5688    pub fn insert_node(
5689        &mut self,
5690        label: &str,
5691        key: &str,
5692        props: Vec<(String, Value)>,
5693    ) -> Result<()> {
5694        if self.read_only {
5695            return Err(GraphError::ReadOnly);
5696        }
5697        MutPreview::new(self).check_insert_node(key)?;
5698        self.log_dense(vec![WalRecord::InsertNode {
5699            label: label.into(),
5700            key: key.into(),
5701            props,
5702        }])
5703    }
5704
5705    pub fn insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
5706        if self.read_only {
5707            return Err(GraphError::ReadOnly);
5708        }
5709        if !MutPreview::new(self).prepare_insert_edge(edge_type, src_key, dst_key)? {
5710            return Ok(false);
5711        }
5712        self.log_dense(vec![WalRecord::InsertEdge {
5713            edge_type: edge_type.into(),
5714            src_key: src_key.into(),
5715            dst_key: dst_key.into(),
5716        }])?;
5717        Ok(true)
5718    }
5719
5720    pub fn set_prop(&mut self, key: &str, field: &str, value: Value) -> Result<()> {
5721        if self.read_only {
5722            return Err(GraphError::ReadOnly);
5723        }
5724        if let Some(view_name) = self.view_store.view_for_prop(field) {
5725            return Err(GraphError::ViewPropReadOnly {
5726                view_name: view_name.to_string(),
5727            });
5728        }
5729        MutPreview::new(self).check_live_key(key)?;
5730        self.log_dense(vec![WalRecord::SetProp {
5731            key: key.into(),
5732            field: field.into(),
5733            value,
5734        }])
5735    }
5736
5737    /// Set several properties on one live node in a single WAL commit.
5738    ///
5739    /// Every per-property check [`set_prop`](Self::set_prop) runs — view-owned
5740    /// names, live key, the `ns` immutability rule and its type — is evaluated
5741    /// for the whole list before any record is logged. The first refusal
5742    /// returns and the node is unchanged. An empty list writes nothing.
5743    pub fn set_props(&mut self, key: &str, props: Vec<(String, Value)>) -> Result<()> {
5744        if self.read_only {
5745            return Err(GraphError::ReadOnly);
5746        }
5747        MutPreview::new(self).check_live_key(key)?;
5748        for (field, _) in &props {
5749            if let Some(view_name) = self.view_store.view_for_prop(field) {
5750                return Err(GraphError::ViewPropReadOnly {
5751                    view_name: view_name.to_string(),
5752                });
5753            }
5754        }
5755        if props.is_empty() {
5756            return Ok(());
5757        }
5758        self.write_batch(|b| {
5759            for (field, value) in props {
5760                b.set_prop(key, &field, value);
5761            }
5762        })
5763        .map(|_| ())
5764    }
5765
5766    /// Remove a property. Returns `Ok(false)` (and does not log) if the field
5767    /// is already absent. Unknown or tombstoned keys are `Err(KeyNotFound)`.
5768    pub fn remove_prop(&mut self, key: &str, field: &str) -> Result<bool> {
5769        if self.read_only {
5770            return Err(GraphError::ReadOnly);
5771        }
5772        if let Some(view_name) = self.view_store.view_for_prop(field) {
5773            return Err(GraphError::ViewPropReadOnly {
5774                view_name: view_name.to_string(),
5775            });
5776        }
5777        if !MutPreview::new(self).prepare_remove_prop(key, field)? {
5778            return Ok(false);
5779        }
5780        self.log_then_apply(WalRecord::RemoveProp {
5781            key: key.into(),
5782            field: field.into(),
5783        })?;
5784        Ok(true)
5785    }
5786
5787    /// Delete a user edge. Returns `Ok(false)` (and does not log) if the edge
5788    /// is absent. Unknown keys are `Err(KeyNotFound)`. Rule-owned edges — in
5789    /// provenance, or a pair a live rule would derive — are `Err(RuleOwned)`
5790    /// (the rule would just put the edge back; delete or change the rule).
5791    pub fn delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
5792        if self.read_only {
5793            return Err(GraphError::ReadOnly);
5794        }
5795        if !MutPreview::new(self).prepare_delete_edge(edge_type, src_key, dst_key)? {
5796            return Ok(false);
5797        }
5798        self.log_then_apply(WalRecord::DeleteEdge {
5799            edge_type: edge_type.into(),
5800            src_key: src_key.into(),
5801            dst_key: dst_key.into(),
5802        })?;
5803        Ok(true)
5804    }
5805
5806    /// Delete a live node. Unknown or already-tombstoned keys are
5807    /// `Err(KeyNotFound)` and are not logged. Validation runs before the WAL
5808    /// write; `apply` of a logged `DeleteNode` for an already-tombstoned key
5809    /// (crash window) is a clean no-op.
5810    ///
5811    /// Returns a [`DeleteReport`] with counts of manual and derived edges
5812    /// removed (computed from live state before the deletion is applied).
5813    pub fn delete_node(&mut self, key: &str) -> Result<DeleteReport> {
5814        if self.read_only {
5815            return Err(GraphError::ReadOnly);
5816        }
5817        // Provenance must be loaded before we query provenance_touching.
5818        self.engine.ensure_provenance_loaded_mut();
5819        let id = self
5820            .ids
5821            .get(key)
5822            .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
5823
5824        // Count edges before the delete is applied so we can report counts.
5825        let derived_set: BTreeSet<(u32, u32, u32)> = self
5826            .engine
5827            .provenance_touching(id)
5828            .map(|(_, etype, src, dst)| (etype, src, dst))
5829            .collect();
5830        let derived_edges = derived_set.len() as u64;
5831
5832        let mut total_topo = 0u64;
5833        let tv = self.topo_view();
5834        for et in tv.etypes() {
5835            total_topo += tv.neighbors(et, Direction::Out, id).len() as u64
5836                + tv.neighbors(et, Direction::In, id).len() as u64;
5837        }
5838        // For symmetric rules (e.g. Overlap), a→b and b→a are two separate directed
5839        // triples in both the topo scan (Out and In from id) and in provenance_touching.
5840        // The subtraction remains correct because both counts include both directions.
5841        let manual_edges = total_topo.saturating_sub(derived_edges);
5842
5843        self.log_then_apply(WalRecord::DeleteNode { key: key.into() })?;
5844        Ok(DeleteReport {
5845            manual_edges,
5846            derived_edges,
5847        })
5848    }
5849
5850    /// Rename a live node's key.  The dense id (and therefore all edges,
5851    /// props, history, and last-change tracking) is unaffected.
5852    ///
5853    /// Returns `Err(KeyNotFound)` if `old` is not a live key.
5854    /// Returns `Err(DuplicateKey)` if `new` is already live.
5855    pub fn rename_node(&mut self, old: &str, new: &str) -> Result<()> {
5856        if self.read_only {
5857            return Err(GraphError::ReadOnly);
5858        }
5859        MutPreview::new(self).check_rename_node(old, new)?;
5860        self.log_then_apply(WalRecord::RenameNode {
5861            old_key: old.into(),
5862            new_key: new.into(),
5863        })
5864    }
5865
5866    /// Return the IVF drift counter for the dst-side candidate index of `rule`.
5867    /// `None` if the rule does not exist or is not approximate.
5868    ///
5869    /// The drift counter increments on IVF insert/remove after the last fit.
5870    /// When dst-side drift exceeds [`core_rules::IVF_DRIFT_REBUILD`], apply
5871    /// WAL-logs `RebuildRule` as a second commit (rebuild resets the counter).
5872    pub fn ivf_dst_drift(&self, rule: &str) -> Option<u64> {
5873        // SideIvfExport = (centroids, node→cluster, drift)
5874        self.engine
5875            .export_ivf_state()
5876            .remove(rule)
5877            .map(|(_src, dst)| dst.2)
5878    }
5879
5880    /// Validate and WAL-log a new rule, then backfill derived edges inside apply.
5881    /// Validation and duplicate-name check run before logging so invalid rules
5882    /// never enter the WAL.
5883    pub fn create_rule(&mut self, def: RuleDef) -> Result<()> {
5884        if self.read_only {
5885            return Err(GraphError::ReadOnly);
5886        }
5887        MutPreview::new(self).check_create_rule(&def)?;
5888        let def_bytes = bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
5889            detail: format!("serialize rule: {e}"),
5890        })?;
5891        self.log_then_apply(WalRecord::CreateRule { def_bytes })
5892    }
5893
5894    /// Override this handle's HNSW build-slice size, or `None` to restore
5895    /// [`core_rules::HNSW_BUILD_BATCH`].
5896    ///
5897    /// Exposed for tests that need a small slice without a large corpus; not
5898    /// part of the stable surface.
5899    #[doc(hidden)]
5900    pub fn set_hnsw_build_batch(&mut self, batch: Option<usize>) {
5901        self.engine.set_hnsw_build_batch(batch);
5902    }
5903
5904    /// Rules whose vector index is still being built, in name order.
5905    ///
5906    /// The same list [`GraphDb::stats`] reports per rule in `building`.
5907    /// After a clean open this includes a build a snapshot cut short, so
5908    /// `serve`'s ticker can pump it without a write.
5909    pub fn builds_in_progress(&self) -> Vec<BuildProgress> {
5910        self.engine.builds_in_progress()
5911    }
5912
5913    /// Advance any vector index still building and backfill each rule that
5914    /// finishes. Returns what is still outstanding.
5915    ///
5916    /// A map lookup when nothing is pending, so it is cheap to call on a timer.
5917    /// One write lock and at most [`core_rules::HNSW_BUILD_BATCH`] vector
5918    /// inserts per pending rule per call, so a caller can drive a large build
5919    /// to completion without ever holding the lock for more than a slice.
5920    ///
5921    /// A rule that finishes here is backfilled through the same
5922    /// `WalRecord::RebuildRule` second commit that IVF drift already uses, so
5923    /// its derived edges are produced by [`GraphDb::rebuild_rule`]'s code path
5924    /// and appear all at once.
5925    ///
5926    /// Every ordinary write pumps one slice on its own (see the post-commit
5927    /// hook in `log_then_apply_with`), so this is for quiescent stores and for
5928    /// operators who want the build finished before traffic arrives.
5929    pub fn pump_index_build(&mut self) -> Result<Vec<BuildProgress>> {
5930        Ok(self.pump_index_build_reporting()?.1)
5931    }
5932
5933    /// [`GraphDb::pump_index_build`], also reporting the builds that **this**
5934    /// call finished, so a progress display can say so.
5935    ///
5936    /// A build can be registered and completed inside a single call — that is
5937    /// what a mid-build snapshot looks like on reopen, where the index scan
5938    /// finishes the graph and only the backfill is outstanding — and the
5939    /// outstanding list alone cannot show that anything happened.
5940    pub fn pump_index_build_reporting(
5941        &mut self,
5942    ) -> Result<(Vec<BuildProgress>, Vec<BuildProgress>)> {
5943        // A read-only handle cannot issue the `RebuildRule` a finished build
5944        // needs, so it would advance the index and then silently fail to
5945        // produce the edges. Refusing is the honest answer.
5946        if self.read_only {
5947            return Err(GraphError::ReadOnly);
5948        }
5949        let finished = self.pump_one_slice();
5950        for done in &finished {
5951            // The index is whole but the rule still owns no edges. A failed
5952            // second commit must leave the rule re-pumpable rather than
5953            // silently edge-less, so the error is surfaced here — unlike the
5954            // post-commit hook, this call is not riding someone else's commit.
5955            self.log_then_apply(WalRecord::RebuildRule {
5956                name: done.rule.clone(),
5957            })?;
5958        }
5959        Ok((finished, self.engine.builds_in_progress()))
5960    }
5961
5962    /// Run the deferred candidate-index build, if it is still owed, against the
5963    /// graph as it stands *now* — before the caller applies anything.
5964    ///
5965    /// A no-op bool test once the indexes are populated, which is after the
5966    /// first write of the handle's life, and for a store with no rules at all.
5967    fn populate_indexes_before_write(&mut self) {
5968        if !self.engine.needs_index_population() {
5969            return;
5970        }
5971        // The retained snapshot blobs arrive with the V8 base sections; without
5972        // them the scan would rebuild every graph the snapshot already holds.
5973        self.ensure_v8_base_sections_loaded();
5974        if !self.engine.needs_index_population() {
5975            return;
5976        }
5977        let mut eng = std::mem::take(&mut self.engine);
5978        {
5979            let gm = make_graph_mut(
5980                &self.ids,
5981                &mut self.syms,
5982                &self.labels,
5983                build_props_view(&self.props, &self.base),
5984                &mut self.topo,
5985                &self.base,
5986                &mut self.edge_props,
5987            );
5988            eng.populate_indexes(&gm);
5989        }
5990        self.engine = eng;
5991    }
5992
5993    /// One slice of build work for every pending rule. Returns the rules whose
5994    /// index just became whole, which the caller must `RebuildRule`.
5995    ///
5996    /// Goes through the engine even with nothing pending when the indexes have
5997    /// not been populated yet: that call adopts the persisted graphs and, for
5998    /// an incomplete blob already registered at open, leaves the remainder to
5999    /// this slice rather than inserting it inline.
6000    fn pump_one_slice(&mut self) -> Vec<BuildProgress> {
6001        // The retained snapshot blobs — and the id count an interrupted build
6002        // is recognised against — arrive with the V8 base sections, which a
6003        // clean open reads lazily. Without this a freshly opened handle pumps
6004        // against empty retained state and concludes there is nothing to do,
6005        // which is precisely the store `build-index` exists for.
6006        self.ensure_v8_base_sections_loaded();
6007        let mut eng = std::mem::take(&mut self.engine);
6008        let finished = {
6009            let mut gm = make_graph_mut(
6010                &self.ids,
6011                &mut self.syms,
6012                &self.labels,
6013                build_props_view(&self.props, &self.base),
6014                &mut self.topo,
6015                &self.base,
6016                &mut self.edge_props,
6017            );
6018            eng.pump_index_build(&mut gm)
6019        };
6020        self.engine = eng;
6021        finished
6022    }
6023
6024    /// Register a sliced build a snapshot cut short, from blobs with
6025    /// `complete == false`.
6026    ///
6027    /// Peeks the V8 mmap for incomplete entries without copying complete
6028    /// graphs. V5–V7 already hold the blobs in the engine from restore.
6029    fn register_outstanding_index_builds(&mut self) {
6030        if self.engine.indexes_populated() {
6031            return;
6032        }
6033        let extra = self.collect_incomplete_hnsw_blobs();
6034        let mut eng = std::mem::take(&mut self.engine);
6035        {
6036            let gm = make_graph_mut(
6037                &self.ids,
6038                &mut self.syms,
6039                &self.labels,
6040                build_props_view(&self.props, &self.base),
6041                &mut self.topo,
6042                &self.base,
6043                &mut self.edge_props,
6044            );
6045            eng.register_incomplete_hnsw_builds(&extra, &gm);
6046        }
6047        self.engine = eng;
6048    }
6049
6050    /// Incomplete `(src, dst)` HNSW blobs from the V8 mmap, copied only when
6051    /// `complete` is false. Empty when there is no mmap base (V5–V7 uses the
6052    /// engine's retained map instead).
6053    fn collect_incomplete_hnsw_blobs(&self) -> BTreeMap<String, (Vec<u8>, Vec<u8>)> {
6054        let Some(base) = &self.base else {
6055            return BTreeMap::new();
6056        };
6057        let Ok(archived) = base.hnsw_section() else {
6058            return BTreeMap::new();
6059        };
6060        archived
6061            .rules
6062            .iter()
6063            .filter_map(|e| {
6064                let src = e.src_blob.as_slice();
6065                let dst = e.dst_blob.as_slice();
6066                if core_rules::hnsw::hnsw_blob_complete(src) == Some(false)
6067                    || core_rules::hnsw::hnsw_blob_complete(dst) == Some(false)
6068                {
6069                    Some((e.name.as_str().to_string(), (src.to_vec(), dst.to_vec())))
6070                } else {
6071                    None
6072                }
6073            })
6074            .collect()
6075    }
6076
6077    /// WAL-log rule deletion. Returns RuleNotFound if the rule does not exist.
6078    pub fn delete_rule(&mut self, name: &str) -> Result<()> {
6079        if self.read_only {
6080            return Err(GraphError::ReadOnly);
6081        }
6082        MutPreview::new(self).check_delete_rule(name)?;
6083        self.log_then_apply(WalRecord::DeleteRule { name: name.into() })
6084    }
6085
6086    /// Return a snapshot of all registered rules.
6087    pub fn rules(&self) -> Vec<RuleDef> {
6088        self.engine.rules().cloned().collect()
6089    }
6090
6091    // -----------------------------------------------------------------------
6092    // Rule suggestion API
6093    // -----------------------------------------------------------------------
6094
6095    /// Profile the database and suggest linking rules with previewed edge counts.
6096    ///
6097    /// Uses the default seed ([`core_rules::SUGGEST_DEFAULT_SEED`]) for deterministic
6098    /// sampling. Suggestions are sorted by estimated edge count (descending).
6099    /// **NO auto-accept** — call [`GraphDb::create_rule`] explicitly to apply.
6100    pub fn suggest_rules(&self) -> Vec<core_rules::RuleSuggestion> {
6101        self.suggest_rules_seeded(core_rules::SUGGEST_DEFAULT_SEED)
6102    }
6103
6104    /// Like [`suggest_rules`] but with a caller-supplied RNG seed for
6105    /// reproducibility. Same seed + same data = identical output.
6106    pub fn suggest_rules_seeded(&self, seed: u64) -> Vec<core_rules::RuleSuggestion> {
6107        self.suggest_rules_with_config(&core_rules::suggest::SuggestConfig::default(), seed)
6108            .suggestions
6109    }
6110
6111    /// [`suggest_rules_seeded`] with a fully custom [`SuggestConfig`].
6112    ///
6113    /// Returns a [`core_rules::SuggestReport`] that includes both the candidate list
6114    /// and a `truncated` flag indicating whether the global budget fired before all
6115    /// candidates were evaluated.
6116    pub fn suggest_rules_with_config(
6117        &self,
6118        config: &core_rules::suggest::SuggestConfig,
6119        seed: u64,
6120    ) -> core_rules::SuggestReport {
6121        use std::collections::BTreeMap;
6122
6123        // Collect (node_id, key) pairs per label, skipping tombstoned nodes.
6124        let mut label_nodes: BTreeMap<String, Vec<(u32, String)>> = BTreeMap::new();
6125        for id in 0..self.ids.len() as u32 {
6126            let Some(key) = self.ids.key_of(id) else {
6127                continue;
6128            };
6129            let Some(&sym) = self.labels.get(id as usize) else {
6130                continue;
6131            };
6132            if sym == u32::MAX {
6133                continue; // tombstoned
6134            }
6135            let Some(label) = self.syms.resolve(sym) else {
6136                continue;
6137            };
6138            label_nodes
6139                .entry(label.to_string())
6140                .or_default()
6141                .push((id, key.to_string()));
6142        }
6143
6144        let existing = self.rules();
6145        let pv = build_props_view(&self.props, &self.base);
6146        let all_fields: Vec<String> = pv.field_names();
6147
6148        core_rules::suggest::suggest_rules(
6149            &label_nodes,
6150            &|id, field| pv.get(id, field).map(|vr| vr.into_value()),
6151            &all_fields,
6152            &existing,
6153            config,
6154            seed,
6155        )
6156    }
6157
6158    /// Recompute a rule's derived edges from scratch. WAL-logged so un-trip
6159    /// plus later mutations replay identically (rebuild is a pure function
6160    /// of state).
6161    ///
6162    /// Only exit from the tripped latch: if the full desired set fits the
6163    /// budget, it is applied completely and `tripped` clears; if it still
6164    /// exceeds the budget, provenance is left untouched and `tripped` stays
6165    /// true. Counts as a fire evaluation (see [`RuleStats::fires`]).
6166    /// Unknown rule → `RuleNotFound`, nothing logged.
6167    pub fn rebuild_rule(&mut self, name: &str) -> Result<()> {
6168        if self.read_only {
6169            return Err(GraphError::ReadOnly);
6170        }
6171        if !self.engine.rules().any(|r| r.name == name) {
6172            return Err(GraphError::RuleNotFound { name: name.into() });
6173        }
6174        self.log_then_apply(WalRecord::RebuildRule { name: name.into() })
6175    }
6176
6177    // -----------------------------------------------------------------------
6178    // Materialized view API
6179    // -----------------------------------------------------------------------
6180
6181    /// Register a new materialized property view, backfill its values for all
6182    /// existing nodes, and WAL-log the definition.
6183    ///
6184    /// # Errors
6185    /// - `ReadOnly`: called on an as-of instance.
6186    /// - `RuleInvalid`: name collision, view_prop collision, or invalid def.
6187    pub fn create_view(&mut self, def: ViewDef) -> Result<()> {
6188        if self.read_only {
6189            return Err(GraphError::ReadOnly);
6190        }
6191        // Pre-validate before WAL write.
6192        def.validate()
6193            .map_err(|e| GraphError::RuleInvalid { detail: e })?;
6194        if self.view_store.has_view(&def.name) {
6195            return Err(GraphError::RuleInvalid {
6196                detail: format!("view {:?} already exists", def.name),
6197            });
6198        }
6199        if let Some(existing) = self.view_store.view_for_prop(&def.view_prop) {
6200            return Err(GraphError::RuleInvalid {
6201                detail: format!(
6202                    "view_prop {:?} is already used by view {:?}",
6203                    def.view_prop, existing
6204                ),
6205            });
6206        }
6207        let def_bytes = bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
6208            detail: format!("serialize view: {e}"),
6209        })?;
6210        // Enable delta accumulation before the view is registered so subsequent
6211        // incremental edge events reach view maintenance from this point onward.
6212        // (The backfill inside create_view reads topo directly; it does not rely
6213        // on pending deltas.)
6214        self.engine.set_emit_deltas(true);
6215        self.log_then_apply(WalRecord::CreateView { def_bytes })
6216    }
6217
6218    /// Remove a named view and delete its values from every node.
6219    ///
6220    /// # Errors
6221    /// - `ReadOnly`: called on an as-of instance.
6222    /// - `RuleNotFound`: view does not exist.
6223    pub fn delete_view(&mut self, name: &str) -> Result<()> {
6224        if self.read_only {
6225            return Err(GraphError::ReadOnly);
6226        }
6227        if !self.view_store.has_view(name) {
6228            return Err(GraphError::RuleNotFound { name: name.into() });
6229        }
6230        let result = self.log_then_apply(WalRecord::DeleteView { name: name.into() });
6231        // After deletion, disable accumulation if no listeners remain.
6232        if !self.needs_emit_deltas() {
6233            self.engine.set_emit_deltas(false);
6234        }
6235        result
6236    }
6237
6238    /// Snapshot of all registered view definitions.
6239    pub fn views(&self) -> Vec<ViewDef> {
6240        self.view_store.views().cloned().collect()
6241    }
6242
6243    // -----------------------------------------------------------------------
6244    // Full-text-lite API
6245    // -----------------------------------------------------------------------
6246
6247    /// Enable full-text indexing for all nodes of `label` on property `field`.
6248    ///
6249    /// After this call, every subsequent write to `(label, field)` is reflected
6250    /// in the index incrementally.  Existing nodes are backfilled immediately.
6251    /// The declaration is persisted as a WAL record; the index itself is rebuilt
6252    /// from scratch on re-open (no snapshot format changes).
6253    ///
6254    /// # Errors
6255    /// - [`GraphError::ReadOnly`]: called on an as-of instance.
6256    /// - [`GraphError::RuleInvalid`]: `(label, field)` is already indexed.
6257    pub fn enable_fulltext(&mut self, label: &str, field: &str) -> Result<()> {
6258        if self.read_only {
6259            return Err(GraphError::ReadOnly);
6260        }
6261        if self.fulltext.is_enabled(label, field) {
6262            return Err(GraphError::RuleInvalid {
6263                detail: format!("full-text index for ({label:?}, {field:?}) already enabled"),
6264            });
6265        }
6266        self.log_then_apply(WalRecord::EnableFulltext {
6267            label: label.into(),
6268            field: field.into(),
6269        })
6270    }
6271
6272    /// Disable full-text indexing for `(label, field)` and drop its postings.
6273    ///
6274    /// # Errors
6275    /// - [`GraphError::ReadOnly`]: called on an as-of instance.
6276    /// - [`GraphError::RuleNotFound`]: `(label, field)` is not currently indexed.
6277    pub fn disable_fulltext(&mut self, label: &str, field: &str) -> Result<()> {
6278        if self.read_only {
6279            return Err(GraphError::ReadOnly);
6280        }
6281        if !self.fulltext.is_enabled(label, field) {
6282            return Err(GraphError::RuleNotFound {
6283                name: format!("fulltext({label},{field})"),
6284            });
6285        }
6286        self.log_then_apply(WalRecord::DisableFulltext {
6287            label: label.into(),
6288            field: field.into(),
6289        })
6290    }
6291
6292    /// Whether `(label, field)` is currently indexed for full-text search.
6293    pub fn is_fulltext_enabled(&self, label: &str, field: &str) -> bool {
6294        self.fulltext.is_enabled(label, field)
6295    }
6296
6297    /// Every `(label, field)` pair with a live full-text index, sorted.
6298    ///
6299    /// Note that [`GraphDb::search`] is keyed by field alone — a pair only
6300    /// declares which nodes are *indexed*, so callers that want to search
6301    /// everything indexed should query each distinct field once.
6302    pub fn fulltext_pairs(&self) -> Vec<(String, String)> {
6303        let mut v: Vec<(String, String)> = self.fulltext.enabled_pairs().cloned().collect();
6304        v.sort();
6305        v
6306    }
6307
6308    /// Enable an equality index for all nodes of `label` on scalar property
6309    /// `field`. Subsequent `WHERE n.field = value` lookups become O(matches)
6310    /// instead of an O(N_label) scan. Existing nodes are backfilled; the
6311    /// declaration persists via WAL and the postings rebuild on re-open.
6312    ///
6313    /// # Errors
6314    /// - [`GraphError::ReadOnly`]: called on an as-of instance.
6315    /// - [`GraphError::RuleInvalid`]: `(label, field)` is already indexed.
6316    pub fn enable_index(&mut self, label: &str, field: &str) -> Result<()> {
6317        if self.read_only {
6318            return Err(GraphError::ReadOnly);
6319        }
6320        if self.prop_index.is_enabled(label, field) {
6321            return Err(GraphError::RuleInvalid {
6322                detail: format!("property index for ({label:?}, {field:?}) already enabled"),
6323            });
6324        }
6325        self.log_then_apply(WalRecord::EnableIndex {
6326            label: label.into(),
6327            field: field.into(),
6328        })
6329    }
6330
6331    /// Disable the equality index for `(label, field)` and drop its postings.
6332    ///
6333    /// # Errors
6334    /// - [`GraphError::ReadOnly`]: called on an as-of instance.
6335    /// - [`GraphError::RuleNotFound`]: `(label, field)` is not currently indexed.
6336    pub fn disable_index(&mut self, label: &str, field: &str) -> Result<()> {
6337        if self.read_only {
6338            return Err(GraphError::ReadOnly);
6339        }
6340        if !self.prop_index.is_enabled(label, field) {
6341            return Err(GraphError::RuleNotFound {
6342                name: format!("index({label},{field})"),
6343            });
6344        }
6345        self.log_then_apply(WalRecord::DisableIndex {
6346            label: label.into(),
6347            field: field.into(),
6348        })
6349    }
6350
6351    /// Whether `(label, field)` currently has an equality index.
6352    pub fn is_index_enabled(&self, label: &str, field: &str) -> bool {
6353        self.prop_index.is_enabled(label, field)
6354    }
6355
6356    /// Search a full-text-indexed field.
6357    ///
6358    /// Returns `(node_key, match_count)` pairs sorted by match_count descending,
6359    /// ties broken by key (lexicographic).  Tombstoned nodes are excluded.
6360    ///
6361    /// **Query syntax:**
6362    /// - Space-separated terms are AND'd: `"foo bar"` requires both.
6363    /// - `OR` between terms forms disjunction: `"foo OR bar"` matches either.
6364    /// - Trailing `*` on a term is a prefix match: `"rust*"` matches `rustlang`, `rusty`.
6365    /// - `AND` keyword is accepted explicitly and is the default.
6366    /// - Tokenization is unicode-alphanumeric (same as index time); case-insensitive.
6367    ///
6368    /// **Unindexed field:** returns `Ok(vec![])` if `field` is not indexed.
6369    /// Pin: this is the documented, tested, stable behavior for v1.
6370    ///
6371    /// **Memory / performance:** O(postings) lookup; no scan.  The index is
6372    /// in-memory and proportional to total indexed text across all enabled fields.
6373    ///
6374    /// **v2 grammar:** supports `"phrase"`, `-negation`, `prefix*`, `OR`, `AND`.
6375    /// Results are BM25-scored (k1=1.2, b=0.75) and sorted by score descending,
6376    /// key ascending for deterministic tiebreaking.
6377    pub fn search(&self, field: &str, query: &str) -> Vec<(String, f64)> {
6378        // Resolve node_ids to keys (excluding tombstones) then re-sort by
6379        // (score DESC, key ASC) to give a deterministic, key-lexicographic
6380        // tiebreak.  FulltextIndex::search sorts by (score DESC, node_id ASC)
6381        // which diverges from key order when nodes were not inserted in key-lex order.
6382        let mut results: Vec<(String, f64)> = self
6383            .fulltext
6384            .search(field, query, 0)
6385            .into_iter()
6386            .filter_map(|(id, score)| self.ids.key_of(id).map(|key| (key.to_string(), score)))
6387            .collect();
6388        results.sort_by(|a, b| {
6389            b.1.partial_cmp(&a.1)
6390                .unwrap_or(std::cmp::Ordering::Equal)
6391                .then(a.0.cmp(&b.0))
6392        });
6393        results
6394    }
6395
6396    /// [`search`](Self::search), stopping at the `k` best hits.
6397    ///
6398    /// Same ranking and the same deterministic tiebreak, but the index drops
6399    /// everything past `k` before any key is resolved, so a caller that wants
6400    /// the top few out of a field that matched thousands does not pay to
6401    /// materialise and re-sort the tail. `k == 0` means no limit, exactly as
6402    /// [`search`](Self::search) behaves.
6403    ///
6404    /// The BM25 scoring itself is not bounded by `k` — every candidate is
6405    /// scored either way — so this trims the resolve and the sort, not the
6406    /// search.
6407    pub fn search_top(&self, field: &str, query: &str, k: usize) -> Vec<(String, f64)> {
6408        // A tombstoned id resolves to nothing, so asking the index for exactly
6409        // `k` could return fewer. Over-fetching a little and truncating after
6410        // the filter keeps the count right without unbounding the call.
6411        let want = if k == 0 { 0 } else { k.saturating_mul(2) };
6412        let mut results: Vec<(String, f64)> = self
6413            .fulltext
6414            .search(field, query, want)
6415            .into_iter()
6416            .filter_map(|(id, score)| self.ids.key_of(id).map(|key| (key.to_string(), score)))
6417            .collect();
6418        results.sort_by(|a, b| {
6419            b.1.partial_cmp(&a.1)
6420                .unwrap_or(std::cmp::Ordering::Equal)
6421                .then(a.0.cmp(&b.0))
6422        });
6423        if k > 0 {
6424            results.truncate(k);
6425        }
6426        results
6427    }
6428
6429    /// Hybrid search: Reciprocal Rank Fusion (RRF) over fulltext + vector results.
6430    ///
6431    /// Takes up to `4*k` fulltext hits for `(text_field, query_text)` and up to
6432    /// `4*k` vector hits for `(vector_field, query_vec, min=0.0)`, then fuses
6433    /// them with RRF using a fixed constant of 60.
6434    ///
6435    /// ```text
6436    /// score(d) = Σ  1 / (60 + rank_i(d))    (rank 1-based per list)
6437    /// ```
6438    ///
6439    /// Returns the top `k` nodes by fused score, ties broken by node key
6440    /// ascending (deterministic).
6441    ///
6442    /// # Vector leg fallback
6443    ///
6444    /// When `query_vec` is empty the vector leg is skipped entirely and
6445    /// results are ranked by the text list alone through the same RRF path
6446    /// (each text result scores `1/(60 + rank)` from that single list).
6447    ///
6448    /// When `label` is `None`, the vector leg **always** returns empty results.
6449    /// Internally `label` is mapped to `""`, which does not match any rule-created
6450    /// HNSW index (all such indexes are keyed to a specific non-empty label), and
6451    /// the brute-force fallback finds no nodes with an empty label.  The fused
6452    /// ranking is therefore text-only in this case.
6453    pub fn search_hybrid(
6454        &self,
6455        text_field: &str,
6456        query_text: &str,
6457        vector_field: &str,
6458        query_vec: &[f64],
6459        label: Option<&str>,
6460        k: usize,
6461    ) -> Vec<(String, f64)> {
6462        use std::collections::HashMap;
6463
6464        const RRF_K: f64 = 60.0;
6465        let pool = 4 * k;
6466
6467        // Accumulate per-node RRF scores.
6468        let mut scores: HashMap<String, f64> = HashMap::new();
6469
6470        // Text leg.
6471        let text_hits = self.search(text_field, query_text);
6472        for (rank0, (key, _count)) in text_hits.into_iter().take(pool).enumerate() {
6473            let rank = (rank0 + 1) as f64;
6474            *scores.entry(key).or_insert(0.0) += 1.0 / (RRF_K + rank);
6475        }
6476
6477        // Vector leg (skipped when query_vec is empty).
6478        if !query_vec.is_empty() {
6479            let vec_hits = self.find_similar_vector(vector_field, label, query_vec, pool, 0.0);
6480            for (rank0, (key, _sim)) in vec_hits.into_iter().enumerate() {
6481                let rank = (rank0 + 1) as f64;
6482                *scores.entry(key).or_insert(0.0) += 1.0 / (RRF_K + rank);
6483            }
6484        }
6485
6486        // Sort: score DESC, then key ASC for deterministic tie-breaking.
6487        let mut ranked: Vec<(String, f64)> = scores.into_iter().collect();
6488        ranked.sort_by(|a, b| {
6489            b.1.partial_cmp(&a.1)
6490                .unwrap_or(std::cmp::Ordering::Equal)
6491                .then(a.0.cmp(&b.0))
6492        });
6493        ranked.truncate(k);
6494        ranked
6495    }
6496
6497    /// For DST/testing: scratch BM25 search over live nodes without the index.
6498    /// Walks every live node, re-stems field tokens, computes corpus stats, and
6499    /// returns BM25-ranked results.
6500    ///
6501    /// The oracle: the ordered key list of `search(field, q)` must equal that of
6502    /// `scratch_search(field, q)` at every quiescent state.
6503    #[doc(hidden)]
6504    pub fn scratch_search(&self, field: &str, query: &str) -> Vec<(String, f64)> {
6505        use core_storage::fulltext::{parse_query, value_tokens_stemmed_with_positions};
6506        use std::collections::BTreeMap;
6507
6508        let groups = parse_query(query);
6509        if groups.is_empty() {
6510            return vec![];
6511        }
6512
6513        // --- Pass 1: collect all live indexed nodes with stemmed token data ---
6514        struct NodeData {
6515            key: String,
6516            /// stemmed_token → positions (sorted)
6517            tokens: BTreeMap<String, Vec<u32>>,
6518            dl: u32,
6519        }
6520
6521        let mut nodes: Vec<NodeData> = Vec::new();
6522        for id in 0..self.ids.len() as u32 {
6523            let Some(key) = self.ids.key_of(id) else {
6524                continue;
6525            };
6526            let Some(&sym) = self.labels.get(id as usize) else {
6527                continue;
6528            };
6529            if sym == u32::MAX {
6530                continue;
6531            }
6532            let label = match self.syms.resolve(sym) {
6533                Some(l) => l,
6534                None => continue,
6535            };
6536            if !self.fulltext.is_enabled(label, field) {
6537                continue;
6538            }
6539            let Some(value) = self.props_view().get(id, field).map(|vr| vr.into_value()) else {
6540                continue;
6541            };
6542            // Use value_tokens_stemmed_with_positions so list elements are
6543            // separated by POSITION_GAP — identical to the index path, which
6544            // prevents phrase queries from matching across element boundaries.
6545            let stemmed_with_pos = match &value {
6546                Value::Str(_) | Value::List(_) => value_tokens_stemmed_with_positions(&value),
6547                _ => continue,
6548            };
6549            let dl = stemmed_with_pos.len() as u32;
6550            let mut tok_map: BTreeMap<String, Vec<u32>> = BTreeMap::new();
6551            for (tok, pos) in stemmed_with_pos {
6552                tok_map.entry(tok).or_default().push(pos);
6553            }
6554            nodes.push(NodeData {
6555                key: key.to_string(),
6556                tokens: tok_map,
6557                dl,
6558            });
6559        }
6560
6561        if nodes.is_empty() {
6562            return vec![];
6563        }
6564
6565        // --- BM25 corpus stats ---
6566        let n = nodes.len() as f64;
6567        let avg_dl: f64 = nodes.iter().map(|nd| nd.dl as f64).sum::<f64>() / n;
6568        // df per stemmed token across all live indexed nodes.
6569        let mut df_map: BTreeMap<&str, f64> = BTreeMap::new();
6570        for nd in &nodes {
6571            for tok in nd.tokens.keys() {
6572                *df_map.entry(tok.as_str()).or_insert(0.0) += 1.0;
6573            }
6574        }
6575
6576        const K1: f64 = 1.2;
6577        const B: f64 = 0.75;
6578
6579        // --- Pass 2: score each node against each OR-group ---
6580        let mut results: Vec<(String, f64)> = Vec::new();
6581        for nd in &nodes {
6582            let dl = nd.dl as f64;
6583            let mut total_score = 0.0f64;
6584
6585            'group: for group in &groups {
6586                let mut group_score = 0.0f64;
6587
6588                for term in group {
6589                    if term.negated {
6590                        // Negated: if doc has this stemmed token → group fails.
6591                        let present = if term.prefix {
6592                            nd.tokens.keys().any(|t| t.starts_with(term.token.as_str()))
6593                        } else {
6594                            nd.tokens.contains_key(term.token.as_str())
6595                        };
6596                        if present {
6597                            continue 'group;
6598                        }
6599                        continue;
6600                    }
6601                    if term.prefix {
6602                        // Prefix: sum BM25 for all matching stemmed tokens.
6603                        let mut prefix_matched = false;
6604                        for (tok, positions) in &nd.tokens {
6605                            if tok.starts_with(term.token.as_str()) {
6606                                let tf = positions.len() as f64;
6607                                let df = df_map.get(tok.as_str()).copied().unwrap_or(1.0);
6608                                let idf = ((n - df + 0.5) / (df + 0.5) + 1.0).ln();
6609                                let tf_norm =
6610                                    tf * (K1 + 1.0) / (tf + K1 * (1.0 - B + B * dl / avg_dl));
6611                                group_score += idf * tf_norm;
6612                                prefix_matched = true;
6613                            }
6614                        }
6615                        if !prefix_matched {
6616                            continue 'group;
6617                        }
6618                    } else {
6619                        // term.token is already stemmed by parse_query; use directly.
6620                        match nd.tokens.get(term.token.as_str()) {
6621                            None => continue 'group,
6622                            Some(positions) => {
6623                                let tf = positions.len() as f64;
6624                                let df = df_map.get(term.token.as_str()).copied().unwrap_or(1.0);
6625                                let idf = ((n - df + 0.5) / (df + 0.5) + 1.0).ln();
6626                                let tf_norm =
6627                                    tf * (K1 + 1.0) / (tf + K1 * (1.0 - B + B * dl / avg_dl));
6628                                group_score += idf * tf_norm;
6629                            }
6630                        }
6631                    }
6632                }
6633
6634                if group_score > 0.0 {
6635                    total_score += group_score;
6636                }
6637            }
6638
6639            if total_score > 0.0 {
6640                results.push((nd.key.clone(), total_score));
6641            }
6642        }
6643
6644        results.sort_by(|a, b| {
6645            b.1.partial_cmp(&a.1)
6646                .unwrap_or(std::cmp::Ordering::Equal)
6647                .then(a.0.cmp(&b.0))
6648        });
6649        results
6650    }
6651
6652    /// Return the current view-maintained value of `view_prop` for node `key`.
6653    /// Equivalent to `get_prop` but documents that it reads a view-managed column.
6654    pub fn get_view_prop(&self, key: &str, view_prop: &str) -> Option<Value> {
6655        let id = self.ids.get(key)?;
6656        self.props_view()
6657            .get(id, view_prop)
6658            .map(|vr| vr.into_value())
6659    }
6660
6661    /// For testing / DST oracle: scratch recompute of a view value for one node.
6662    ///
6663    /// Returns `None` if the node does not exist, the view does not exist, or
6664    /// the view has no result for the node (e.g. Avg with no qualifying neighbors).
6665    #[doc(hidden)]
6666    pub fn scratch_view_value(&self, key: &str, view_name: &str) -> Option<Value> {
6667        let node = self.ids.get(key)?;
6668        let def = self.view_store.views().find(|v| v.name == view_name)?;
6669        // Use TopologyView so that NeighborAgg sees base + overlay edges
6670        // without materialising a temporary Topology (I1).
6671        let topo_view = self.topo_view();
6672        core_rules::views::compute_view_value(
6673            def,
6674            node,
6675            self.props_view(),
6676            &topo_view,
6677            &self.ids,
6678            &self.syms,
6679            &self.labels,
6680        )
6681    }
6682
6683    // -----------------------------------------------------------------------
6684    // Graph algorithm API
6685    // -----------------------------------------------------------------------
6686
6687    /// Run PageRank over the unified topology (manual + derived edges).
6688    ///
6689    /// Returns a [`PageRankReport`] with scores sorted descending (ties: key
6690    /// ascending).  Set `config.edge_type` to restrict to one edge type.
6691    /// `config.converged` is `true` only when the power iteration converged
6692    /// within `config.max_iters` and within any time budget.
6693    pub fn pagerank(&self, config: &crate::algo::PageRankConfig) -> crate::algo::PageRankReport {
6694        let topo = build_topo_view(&self.topo, &self.base);
6695        let edge_props = self.edge_props_view();
6696        crate::algo::pagerank(
6697            &topo,
6698            &self.ids,
6699            &self.syms,
6700            &self.labels,
6701            &edge_props,
6702            config,
6703        )
6704    }
6705
6706    /// Weakly-connected components over the unified topology (treated as
6707    /// undirected regardless of how edges were inserted).
6708    ///
6709    /// Component IDs are the key of the smallest member in the component
6710    /// (deterministic).  Result sorted by (component_id, key).
6711    pub fn connected_components(&self, config: &crate::algo::WccConfig) -> crate::algo::WccReport {
6712        let topo = build_topo_view(&self.topo, &self.base);
6713        let edge_props = self.edge_props_view();
6714        crate::algo::wcc(
6715            &topo,
6716            &self.ids,
6717            &self.syms,
6718            &self.labels,
6719            &edge_props,
6720            config,
6721        )
6722    }
6723
6724    /// Degree centrality for every live node.
6725    ///
6726    /// `direction`: `AlgoDir::Out` = out-degree, `AlgoDir::In` = in-degree,
6727    /// `AlgoDir::Both` = out + in (total directed degree).
6728    ///
6729    /// For one-shot ranking use this; for a live property updated on every
6730    /// write, create a Degree materialized view instead (see `docs/site/algorithms.md`).
6731    pub fn degree_centrality(
6732        &self,
6733        config: &crate::algo::DegreeConfig,
6734    ) -> crate::algo::DegreeReport {
6735        let topo = build_topo_view(&self.topo, &self.base);
6736        let edge_props = self.edge_props_view();
6737        crate::algo::degree_centrality(
6738            &topo,
6739            &self.ids,
6740            &self.syms,
6741            &self.labels,
6742            &edge_props,
6743            config,
6744        )
6745    }
6746
6747    /// Louvain community detection over the unified topology (undirected).
6748    ///
6749    /// See [`crate::algo::LouvainConfig`] for edge-type/weight/label
6750    /// restriction and [`crate::algo::CommunityReport`] for the shape of the
6751    /// result (communities sorted size-desc, then smallest member key asc).
6752    pub fn communities(&self, config: &crate::algo::LouvainConfig) -> crate::algo::CommunityReport {
6753        let topo = build_topo_view(&self.topo, &self.base);
6754        let edge_props = self.edge_props_view();
6755        crate::algo::louvain(
6756            &topo,
6757            &self.ids,
6758            &self.syms,
6759            &self.labels,
6760            &edge_props,
6761            config,
6762        )
6763    }
6764
6765    /// Write a vector of `(node_key, score)` pairs as `prop_name` on each node,
6766    /// atomically via a single write-batch (one WAL frame, one fsync).
6767    ///
6768    /// # Errors
6769    /// - [`GraphError::ReadOnly`]: called on an as-of instance.
6770    /// - [`GraphError::RuleInvalid`]: `prop_name` is managed by an existing view
6771    ///   (collision check mirrors `create_view`).
6772    /// - [`GraphError::KeyNotFound`]: a key in `scores` does not exist as a live node.
6773    pub fn write_scores(&mut self, prop_name: &str, scores: &[(String, f64)]) -> Result<()> {
6774        if self.read_only {
6775            return Err(GraphError::ReadOnly);
6776        }
6777        // Collision check: refuse if prop_name is view-managed.
6778        if let Some(view_name) = self.view_store.view_for_prop(prop_name) {
6779            return Err(GraphError::RuleInvalid {
6780                detail: format!(
6781                    "prop {:?} is managed by view {:?} and cannot be written as scores",
6782                    prop_name, view_name
6783                ),
6784            });
6785        }
6786        // Refuse if prop_name is a view name itself (confusing namespace collision).
6787        if self.view_store.has_view(prop_name) {
6788            return Err(GraphError::RuleInvalid {
6789                detail: format!(
6790                    "prop_name {:?} collides with an existing view name",
6791                    prop_name
6792                ),
6793            });
6794        }
6795        // Write all scores in a single crash-atomic batch.
6796        self.write_batch(|b| {
6797            for (key, score) in scores {
6798                b.set_prop(key, prop_name, Value::Float(*score));
6799            }
6800        })?;
6801        Ok(())
6802    }
6803
6804    /// Return the value of `field` for the node with key `key`, or `None` if
6805    /// the node or field is absent.  Reads through the overlay-over-base
6806    /// `ColumnsView`, materialising base values on demand (zero heap cost for
6807    /// overlay hits; one clone per base hit).
6808    pub fn get_prop(&self, key: &str, field: &str) -> Option<Value> {
6809        let id = self.ids.get(key)?;
6810        self.props_view().get(id, field).map(|vr| vr.into_value())
6811    }
6812
6813    pub fn has_node(&self, key: &str) -> bool {
6814        self.ids.get(key).is_some()
6815    }
6816
6817    /// Borrow the raw id map. Used by `NodeMask::from_keys` to resolve keys.
6818    pub(crate) fn ids(&self) -> &IdMap {
6819        &self.ids
6820    }
6821
6822    // -----------------------------------------------------------------------
6823    // Namespaces
6824    // -----------------------------------------------------------------------
6825
6826    /// The index `name` already has in `ns_names`, if any.
6827    fn ns_index_of(&self, name: &str) -> Option<u32> {
6828        self.ns_names
6829            .iter()
6830            .position(|n| n == name)
6831            .map(|i| i as u32)
6832    }
6833
6834    /// The index for `name`, appending it to `ns_names` when it is new.
6835    ///
6836    /// The table holds one entry per distinct namespace in the store — a
6837    /// tenant count, not a node count — so the linear scan is cheaper than a
6838    /// map and keeps `namespaces()` allocation-free of a second index.
6839    fn ns_index_for(&mut self, name: &str) -> u32 {
6840        match self.ns_index_of(name) {
6841            Some(i) => i,
6842            None => {
6843                self.ns_names.push(name.to_string());
6844                (self.ns_names.len() - 1) as u32
6845            }
6846        }
6847    }
6848
6849    /// The namespace name at `idx`, or [`NS_DEFAULT`] for an index this handle
6850    /// does not know (unreachable; the default is the narrowing answer).
6851    fn ns_name(&self, idx: u32) -> &str {
6852        self.ns_names
6853            .get(idx as usize)
6854            .map(String::as_str)
6855            .unwrap_or(NS_DEFAULT)
6856    }
6857
6858    /// The namespace index of dense node `id`, defaulting for an id with no
6859    /// entry (a node inserted before this handle rebuilt the array cannot
6860    /// exist: every insert path maintains it).
6861    fn node_ns_idx(&self, id: u32) -> u32 {
6862        self.node_ns
6863            .get(id as usize)
6864            .copied()
6865            .unwrap_or(NS_DEFAULT_IDX)
6866    }
6867
6868    /// File node `id` under namespace `name`, growing `node_ns` as `labels`
6869    /// grows. Called from `apply` for every node insert, live and replayed.
6870    fn set_node_ns(&mut self, id: u32, name: &str) {
6871        let idx = if name == NS_DEFAULT {
6872            NS_DEFAULT_IDX
6873        } else {
6874            self.ns_index_for(name)
6875        };
6876        if self.node_ns.len() <= id as usize {
6877            self.node_ns.resize(id as usize + 1, NS_DEFAULT_IDX);
6878        }
6879        self.node_ns[id as usize] = idx;
6880    }
6881
6882    /// Rebuild `node_ns` from the `ns` column — one pass, at the end of an
6883    /// open or a reload, after the snapshot is restored and the WAL replayed.
6884    ///
6885    /// A store with no `ns` column reads nothing: the column-name check fails
6886    /// and the vector is filled with one constant.
6887    fn rebuild_node_ns(&mut self) {
6888        let total = self.ids.len();
6889        self.ns_names.truncate(1);
6890        self.node_ns.clear();
6891        self.node_ns.resize(total, NS_DEFAULT_IDX);
6892        let has_ns_column = {
6893            let cv = self.props_view();
6894            cv.field_names().iter().any(|f| f == NS_PROP)
6895        };
6896        if !has_ns_column {
6897            return;
6898        }
6899        // Collected first so the props view is released before `ns_index_for`
6900        // takes `&mut self`.
6901        let named: Vec<(u32, String)> = {
6902            let cv = self.props_view();
6903            (0..total as u32)
6904                .filter_map(|id| match cv.get(id, NS_PROP).map(|vr| vr.into_value()) {
6905                    Some(Value::Str(s)) if s != NS_DEFAULT => Some((id, s)),
6906                    _ => None,
6907                })
6908                .collect()
6909        };
6910        for (id, name) in named {
6911            let idx = self.ns_index_for(&name);
6912            self.node_ns[id as usize] = idx;
6913        }
6914    }
6915
6916    /// Every namespace with at least one live node, in name order.
6917    ///
6918    /// `["default"]` on any store that has never named a namespace, including
6919    /// an empty one: a store is always at least its default namespace.
6920    pub fn namespaces(&self) -> Vec<String> {
6921        let mut out: BTreeSet<&str> = BTreeSet::new();
6922        out.insert(NS_DEFAULT);
6923        for (id, &idx) in self.node_ns.iter().enumerate() {
6924            if idx == NS_DEFAULT_IDX || !self.is_live_node(id as u32) {
6925                continue;
6926            }
6927            out.insert(self.ns_name(idx));
6928        }
6929        out.into_iter().map(str::to_string).collect()
6930    }
6931
6932    /// The namespace of `key`, or `None` when the key names no live node.
6933    pub fn namespace_of(&self, key: &str) -> Option<String> {
6934        let id = self.ids.get(key)?;
6935        if !self.is_live_node(id) {
6936            return None;
6937        }
6938        Some(self.ns_name(self.node_ns_idx(id)).to_string())
6939    }
6940
6941    /// Every live node in `namespace`, as a visibility mask.
6942    ///
6943    /// Built off `node_ns` on whichever handle this is, so on a temporal handle
6944    /// it is the namespace's membership at that commit. A name no node uses
6945    /// gives an empty mask — a namespace scope never widens.
6946    pub fn mask_for_namespace(&self, namespace: &str) -> crate::mask::NodeMask {
6947        let Some(idx) = self.ns_index_of(namespace) else {
6948            return crate::mask::NodeMask::from_ids(std::collections::HashSet::new());
6949        };
6950        let visible: std::collections::HashSet<u32> = (0..self.ids.len() as u32)
6951            .filter(|&id| self.node_ns_idx(id) == idx && self.is_live_node(id))
6952            .collect();
6953        crate::mask::NodeMask::from_ids(visible)
6954    }
6955
6956    /// Live-node test used by the namespace accessors: a deleted node keeps its
6957    /// dense id and its `node_ns` slot, and the label sentinel is what marks it
6958    /// gone — the same test `mask_for_role`'s label leg applies implicitly.
6959    fn is_live_node(&self, id: u32) -> bool {
6960        self.labels
6961            .get(id as usize)
6962            .is_some_and(|&sym| sym != u32::MAX)
6963            && self.ids.key_of(id).is_some()
6964    }
6965
6966    /// Per-namespace live node counts for [`Stats`], in name order.
6967    fn namespace_stats(&self) -> Vec<NamespaceStats> {
6968        let mut counts: BTreeMap<&str, usize> = BTreeMap::new();
6969        counts.insert(NS_DEFAULT, 0);
6970        for id in 0..self.ids.len() as u32 {
6971            if !self.is_live_node(id) {
6972                continue;
6973            }
6974            *counts
6975                .entry(self.ns_name(self.node_ns_idx(id)))
6976                .or_insert(0) += 1;
6977        }
6978        counts
6979            .into_iter()
6980            .filter(|&(name, n)| n > 0 || name == NS_DEFAULT)
6981            .map(|(name, nodes_live)| NamespaceStats {
6982                name: name.to_string(),
6983                nodes_live,
6984            })
6985            .collect()
6986    }
6987
6988    /// The namespace a create-class op would put its node in: the `ns` entry of
6989    /// the props it carries, normalised, with absent meaning [`NS_DEFAULT`].
6990    fn created_namespace<'a>(key: &str, props: &'a [(String, Value)]) -> Result<&'a str> {
6991        Ok(namespace_of_value(Self::sole_ns_entry(key, props)?))
6992    }
6993
6994    /// The one `ns` entry in a node's props, or `None` when it carries none.
6995    ///
6996    /// A props list naming `ns` twice is refused. Without that refusal the
6997    /// write path and the authorisation path can read the same list
6998    /// differently — one taking the first entry, the other the last — and
6999    /// `CREATE (n:L {ns: 'mine', ns: 'theirs'})` lands a node in a namespace
7000    /// the role was checked against the other of. One entry is the only shape
7001    /// where "the node's namespace" is a single fact, so it is the only shape
7002    /// accepted, and every reader of it agrees by construction.
7003    fn sole_ns_entry<'a>(key: &str, props: &'a [(String, Value)]) -> Result<Option<&'a Value>> {
7004        let mut found: Option<&'a Value> = None;
7005        for (field, value) in props {
7006            if field != NS_PROP {
7007                continue;
7008            }
7009            if found.is_some() {
7010                return Err(GraphError::RuleInvalid {
7011                    detail: format!(
7012                        "node {key}: {NS_PROP} is given more than once; a node has exactly \
7013                         one namespace"
7014                    ),
7015                });
7016            }
7017            found = Some(value);
7018        }
7019        Ok(found)
7020    }
7021
7022    /// The definition of the role a write authorisation names.
7023    ///
7024    /// `None` when `roles.json` was corrupt at open or the role has since been
7025    /// removed — neither can reach a write, because the authorisation carries a
7026    /// mask `mask_for_role` already resolved for that name.
7027    fn role_def_for(&self, role: &str) -> Option<&RoleDef> {
7028        self.roles.as_ref()?.iter().find(|r| r.name == role)
7029    }
7030
7031    /// Validate the `ns` entry of a node's props and drop an explicit default.
7032    ///
7033    /// Runs on the write path only (see `rewrite_wal_dense`), never on replay:
7034    /// a record that reached the WAL was already accepted here.
7035    fn normalise_insert_ns(
7036        key: &str,
7037        props: Vec<(String, Value)>,
7038    ) -> Result<(Vec<(String, Value)>, String)> {
7039        // One `ns` or none: this is where that is enforced, so every later
7040        // reader of the list — the authorisation gate, the two `apply` arms,
7041        // `node_ns` — is looking at a single entry and cannot disagree about
7042        // which one counts.
7043        Self::sole_ns_entry(key, &props)?;
7044        let mut name = NS_DEFAULT.to_string();
7045        let mut out = Vec::with_capacity(props.len());
7046        for (field, value) in props {
7047            if field != NS_PROP {
7048                out.push((field, value));
7049                continue;
7050            }
7051            let Value::Str(ref s) = value else {
7052                return Err(GraphError::RuleInvalid {
7053                    detail: format!(
7054                        "node {key}: {NS_PROP} must be a string naming a namespace, \
7055                         got {value:?}"
7056                    ),
7057                });
7058            };
7059            if !valid_namespace(s) {
7060                return Err(GraphError::RuleInvalid {
7061                    detail: format!(
7062                        "node {key}: {s:?} is not a valid namespace name — 1 to {NS_MAX_LEN} \
7063                         characters of [A-Za-z0-9_.-]"
7064                    ),
7065                });
7066            }
7067            name = s.clone();
7068            // An explicit default stores nothing, so a single-tenant store
7069            // never grows an `ns` column.
7070            if name != NS_DEFAULT {
7071                out.push((field, value));
7072            }
7073        }
7074        Ok((out, name))
7075    }
7076
7077    // -----------------------------------------------------------------------
7078    // RBAC role resolution
7079    // -----------------------------------------------------------------------
7080
7081    /// Parse `roles.json` bytes from `fs`.
7082    ///
7083    /// Return values:
7084    ///   `Ok(Some(roles))` — file absent (returns `vec![]`) **or** file present
7085    ///                       and valid; in both cases `mask_for_role` uses the
7086    ///                       list normally (an absent file means no roles defined).
7087    ///   `Ok(None)`        — file present but corrupt or unrecognised version
7088    ///                       → poisoned state; `mask_for_role` returns `Err` for
7089    ///                       any role name until the file is fixed and the DB
7090    ///                       re-opened (or `apply_schema` is called to repair it).
7091    ///
7092    /// Note: `None` signals corruption, not absence — the opposite of what an
7093    /// optional "file missing" convention would suggest.  The open path stores
7094    /// this result on `db.roles` directly.
7095    fn load_roles_from_fs(fs: &F) -> Result<Option<Vec<RoleDef>>> {
7096        let bytes = fs.read(FileId::Roles).map_err(GraphError::Io)?;
7097        if bytes.is_empty() {
7098            // Empty bytes means either the file is absent or zero-byte — both
7099            // are treated identically as "no roles defined".  A zero-byte
7100            // roles.json does NOT widen access: an absent file and a zero-byte
7101            // file both resolve to an empty role list (sees nothing by default).
7102            return Ok(Some(vec![]));
7103        }
7104        match serde_json::from_slice::<RolesFile>(&bytes) {
7105            Ok(f) if matches!(f.version, 1..=4) => Ok(Some(f.roles)),
7106            // Corrupt or unrecognised version (>4): poison the roles state.
7107            // Never widen: a version this binary does not know may carry a
7108            // narrowing this binary would not apply.
7109            _ => Ok(None),
7110        }
7111    }
7112
7113    /// Resolve a role to a node-visibility mask against the current graph state.
7114    ///
7115    /// Returns `Err` when:
7116    /// - `roles.json` was present but corrupt at open (poisoned state), or
7117    /// - `role` does not match any defined role name.
7118    ///
7119    /// The mask union is: explicit `keys` (unknown keys silently ignored) plus
7120    /// all live nodes carrying any label in `labels` that also pass the role's
7121    /// [`visible_where`](crate::roles::RoleDef::visible_where) predicate, if it
7122    /// has one.  Label resolution is live — new nodes of an allowed label are
7123    /// visible without re-applying the schema, and a property edited out of the
7124    /// predicate takes its node out of the mask on the next read.  An empty
7125    /// union = empty mask = sees nothing.
7126    ///
7127    /// This is the one resolver every read path calls, live and as-of alike, so
7128    /// the predicate applies everywhere at once.  On an as-of handle the role
7129    /// *definition* is the current one and the graph is the historical one: the
7130    /// predicate is evaluated against the property values at the commit being
7131    /// read.
7132    ///
7133    /// The result is memoised per `(role, commit_seq)`, so a scoped reader
7134    /// between two writes resolves the role once.  See
7135    /// [`RoleMaskCache`](crate::mask::RoleMaskCache) for why that cannot go
7136    /// stale.
7137    pub fn mask_for_role(&self, role: &str) -> Result<crate::mask::NodeMask> {
7138        self.role_masks
7139            .get_or_build(role, self.commit_seq, || self.build_mask_for_role(role))
7140            .map(|m| (*m).clone())
7141    }
7142
7143    /// The mask an [`AsOfScope`] names, resolved against this handle.
7144    ///
7145    /// Shared by [`GraphDb::query_at_scoped`] and
7146    /// [`GraphDb::query_at_scoped_in_namespace`] so one scope resolves one way
7147    /// however the namespace leg is added.
7148    fn mask_at_scope(&self, scope: AsOfScope<'_>) -> Result<crate::mask::NodeMask> {
7149        // One resolver answers "what may this role see" — `mask_for_role` — and
7150        // it runs against this handle, so on a temporal one the answer is the
7151        // as-of one.
7152        Ok(match scope {
7153            AsOfScope::Role(role) => self.mask_for_role(role)?,
7154            AsOfScope::Keys(keys) => {
7155                crate::mask::NodeMask::from_keys(self, keys.iter().map(String::as_str))
7156            }
7157            AsOfScope::RoleAndKeys(role, keys) => {
7158                self.mask_for_role(role)?
7159                    .intersect(&crate::mask::NodeMask::from_keys(
7160                        self,
7161                        keys.iter().map(String::as_str),
7162                    ))
7163            }
7164            AsOfScope::Namespace(namespace) => self.mask_for_namespace(namespace),
7165        })
7166    }
7167
7168    /// Resolve `role` against the current graph, ignoring the memo.
7169    fn build_mask_for_role(&self, role: &str) -> Result<crate::mask::NodeMask> {
7170        let roles = self.roles.as_ref().ok_or_else(|| GraphError::Corrupt {
7171            detail:
7172                "roles.json was corrupt at open; fix the file and re-open to restore role access"
7173                    .into(),
7174        })?;
7175        let def = roles
7176            .iter()
7177            .find(|r| r.name == role)
7178            .ok_or_else(|| GraphError::KeyNotFound {
7179                key: format!("role:{role}"),
7180            })?;
7181
7182        let mut visible = std::collections::HashSet::new();
7183
7184        // Key leg: resolve explicit keys to dense ids (unknown keys ignored).
7185        // An administrative grant, never narrowed by the predicate.
7186        for key in &def.keys {
7187            if let Some(id) = self.ids.get(key) {
7188                visible.insert(id);
7189            }
7190        }
7191
7192        // Label leg: live scan — iterate labels vec for matching symbol, and
7193        // when the role carries a predicate, test the property as well.  The
7194        // property comes from the store's own merged view (overlay over the
7195        // mmap'd base), so an as-of handle reads the values of its own commit.
7196        let props = def.visible_where.as_ref().map(|_| self.props_view());
7197        for label_name in &def.labels {
7198            if let Some(sym) = self.syms.get(label_name) {
7199                for (i, &s) in self.labels.iter().enumerate() {
7200                    if s != sym {
7201                        continue;
7202                    }
7203                    let id = i as u32;
7204                    match (&def.visible_where, &props) {
7205                        (Some(pred), Some(view)) => {
7206                            let value = view.get(id, &pred.field).map(|vr| vr.into_value());
7207                            if pred.holds(value.as_ref()) {
7208                                visible.insert(id);
7209                            }
7210                        }
7211                        _ => {
7212                            visible.insert(id);
7213                        }
7214                    }
7215                }
7216            }
7217        }
7218
7219        // Namespace leg: an intersection over the whole union, the key leg
7220        // included. A namespace is a tenancy boundary, so a key naming a node in
7221        // another tenant's namespace is not an administrative grant — and
7222        // `apply_schema` has already refused that role, so this only has to be
7223        // right about the node that moved into existence afterwards.
7224        if def.namespaces.is_some() {
7225            visible.retain(|&id| def.sees_namespace(self.ns_name(self.node_ns_idx(id))));
7226        }
7227
7228        Ok(crate::mask::NodeMask::from_ids(visible))
7229    }
7230
7231    /// Return the current list of role definitions.
7232    ///
7233    /// Returns an empty list when no roles are defined or when `roles.json`
7234    /// was corrupt at open (check [`mask_for_role`](Self::mask_for_role) for
7235    /// the fail-loud error in that case).
7236    pub fn roles(&self) -> Vec<RoleDef> {
7237        self.roles.as_deref().unwrap_or(&[]).to_vec()
7238    }
7239
7240    // ── Role-scoped write authz ───────────────────────────────────────────────
7241
7242    /// Execute `ops` with optional role-scoped write authorization.
7243    ///
7244    /// - `None` → full authority, identical to [`write_batch`](Self::write_batch)
7245    ///   (zero-cost bypass of all authz checks).
7246    /// - `Some(authz)` → the decision table is evaluated per-op BEFORE any WAL
7247    ///   record is built.  A denial returns an error with no WAL frame written
7248    ///   (all-or-nothing at the authz boundary, then at the MutPreview boundary).
7249    ///
7250    /// See the plan's "authz decision table" section for the full semantics.
7251    pub fn write_batch_authz(
7252        &mut self,
7253        authz: Option<&WriteAuthz>,
7254        ops: Vec<BatchOp>,
7255    ) -> Result<(usize, usize)> {
7256        // Thread authz as a direct parameter — never touches pending_write_authz.
7257        self.commit_logged_batch(ops, None, authz.cloned())
7258    }
7259
7260    /// Execute a Cypher write statement with role-scoped write authorization.
7261    ///
7262    /// Resolves scope + mask from `self.roles` inside the call (same write-guard
7263    /// lifetime as execution, satisfying §5 lock discipline).  The resolved
7264    /// `WriteAuthz` is stored as `pending_write_authz` for the duration of the
7265    /// call so that all inner `batch.commit()` calls are authz-checked.
7266    ///
7267    /// MERGE is handled specially: the MERGE scope precondition (§3.3) is
7268    /// checked in `exec_merge` BEFORE `has_node` to close the §6.2
7269    /// timing-oracle item (hidden ≡ absent for unscoped roles).
7270    ///
7271    /// Roles with `write: None` (v1 behavior) → `RoleWriteDenied` with
7272    /// "this endpoint is not permitted".
7273    pub fn query_write_authz(
7274        &mut self,
7275        role: &str,
7276        cypher: &str,
7277        params: &BTreeMap<String, Value>,
7278    ) -> Result<ResultSet> {
7279        // Resolve scope (fails fast if role has no write scope).
7280        // write:None → byte-identical v1 blanket-403 body (plan §v1-sidecar mandate).
7281        let scope =
7282            {
7283                let roles = self.roles.as_deref().ok_or_else(|| GraphError::Corrupt {
7284                    detail: "roles.json was corrupt at open; re-open to restore role access".into(),
7285                })?;
7286                let def = roles.iter().find(|r| r.name == role).ok_or_else(|| {
7287                    GraphError::KeyNotFound {
7288                        key: format!("role:{role}"),
7289                    }
7290                })?;
7291                def.write
7292                    .clone()
7293                    .ok_or_else(|| GraphError::RoleWriteDenied {
7294                        reason: "role-bound token: writes are not permitted".into(),
7295                    })?
7296            };
7297        // Resolve mask inside the call (same guard, §5 coherence).
7298        let mask = self.mask_for_role(role)?;
7299        self.pending_write_authz = Some(WriteAuthz {
7300            role: role.into(),
7301            scope,
7302            mask,
7303        });
7304        // RAII guard: always clears pending_write_authz on scope exit, including
7305        // on panic or early-return, mirroring the RestoreEmitDeltas precedent.
7306        struct ClearPendingAuthzOnDrop(*mut Option<WriteAuthz>);
7307        impl Drop for ClearPendingAuthzOnDrop {
7308            fn drop(&mut self) {
7309                // SAFETY: pointer into the owning GraphDb; guard is dropped
7310                // within this function's frame before it returns.
7311                unsafe { *self.0 = None };
7312            }
7313        }
7314        // SAFETY: raw pointer into self; guard dropped before this fn returns.
7315        let _authz_guard = ClearPendingAuthzOnDrop(&mut self.pending_write_authz as *mut _);
7316        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
7317            detail: format!("lex: {e}"),
7318        })?;
7319        let stmt = parse_write(&tokens).map_err(|e| GraphError::QueryError {
7320            detail: format!("parse: {e}"),
7321        })?;
7322        self.exec_write_stmt(stmt, params)
7323    }
7324
7325    /// Execute `ops` with optional role-scoped write authorization, suppressing
7326    /// fsync (for use inside the group-commit drain thread, which performs one
7327    /// group fsync after releasing the write lock).
7328    ///
7329    /// Identical to [`write_batch_authz`] except the fsync policy is temporarily
7330    /// forced to `Relaxed` for the duration of the call, matching the drain-thread
7331    /// contract established by [`commit_batch_nosync`].
7332    pub(crate) fn write_batch_authz_nosync(
7333        &mut self,
7334        authz: Option<&WriteAuthz>,
7335        ops: Vec<BatchOp>,
7336    ) -> Result<(usize, usize)> {
7337        let saved = self.fsync;
7338        struct RestoreFsync(*mut FsyncPolicy, FsyncPolicy);
7339        impl Drop for RestoreFsync {
7340            fn drop(&mut self) {
7341                // SAFETY: pointer into the owning GraphDb; guard is dropped
7342                // within the enclosing function's frame before it returns.
7343                unsafe { *self.0 = self.1 };
7344            }
7345        }
7346        // SAFETY: raw pointer into self; guard dropped before this fn returns.
7347        let _g = RestoreFsync(&mut self.fsync as *mut FsyncPolicy, saved);
7348        self.fsync = FsyncPolicy::Relaxed;
7349        self.commit_logged_batch(ops, None, authz.cloned())
7350    }
7351
7352    /// Execute a `/ingest` request with role-scoped write authorization.
7353    ///
7354    /// Resolves the role's `WriteScope` and `NodeMask` inside this call (same
7355    /// write-guard lifetime as the mutation, satisfying §5 lock discipline).
7356    /// Sets `pending_write_authz` for the duration of the call so that the
7357    /// `commit_ingest` → `commit_logged_batch` path picks up the authz context
7358    /// and evaluates the decision table per-op before any WAL write.
7359    ///
7360    /// §7.3: roles with empty `create_labels` will see every `InsertNode` op
7361    /// denied by the decision table with the appropriate §4.3 scope reason;
7362    /// no special HTTP-layer check is needed.
7363    ///
7364    /// Roles with `write: None` return `RoleWriteDenied` with
7365    /// "writes are not permitted" (byte-identical to v1 blanket 403).
7366    pub fn ingest_with_edges_authz(
7367        &mut self,
7368        role: &str,
7369        label: &str,
7370        rows: Vec<std::collections::BTreeMap<String, Value>>,
7371        opts: &crate::ingest::IngestOptions,
7372        edges: &[(String, String, String)],
7373    ) -> Result<crate::ingest::IngestReport> {
7374        // Resolve scope (fails fast if role has no write scope).
7375        // write:None → byte-identical v1 blanket-403 body (plan §v1-sidecar mandate).
7376        let scope =
7377            {
7378                let roles = self.roles.as_deref().ok_or_else(|| GraphError::Corrupt {
7379                    detail: "roles.json was corrupt at open; re-open to restore role access".into(),
7380                })?;
7381                let def = roles.iter().find(|r| r.name == role).ok_or_else(|| {
7382                    GraphError::KeyNotFound {
7383                        key: format!("role:{role}"),
7384                    }
7385                })?;
7386                def.write
7387                    .clone()
7388                    .ok_or_else(|| GraphError::RoleWriteDenied {
7389                        reason: "role-bound token: writes are not permitted".into(),
7390                    })?
7391            };
7392        let mask = self.mask_for_role(role)?;
7393        self.pending_write_authz = Some(WriteAuthz {
7394            role: role.into(),
7395            scope,
7396            mask,
7397        });
7398        // RAII guard: always clears pending_write_authz on scope exit, including
7399        // on panic or early-return, mirroring the RestoreEmitDeltas precedent.
7400        struct ClearPendingAuthzOnDrop(*mut Option<WriteAuthz>);
7401        impl Drop for ClearPendingAuthzOnDrop {
7402            fn drop(&mut self) {
7403                // SAFETY: pointer into the owning GraphDb; guard is dropped
7404                // within this function's frame before it returns.
7405                unsafe { *self.0 = None };
7406            }
7407        }
7408        // SAFETY: raw pointer into self; guard dropped before this fn returns.
7409        let _authz_guard = ClearPendingAuthzOnDrop(&mut self.pending_write_authz as *mut _);
7410        self.ingest_with_edges(label, rows, opts, edges)
7411    }
7412
7413    /// Evaluate the write-authz decision table for one `BatchOp`.
7414    ///
7415    /// Called by `commit_logged_batch` for each op when `pending_write_authz`
7416    /// is `Some`, BEFORE MutPreview.  A denial returns an error immediately;
7417    /// the remaining ops are not evaluated and no WAL frame is written.
7418    ///
7419    /// `batch_created` carries the key→label pairs of nodes that earlier ops in
7420    /// THIS batch will create.  Used by `InsertEdgeUpsert` to count same-batch
7421    /// placeholder nodes as visible (spec: "a placeholder endpoint the SAME
7422    /// batch creates counts as visible if its label passed the create-class gate").
7423    fn check_single_op_authz(
7424        &self,
7425        authz: &WriteAuthz,
7426        op: &BatchOp,
7427        batch_created: &BTreeMap<String, String>,
7428    ) -> Result<()> {
7429        // Helper: 3-way node status under the authz mask.
7430        //
7431        // Batch-created nodes (from earlier InsertNode in THIS batch) are treated
7432        // as Visible with their recorded label — their create gate already passed
7433        // and they are not yet in self.ids (not committed).  This fixes the
7434        // MERGE+ON CREATE SET case where InsertNode + SetProp arrive together:
7435        // the SetProp must not see the node as Absent.
7436        let node_status = |key: &str| -> NodeAuthzStatus {
7437            if let Some(label) = batch_created.get(key) {
7438                return NodeAuthzStatus::Visible(label.clone());
7439            }
7440            match self.ids.get(key) {
7441                None => NodeAuthzStatus::Absent,
7442                Some(id) if !authz.mask.contains_id(id) => NodeAuthzStatus::Hidden,
7443                Some(id) => {
7444                    let label = self
7445                        .labels
7446                        .get(id as usize)
7447                        .and_then(|&sym| {
7448                            if sym == u32::MAX {
7449                                None
7450                            } else {
7451                                self.syms.resolve(sym).map(str::to_string)
7452                            }
7453                        })
7454                        .unwrap_or_default();
7455                    NodeAuthzStatus::Visible(label)
7456                }
7457            }
7458        };
7459
7460        // Helper: is an InsertEdgeUpsert endpoint visible?
7461        // A same-batch placeholder counts as visible if its label passed
7462        // the create-class gate (spec "upsert placeholder-counts-as-visible").
7463        let upsert_ep_visible = |ep_key: &str, placeholder_label: &str| -> bool {
7464            // In store and visible?
7465            if let Some(id) = self.ids.get(ep_key) {
7466                return authz.mask.contains_id(id);
7467            }
7468            // Created by an earlier op in this batch?
7469            if let Some(created_label) = batch_created.get(ep_key) {
7470                return authz.scope.create_labels.contains(created_label);
7471            }
7472            // Will be created by THIS InsertEdgeUpsert: placeholder_label
7473            // must pass the create-class gate.
7474            authz
7475                .scope
7476                .create_labels
7477                .contains(&placeholder_label.to_string())
7478        };
7479
7480        match op {
7481            // RenameNode / CreateRule / DeleteRule: defense-in-depth gate.
7482            // These ops are never routed to role-scoped paths by the HTTP layer,
7483            // but we 403 them here to close any future bypass route.
7484            BatchOp::RenameNode { .. } | BatchOp::CreateRule(_) | BatchOp::DeleteRule { .. } => {
7485                return Err(GraphError::RoleWriteDenied {
7486                    reason: "role-bound token: this endpoint is not permitted".into(),
7487                });
7488            }
7489
7490            // ── CREATE-class: InsertNode ─────────────────────────────────────
7491            //
7492            // Decision table row 1 (scope-before-lookup): check label in
7493            // create_labels BEFORE any key lookup.  This is the structural
7494            // closure of the §6.2 timing-oracle item — the denial fires even
7495            // when the store is EMPTY (see test_create_scope_denied_empty_store).
7496            BatchOp::InsertNode { label, key, props } => {
7497                if !authz.scope.create_labels.contains(label) {
7498                    return Err(GraphError::RoleWriteDenied {
7499                        reason: format!(
7500                            "role-bound token: label '{}' not in write scope (create_labels)",
7501                            label
7502                        ),
7503                    });
7504                }
7505                // A role bound to namespaces may only create inside them. The
7506                // never-widen rule is about what a write makes visible to *any*
7507                // party, not only to the writer: a node this role could never
7508                // read back is a write into somebody else's tenancy. Also a
7509                // scope check, so it runs before the key lookup — it discloses
7510                // nothing about the store. Covers Cypher `CREATE` and the node
7511                // `MERGE` creates, both of which arrive as this op.
7512                // Resolved before the role lookup so a props list naming `ns`
7513                // twice is refused for every role, scoped or not: it is the same
7514                // malformed write the seam refuses, and leaving it to the seam
7515                // would mean the gate had already read one of the two.
7516                let target = Self::created_namespace(key, props)?;
7517                if let Some(def) = self.role_def_for(&authz.role) {
7518                    if !def.sees_namespace(target) {
7519                        return Err(GraphError::RoleWriteDenied {
7520                            reason: format!(
7521                                "role-bound token: namespace '{target}' not in the role's \
7522                                 namespaces"
7523                            ),
7524                        });
7525                    }
7526                }
7527                // Row 2/3: key lookup.
7528                match self.ids.get(key.as_str()) {
7529                    Some(id) if authz.mask.contains_id(id) => {
7530                        // Visible: DuplicateKey — let MutPreview handle this.
7531                    }
7532                    Some(_) => {
7533                        // Hidden: indistinguishable from absent to the role.
7534                        return Err(GraphError::RoleWriteDenied {
7535                            reason: "role-bound token: target node not visible".into(),
7536                        });
7537                    }
7538                    None => {
7539                        // Absent: proceed (create).
7540                    }
7541                }
7542            }
7543
7544            // ── UPDATE-class: SetProp, RemoveProp ────────────────────────────
7545            BatchOp::SetProp { key, .. } | BatchOp::RemoveProp { key, .. } => {
7546                if batch_created.contains_key(key.as_str()) {
7547                    // Batch-created node: create gate already passed this batch.
7548                    // Updating it in the same batch is always allowed, regardless
7549                    // of update_labels (ruling §3.5: "writer just created it").
7550                } else {
7551                    let label = match node_status(key) {
7552                        NodeAuthzStatus::Visible(lbl) => lbl,
7553                        _ => {
7554                            return Err(GraphError::RoleWriteDenied {
7555                                reason: "role-bound token: target node not visible".into(),
7556                            });
7557                        }
7558                    };
7559                    if !authz.scope.update_labels.contains(&label) {
7560                        return Err(GraphError::RoleWriteDenied {
7561                            reason: format!(
7562                                "role-bound token: label '{}' not in write scope (update_labels)",
7563                                label
7564                            ),
7565                        });
7566                    }
7567                }
7568            }
7569
7570            // ── DELETE-class: DeleteNode ─────────────────────────────────────
7571            BatchOp::DeleteNode { key } => {
7572                let label = match node_status(key) {
7573                    NodeAuthzStatus::Visible(lbl) => lbl,
7574                    _ => {
7575                        return Err(GraphError::RoleWriteDenied {
7576                            reason: "role-bound token: target node not visible".into(),
7577                        });
7578                    }
7579                };
7580                if !authz.scope.delete_labels.contains(&label) {
7581                    return Err(GraphError::RoleWriteDenied {
7582                        reason: format!(
7583                            "role-bound token: label '{}' not in write scope (delete_labels)",
7584                            label
7585                        ),
7586                    });
7587                }
7588            }
7589
7590            // ── DELETE-class: DeleteEdge ─────────────────────────────────────
7591            //
7592            // Derived-edge rejection runs BEFORE the delete_edge_types scope
7593            // check (spec §3.5: "existing derived-edge rejection precedes
7594            // delete_edge_types check").
7595            BatchOp::DeleteEdge {
7596                edge_type,
7597                src_key,
7598                dst_key,
7599            } => {
7600                // Check provenance ownership BEFORE scope (spec §3.5 ordering).
7601                if let (Some(src_id), Some(dst_id), Some(et_sym)) = (
7602                    self.ids.get(src_key.as_str()),
7603                    self.ids.get(dst_key.as_str()),
7604                    self.syms.get(edge_type.as_str()),
7605                ) {
7606                    if self.engine.is_owned(et_sym, src_id, dst_id) {
7607                        return Err(GraphError::RuleOwned {
7608                            detail: format!(
7609                                "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
7610                                 delete or change the owning rule"
7611                            ),
7612                        });
7613                    }
7614                    // Also check would_derive via MutPreview (empty overlay, pre-batch).
7615                    let preview = MutPreview::new(self);
7616                    if preview.would_derive(edge_type, src_key, dst_key) {
7617                        return Err(GraphError::RuleOwned {
7618                            detail: format!(
7619                                "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
7620                                 delete or change the owning rule, or a live rule would \
7621                                 re-derive it"
7622                            ),
7623                        });
7624                    }
7625                }
7626                // Scope check (AFTER derived-edge check, BEFORE endpoint visibility).
7627                if !authz.scope.delete_edge_types.contains(edge_type) {
7628                    return Err(GraphError::RoleWriteDenied {
7629                        reason: format!(
7630                            "role-bound token: edge type '{}' not in write scope (delete_edge_types)",
7631                            edge_type
7632                        ),
7633                    });
7634                }
7635                // Both endpoints must be visible.
7636                for ep_key in [src_key.as_str(), dst_key.as_str()] {
7637                    match self.ids.get(ep_key) {
7638                        None => {
7639                            return Err(GraphError::RoleWriteDenied {
7640                                reason: "role-bound token: edge endpoint not visible".into(),
7641                            });
7642                        }
7643                        Some(id) if !authz.mask.contains_id(id) => {
7644                            return Err(GraphError::RoleWriteDenied {
7645                                reason: "role-bound token: edge endpoint not visible".into(),
7646                            });
7647                        }
7648                        _ => {}
7649                    }
7650                }
7651            }
7652
7653            // ── EDGE-CREATE: InsertEdge ──────────────────────────────────────
7654            //
7655            // Scope check BEFORE endpoint lookup (preserves timing symmetry).
7656            BatchOp::InsertEdge {
7657                edge_type,
7658                src_key,
7659                dst_key,
7660            } => {
7661                if !authz.scope.create_edge_types.contains(edge_type) {
7662                    return Err(GraphError::RoleWriteDenied {
7663                        reason: format!(
7664                            "role-bound token: edge type '{}' not in write scope (create_edge_types)",
7665                            edge_type
7666                        ),
7667                    });
7668                }
7669                // Both endpoints must be visible. A node created by an earlier
7670                // InsertNode in the same batch (tracked in batch_created) counts
7671                // as visible if its label passed the create-class gate.
7672                for ep_key in [src_key.as_str(), dst_key.as_str()] {
7673                    if batch_created.contains_key(ep_key) {
7674                        // Created earlier this batch — already scope-checked.
7675                        continue;
7676                    }
7677                    match self.ids.get(ep_key) {
7678                        None => {
7679                            return Err(GraphError::RoleWriteDenied {
7680                                reason: "role-bound token: edge endpoint not visible".into(),
7681                            });
7682                        }
7683                        Some(id) if !authz.mask.contains_id(id) => {
7684                            return Err(GraphError::RoleWriteDenied {
7685                                reason: "role-bound token: edge endpoint not visible".into(),
7686                            });
7687                        }
7688                        _ => {}
7689                    }
7690                }
7691            }
7692
7693            // ── EDGE-CREATE: InsertEdgeUpsert ────────────────────────────────
7694            //
7695            // Scope check first; then endpoint visibility using same-batch
7696            // placeholder awareness (spec: "a placeholder endpoint the SAME
7697            // batch creates counts as visible if its label passed the
7698            // create-class gate").
7699            BatchOp::InsertEdgeUpsert {
7700                edge_type,
7701                src_key,
7702                dst_key,
7703                placeholder_label,
7704            } => {
7705                if !authz.scope.create_edge_types.contains(edge_type) {
7706                    return Err(GraphError::RoleWriteDenied {
7707                        reason: format!(
7708                            "role-bound token: edge type '{}' not in write scope (create_edge_types)",
7709                            edge_type
7710                        ),
7711                    });
7712                }
7713                // Check placeholder label against create_labels (create-class gate).
7714                // This ensures the auto-created endpoints are scope-allowed.
7715                for ep_key in [src_key.as_str(), dst_key.as_str()] {
7716                    if !upsert_ep_visible(ep_key, placeholder_label) {
7717                        return Err(GraphError::RoleWriteDenied {
7718                            reason: "role-bound token: edge endpoint not visible".into(),
7719                        });
7720                    }
7721                }
7722                // A placeholder is created with no props, so it lands in the
7723                // default namespace. A role that cannot read `default` must not
7724                // create one there, for the same reason it may not create a node
7725                // there outright.
7726                //
7727                // The refusal is byte-identical to the hidden-endpoint one above,
7728                // and deliberately so: this arm fires only for an endpoint that
7729                // does **not** exist, and the one above only for an endpoint that
7730                // does. Two different strings would make the pair an existence
7731                // oracle — ask for an upsert and read off whether the key is
7732                // taken. Hidden ≡ absent is the rule everywhere else in this
7733                // table and it holds here too.
7734                if let Some(def) = self.role_def_for(&authz.role) {
7735                    if !def.sees_namespace(NS_DEFAULT) {
7736                        for ep_key in [src_key.as_str(), dst_key.as_str()] {
7737                            if self.ids.get(ep_key).is_none() && !batch_created.contains_key(ep_key)
7738                            {
7739                                return Err(GraphError::RoleWriteDenied {
7740                                    reason: "role-bound token: edge endpoint not visible".into(),
7741                                });
7742                            }
7743                        }
7744                    }
7745                }
7746            }
7747        }
7748        Ok(())
7749    }
7750
7751    /// Write `roles` to `roles.json` atomically and update the in-memory list.
7752    ///
7753    /// Called by `apply_schema` when roles change. Never called on unchanged
7754    /// re-apply — this preserves byte-identical idempotency.
7755    pub(crate) fn commit_roles(&mut self, roles: Vec<RoleDef>) -> Result<()> {
7756        let file = RolesFile::new_versioned(roles.clone());
7757        let bytes = serde_json::to_vec(&file).map_err(|e| GraphError::Corrupt {
7758            detail: format!("roles serialization: {e}"),
7759        })?;
7760        self.fs
7761            .write_atomic(FileId::Roles, &bytes)
7762            .map_err(GraphError::Io)?;
7763        self.roles = Some(roles);
7764        // Rewriting the sidecar is not a commit, so `commit_seq` does not move
7765        // and a memoised mask would still match its version. Install a fresh
7766        // cache instead of clearing the shared one: a reader snapshot frozen
7767        // against the old definitions keeps the old `Arc` to itself and can
7768        // never publish an answer this handle would read back.
7769        self.role_masks = Arc::new(crate::mask::RoleMaskCache::new());
7770        // Refresh the MVCC frozen overlay so that reader() immediately sees the
7771        // updated role definitions without waiting for the next K-commit fold.
7772        self.fold_now();
7773        Ok(())
7774    }
7775
7776    fn view(&self) -> GraphView<'_> {
7777        GraphView {
7778            ids: &self.ids,
7779            syms: &self.syms,
7780            labels: &self.labels,
7781            props: self.props_view(),
7782            topo: self.topo_view(),
7783            edge_props: self.edge_props_view(),
7784            mask: None,
7785            prop_index: Some(&self.prop_index),
7786        }
7787    }
7788
7789    fn view_masked<'a>(&'a self, mask: &'a crate::mask::NodeMask) -> GraphView<'a> {
7790        GraphView {
7791            ids: &self.ids,
7792            syms: &self.syms,
7793            labels: &self.labels,
7794            props: self.props_view(),
7795            topo: self.topo_view(),
7796            edge_props: self.edge_props_view(),
7797            mask: Some(&mask.visible),
7798            prop_index: Some(&self.prop_index),
7799        }
7800    }
7801
7802    /// Execute a read-only Cypher query with a node visibility mask.
7803    ///
7804    /// Only nodes whose key is in `mask` are accessible: label scans, key
7805    /// lookups, and neighbor expansions all respect the mask. Edges where
7806    /// either endpoint is hidden are silently dropped.
7807    ///
7808    /// Returns `Err` with a "masked queries are read-only" message when
7809    /// `cypher` is a write statement (CREATE / MERGE / MATCH…SET / DELETE).
7810    pub fn query_masked(
7811        &self,
7812        cypher: &str,
7813        params: &std::collections::BTreeMap<String, Value>,
7814        mask: &crate::mask::NodeMask,
7815    ) -> Result<ResultSet> {
7816        // Reject write statements up front.
7817        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
7818            detail: format!("lex: {e}"),
7819        })?;
7820        if is_write_tokens(&tokens) {
7821            return Err(GraphError::MaskedReadOnly);
7822        }
7823        let union = parse_read(&tokens).map_err(|e| GraphError::QueryError {
7824            detail: format!("parse: {e}"),
7825        })?;
7826        // Each UNION part executes against the same masked view, so the mask
7827        // applies uniformly across the chain.
7828        execute_union(&self.view_masked(mask), &union, &Params(params)).map_err(|e| {
7829            GraphError::QueryError {
7830                detail: format!("execute: {e}"),
7831            }
7832        })
7833    }
7834
7835    pub fn node_ref(&self, key: &str) -> Option<NodeRef<'_, F>> {
7836        let id = self.ids.get(key)?;
7837        Some(NodeRef { db: self, id })
7838    }
7839
7840    /// BFS neighborhood expansion restricted to visible nodes in `mask`.
7841    ///
7842    /// Hidden nodes are never used as traversal intermediaries in either
7843    /// [`MaskMode::Omit`] or [`MaskMode::Stub`] — a visible node reachable
7844    /// only through a hidden node will not appear in results.
7845    ///
7846    /// In [`MaskMode::Stub`] mode, hidden nodes that are direct neighbours of
7847    /// a visited visible node are appended to the result as stub rows
7848    /// (`label` column is `null`, same key+depth columns as visible rows).
7849    /// They are NOT added to the BFS frontier.
7850    ///
7851    /// Returns `None` when `key` does not exist (caller should 404).
7852    ///
7853    /// **SECURITY**: role-token callers always pass an Omit-mode mask, so
7854    /// stub rows are never produced on the role path.
7855    pub fn neighborhood_masked(
7856        &self,
7857        key: &str,
7858        depth: u32,
7859        edge_types: Option<&[&str]>,
7860        dir: Dir,
7861        mask: &crate::mask::NodeMask,
7862    ) -> Option<ResultSet> {
7863        let start_id = self.ids.get(key)?;
7864        let view = self.view_masked(mask);
7865        let resolved: Option<Vec<u32>> = edge_types.map(|names| {
7866            names
7867                .iter()
7868                .filter_map(|name| view.syms.get(name))
7869                .collect()
7870        });
7871        let nb = neighborhood(&view, start_id, depth, resolved.as_deref(), dir);
7872        let mut rs = ResultSet::new(vec!["key".into(), "label".into(), "depth".into()]);
7873        // Collect visible BFS results (start_id at depth 0, BFS nodes after).
7874        let mut visited: Vec<(u32, u32)> = Vec::with_capacity(nb.nodes.len() + 1);
7875        visited.push((start_id, 0));
7876        for (nid, d) in &nb.nodes {
7877            let k = view.key_of(*nid);
7878            let label = view
7879                .label_of(*nid)
7880                .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
7881            rs.push_row(vec![
7882                Some(Value::Str(k.to_string())),
7883                Some(Value::Str(label.to_string())),
7884                Some(Value::Int(*d as i64)),
7885            ]);
7886            visited.push((*nid, *d));
7887        }
7888        // Stub mode: add hidden direct neighbours of each visited node as stubs.
7889        // Hidden nodes are edge-endpoints only — they are not added to the BFS
7890        // frontier, so the BFS never expands through them.
7891        if mask.mode() == crate::mask::MaskMode::Stub {
7892            let raw_view = self.view();
7893            let mut seen: std::collections::HashSet<u32> =
7894                visited.iter().map(|(id, _)| *id).collect();
7895            for (node_id, node_depth) in &visited {
7896                if *node_depth >= depth {
7897                    continue;
7898                }
7899                for e in expand(&raw_view, *node_id, resolved.as_deref(), dir) {
7900                    let nbr = if e.src == *node_id { e.dst } else { e.src };
7901                    if !mask.contains_id(nbr) && seen.insert(nbr) {
7902                        if let Some(k) = self.ids.key_of(nbr) {
7903                            rs.push_row(vec![
7904                                Some(Value::Str(k.to_string())),
7905                                None,
7906                                Some(Value::Int((*node_depth + 1) as i64)),
7907                            ]);
7908                        }
7909                    }
7910                }
7911            }
7912        }
7913        Some(rs)
7914    }
7915
7916    /// Live node's key, label, and columnar props. Unknown or tombstoned → `None`.
7917    pub fn node_info(&self, key: &str) -> Option<NodeInfo> {
7918        let n = self.node_ref(key)?;
7919        Some(NodeInfo {
7920            key: n.key().to_string(),
7921            label: n.label().to_string(),
7922            props: n.props(),
7923        })
7924    }
7925
7926    /// Look up a node with mask awareness.
7927    ///
7928    /// | Key state         | Omit mode       | Stub mode              |
7929    /// |-------------------|-----------------|------------------------|
7930    /// | does not exist    | `None` (→ 404)  | `None` (→ 404)         |
7931    /// | exists, visible   | `Some(Visible)` | `Some(Visible)`        |
7932    /// | exists, hidden    | `None` (→ 404)  | `Some(Restricted)`     |
7933    ///
7934    /// **SECURITY**: only call from client-mask (full-token) paths.
7935    /// Role-token paths must use [`node_info`] after an explicit visibility check.
7936    pub fn node_info_masked(
7937        &self,
7938        key: &str,
7939        mask: &crate::mask::NodeMask,
7940    ) -> Option<MaskedNodeResult> {
7941        let id = self.ids.get(key)?;
7942        if mask.contains_id(id) {
7943            Some(MaskedNodeResult::Visible(self.node_info(key)?))
7944        } else {
7945            match mask.mode() {
7946                crate::mask::MaskMode::Stub => Some(MaskedNodeResult::Restricted),
7947                crate::mask::MaskMode::Omit => None,
7948            }
7949        }
7950    }
7951
7952    /// Get edges for `key` with mask-aware hidden-endpoint handling.
7953    ///
7954    /// - Omit mode: edges to hidden endpoints are excluded (same as role-path filtering).
7955    /// - Stub mode: edges to hidden endpoints are included; `src_restricted`/`dst_restricted`
7956    ///   is `true` for each hidden endpoint.
7957    ///
7958    /// Unknown key → [`GraphError::KeyNotFound`].
7959    ///
7960    /// **SECURITY**: only call from client-mask (full-token) paths.
7961    pub fn node_edges_masked(
7962        &self,
7963        key: &str,
7964        mask: &crate::mask::NodeMask,
7965    ) -> Result<Vec<MaskedEdge>> {
7966        self.ensure_v8_base_sections_loaded();
7967        let id = self
7968            .ids
7969            .get(key)
7970            .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
7971        let derived: BTreeSet<(u32, u32, u32)> = self
7972            .engine
7973            .provenance_touching(id)
7974            .map(|(_rule, etype, src, dst)| (etype, src, dst))
7975            .collect();
7976        let mut edges = Vec::new();
7977        let tv = self.topo_view();
7978        for etype in tv.etypes() {
7979            // etype comes from the archived CSR (access_unchecked, no eager CRC).
7980            // A bit-flip in the large TOPOLOGY section can produce an etype id
7981            // that is not in the interner.  Return Corrupt rather than panic.
7982            let edge_type = self
7983                .syms
7984                .resolve(etype)
7985                .ok_or_else(|| GraphError::Corrupt {
7986                    detail: format!("v8: topology etype {etype} not in interner"),
7987                })?
7988                .to_string();
7989            for dir in [Direction::Out, Direction::In] {
7990                for &nbr in tv.neighbors(etype, dir, id).as_ref() {
7991                    let nbr_restricted = !mask.contains_id(nbr);
7992                    if nbr_restricted && mask.mode() == crate::mask::MaskMode::Omit {
7993                        continue;
7994                    }
7995                    let nbr_key = self
7996                        .ids
7997                        .key_of(nbr)
7998                        .ok_or_else(|| GraphError::Corrupt {
7999                            detail: format!("topology id {nbr} has no key"),
8000                        })?
8001                        .to_string();
8002                    let (src_id, dst_id, src_key, dst_key, src_restricted, dst_restricted) =
8003                        match dir {
8004                            Direction::Out => {
8005                                (id, nbr, key.to_string(), nbr_key, false, nbr_restricted)
8006                            }
8007                            Direction::In => {
8008                                (nbr, id, nbr_key, key.to_string(), nbr_restricted, false)
8009                            }
8010                        };
8011                    edges.push(MaskedEdge {
8012                        edge_type: edge_type.clone(),
8013                        src_key,
8014                        src_restricted,
8015                        dst_key,
8016                        dst_restricted,
8017                        derived: derived.contains(&(etype, src_id, dst_id)),
8018                    });
8019                }
8020            }
8021        }
8022        edges.sort_by(|a, b| {
8023            a.edge_type
8024                .cmp(&b.edge_type)
8025                .then(a.src_key.cmp(&b.src_key))
8026                .then(a.dst_key.cmp(&b.dst_key))
8027        });
8028        edges.dedup_by(|a, b| {
8029            a.edge_type == b.edge_type && a.src_key == b.src_key && a.dst_key == b.dst_key
8030        });
8031        Ok(edges)
8032    }
8033
8034    /// Every directed edge incident on `key`, both directions, every etype.
8035    ///
8036    /// Walk is `topology.etypes()` × `{Out, In}` × `neighbors()`. `derived` is
8037    /// membership in [`RuleEngine::provenance_touching`] (O(degree) via the
8038    /// Plan-8 `by_node` index). Sorted by `(edge_type, src_key, dst_key)`.
8039    /// Unknown key → [`GraphError::KeyNotFound`].
8040    pub fn node_edges(&self, key: &str) -> Result<Vec<EdgeInfo>> {
8041        self.ensure_v8_base_sections_loaded();
8042        let id = self
8043            .ids
8044            .get(key)
8045            .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
8046        let derived: BTreeSet<(u32, u32, u32)> = self
8047            .engine
8048            .provenance_touching(id)
8049            .map(|(_rule, etype, src, dst)| (etype, src, dst))
8050            .collect();
8051        let mut edges = Vec::new();
8052        let tv = self.topo_view();
8053        for etype in tv.etypes() {
8054            // Same guard as node_edges_masked: etype from unchecked-CRC CSR.
8055            let edge_type = self
8056                .syms
8057                .resolve(etype)
8058                .ok_or_else(|| GraphError::Corrupt {
8059                    detail: format!("v8: topology etype {etype} not in interner"),
8060                })?
8061                .to_string();
8062            for dir in [Direction::Out, Direction::In] {
8063                for &nbr in tv.neighbors(etype, dir, id).as_ref() {
8064                    let (src, dst, src_key, dst_key) = match dir {
8065                        Direction::Out => (
8066                            id,
8067                            nbr,
8068                            key.to_string(),
8069                            self.ids
8070                                .key_of(nbr)
8071                                .ok_or_else(|| GraphError::Corrupt {
8072                                    detail: format!("topology id {nbr} has no key"),
8073                                })?
8074                                .to_string(),
8075                        ),
8076                        Direction::In => (
8077                            nbr,
8078                            id,
8079                            self.ids
8080                                .key_of(nbr)
8081                                .ok_or_else(|| GraphError::Corrupt {
8082                                    detail: format!("topology id {nbr} has no key"),
8083                                })?
8084                                .to_string(),
8085                            key.to_string(),
8086                        ),
8087                    };
8088                    edges.push(EdgeInfo {
8089                        edge_type: edge_type.clone(),
8090                        src_key,
8091                        dst_key,
8092                        derived: derived.contains(&(etype, src, dst)),
8093                    });
8094                }
8095            }
8096        }
8097        edges.sort_by(|a, b| {
8098            a.edge_type
8099                .cmp(&b.edge_type)
8100                .then(a.src_key.cmp(&b.src_key))
8101                .then(a.dst_key.cmp(&b.dst_key))
8102        });
8103        // Self-loops appear in both Out and In; sort makes the pair adjacent
8104        // (sort key matches PartialEq for this case) so one pass drops the dup.
8105        edges.dedup();
8106        Ok(edges)
8107    }
8108
8109    // ── Backup ────────────────────────────────────────────────────────────────
8110
8111    /// Copy this store to `dest` as a consistent, verified snapshot.
8112    ///
8113    /// Copies every durable file in the database directory — `snapshot.bin`,
8114    /// `wal.bin`, all `wal.<N>.archive` files, `wal.floor`, `wal.genesis`, and
8115    /// `roles.json` — into a freshly created `dest` directory using OS-level
8116    /// `copy` calls (no large in-process buffers).
8117    ///
8118    /// # Consistency guarantee
8119    ///
8120    /// The guarantee is **process-local**: the caller holds `&self`, which
8121    /// prevents any concurrent writer in the **same process** from modifying
8122    /// the files during the copy.  Running `mushroomdb backup` against a
8123    /// directory that is **concurrently being written by another process** (e.g.
8124    /// `mushroomdb serve`) is **unsafe** — the copy can be torn.  The post-copy
8125    /// `verified: true` result reduces but does not eliminate the risk of a
8126    /// silent corrupt backup (CRC catches many bit-flips; it cannot catch a
8127    /// consistent mid-write snapshot).
8128    ///
8129    /// **The safe path for a live-served store is `POST /backup` on the HTTP
8130    /// server.** That handler acquires the read lock on the shared database
8131    /// before calling this method, which is the correct cross-process
8132    /// synchronisation point because the server is the single process writing
8133    /// the files.
8134    ///
8135    /// After copying, opens the destination read-only and runs the CRC section
8136    /// verifier (`verify_snapshot`) to confirm byte-for-byte integrity.
8137    /// `BackupReport::verified` reflects whether both checks passed.
8138    ///
8139    /// Returns `Err` when `self` is not backed by a `RealFs` (e.g. `SimFs`).
8140    pub fn backup_to(&self, dest: &std::path::Path) -> Result<BackupReport> {
8141        // Derive source directory from snapshot_path (RealFs only).
8142        let src_dir = match self.fs.snapshot_path() {
8143            Some(p) => p.parent().map(|d| d.to_path_buf()).ok_or_else(|| {
8144                GraphError::Io(std::io::Error::other("snapshot has no parent dir"))
8145            })?,
8146            None => {
8147                return Err(GraphError::Io(std::io::Error::other(
8148                    "backup_to requires a real filesystem (RealFs)",
8149                )))
8150            }
8151        };
8152
8153        std::fs::create_dir_all(dest)?;
8154
8155        let mut files: Vec<String> = Vec::new();
8156        let mut bytes: u64 = 0;
8157
8158        // Helper: copy src_dir/name → dest/name if the file exists.
8159        let mut try_copy = |name: &str| -> std::io::Result<()> {
8160            let src_path = src_dir.join(name);
8161            if src_path.exists() {
8162                let n = std::fs::copy(&src_path, dest.join(name))?;
8163                bytes += n;
8164                files.push(name.to_string());
8165            }
8166            Ok(())
8167        };
8168
8169        try_copy("snapshot.bin")?;
8170        try_copy("snapshot.bin.bak")?;
8171        try_copy("wal.bin")?;
8172        try_copy("wal.floor")?;
8173        try_copy("wal.genesis")?;
8174        try_copy("roles.json")?;
8175
8176        // Copy WAL archives.
8177        let archives = self.fs.list_archives()?;
8178        for n in &archives {
8179            let name = format!("wal.{n}.archive");
8180            let n_bytes = std::fs::copy(src_dir.join(&name), dest.join(&name))?;
8181            bytes += n_bytes;
8182            files.push(name);
8183        }
8184
8185        files.sort();
8186
8187        // Post-copy verification: open dest and run CRC checks.
8188        let snap_in_dest = dest.join("snapshot.bin").exists();
8189        let crc_ok = if snap_in_dest {
8190            crate::verify_snapshot(dest)
8191                .map(|results| results.iter().all(|(_, _, _, r)| r.is_ok()))
8192                .unwrap_or(false)
8193        } else {
8194            true // WAL-only store: nothing to CRC-check in snapshot
8195        };
8196        let opens_ok = GraphDb::<core_storage::fs::RealFs>::open(dest).is_ok();
8197        let verified = crc_ok && opens_ok;
8198
8199        Ok(BackupReport {
8200            files,
8201            bytes,
8202            verified,
8203        })
8204    }
8205
8206    // ── Export helpers ────────────────────────────────────────────────────────
8207
8208    /// All live nodes, sorted by key (deterministic).
8209    ///
8210    /// Reads base + WAL overlay. Tombstoned nodes are excluded.
8211    pub fn all_nodes_for_export(&self) -> Vec<NodeInfo> {
8212        self.ensure_v8_base_sections_loaded();
8213        let pv = self.props_view();
8214        let mut nodes = Vec::new();
8215        for id in 0..self.ids.len() as u32 {
8216            let Some(key) = self.ids.key_of(id) else {
8217                continue;
8218            };
8219            let Some(&sym) = self.labels.get(id as usize) else {
8220                continue;
8221            };
8222            if sym == u32::MAX {
8223                continue; // tombstoned
8224            }
8225            let Some(label) = self.syms.resolve(sym) else {
8226                continue;
8227            };
8228            let mut props = BTreeMap::new();
8229            for field in pv.field_names() {
8230                if let Some(vr) = pv.get(id, &field) {
8231                    props.insert(field, vr.into_value());
8232                }
8233            }
8234            nodes.push(NodeInfo {
8235                key: key.to_string(),
8236                label: label.to_string(),
8237                props,
8238            });
8239        }
8240        nodes.sort_by(|a, b| a.key.cmp(&b.key));
8241        nodes
8242    }
8243
8244    /// All directed edges, sorted by `(edge_type, src, dst)`. Each edge appears once.
8245    ///
8246    /// Derived edges carry `derived: true` and the creating rule's name in `rule`.
8247    /// Manual edges carry `derived: false` and `rule: None`.
8248    /// `weight` is the creating rule's `weight_prop` value read off the edge
8249    /// (numeric only), mirroring the convention used by [`GraphDb::explain`]
8250    /// and [`GraphDb::weighted_edges`]. Deterministic across runs on the same
8251    /// store state.
8252    pub fn all_edges_for_export(&self) -> Vec<ExportEdge> {
8253        self.ensure_v8_base_sections_loaded();
8254
8255        // Build (etype_sym, src_id, dst_id) → rule_name for O(1) derivation lookup.
8256        let mut prov: HashMap<(u32, u32, u32), String> = HashMap::new();
8257        for (rule_name, triples) in self.engine.provenance() {
8258            for &(etype, src, dst) in triples {
8259                prov.insert((etype, src, dst), rule_name.clone());
8260            }
8261        }
8262
8263        // rule_name → weight_prop, for O(1) lookup per derived edge.
8264        let weight_props: HashMap<&str, Option<&str>> = self
8265            .engine
8266            .rules()
8267            .map(|r| (r.name.as_str(), r.weight_prop.as_deref()))
8268            .collect();
8269
8270        let tv = self.topo_view();
8271        let ep = self.edge_props_view();
8272        let mut edges = Vec::new();
8273
8274        for id in 0..self.ids.len() as u32 {
8275            let Some(key) = self.ids.key_of(id) else {
8276                continue;
8277            };
8278            let Some(&lsym) = self.labels.get(id as usize) else {
8279                continue;
8280            };
8281            if lsym == u32::MAX {
8282                continue; // tombstoned
8283            }
8284
8285            for etype_sym in tv.etypes() {
8286                // etype from archived CSR (access_unchecked, no eager CRC).
8287                // Skip edges whose etype is not in the interner; this can only
8288                // occur with a corrupt large TOPOLOGY section (bit-flip on an
8289                // etype field in the archived data).  The function returns Vec,
8290                // not Result, so we continue rather than propagate.
8291                let Some(edge_type) = self.syms.resolve(etype_sym) else {
8292                    continue;
8293                };
8294                let edge_type = edge_type.to_string();
8295                for &nbr in tv.neighbors(etype_sym, Direction::Out, id).as_ref() {
8296                    let Some(dst_key) = self.ids.key_of(nbr) else {
8297                        continue; // skip corrupt entries
8298                    };
8299                    let prov_key = (etype_sym, id, nbr);
8300                    let rule = prov.get(&prov_key).cloned();
8301                    let derived = rule.is_some();
8302                    let weight = rule
8303                        .as_deref()
8304                        .and_then(|rn| weight_props.get(rn).copied().flatten())
8305                        .and_then(|prop| match ep.get(etype_sym, id, nbr, prop) {
8306                            Some(Value::Float(f)) => Some(f),
8307                            Some(Value::Int(i)) => Some(i as f64),
8308                            _ => None,
8309                        });
8310                    edges.push(ExportEdge {
8311                        edge_type: edge_type.clone(),
8312                        src: key.to_string(),
8313                        dst: dst_key.to_string(),
8314                        derived,
8315                        rule,
8316                        weight,
8317                    });
8318                }
8319            }
8320        }
8321
8322        edges.sort_by(|a, b| {
8323            a.edge_type
8324                .cmp(&b.edge_type)
8325                .then(a.src.cmp(&b.src))
8326                .then(a.dst.cmp(&b.dst))
8327        });
8328        edges
8329    }
8330
8331    /// What each edge type *is*, without building one record per edge.
8332    ///
8333    /// [`all_edges_for_export`](Self::all_edges_for_export) answers the same
8334    /// question by materialising every edge — three `String`s apiece, a
8335    /// provenance `HashMap` over every derived edge, and a final sort. That is
8336    /// the right shape for an export, and the wrong one for a summary: on a
8337    /// store with 1.3 M derived edges it allocates hundreds of megabytes to
8338    /// produce nine lines. This walks the topology instead, summing neighbour
8339    /// slice lengths and collecting *label symbols* rather than label strings,
8340    /// so the per-edge cost is an integer add and a set insert on a set with
8341    /// as many members as the store has labels.
8342    ///
8343    /// The rule names come off the rule *definitions*, which each declare the
8344    /// `edge_type` they derive, so naming them costs one pass over the rules
8345    /// rather than one provenance lookup per edge. That is also why `rules`
8346    /// is a list: two rules may derive the same type — the association store
8347    /// derives `INDUSTRY_ALIGNMENT` from both a talent→company and a
8348    /// talent→job rule — and naming only one of them would be a half-truth.
8349    /// A type with no rules is one written by hand.
8350    ///
8351    /// `sample` is the first edge of the type in the store's own id order,
8352    /// which is insertion order: deterministic for a given store, and not the
8353    /// same as key order, which cannot be had without resolving a key per
8354    /// edge. Sorted by `edge_type`.
8355    pub fn edge_type_census(&self) -> Vec<EdgeTypeCensus> {
8356        self.ensure_v8_base_sections_loaded();
8357
8358        let mut rules_by_type: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
8359        for r in self.engine.rules() {
8360            rules_by_type
8361                .entry(r.edge_type.as_str())
8362                .or_default()
8363                .insert(r.name.as_str());
8364        }
8365
8366        let tv = self.topo_view();
8367        let node_count = self.ids.len() as u32;
8368        let mut out = Vec::new();
8369        for etype_sym in tv.etypes() {
8370            // An etype the interner cannot resolve means a corrupt TOPOLOGY
8371            // section; skip it rather than name it, as `all_edges_for_export`
8372            // does for the same reason.
8373            let Some(edge_type) = self.syms.resolve(etype_sym) else {
8374                continue;
8375            };
8376            let mut edges: u64 = 0;
8377            let mut src_syms: BTreeSet<u32> = BTreeSet::new();
8378            let mut dst_syms: BTreeSet<u32> = BTreeSet::new();
8379            let mut sample: Option<(u32, u32)> = None;
8380            for id in 0..node_count {
8381                let Some(&lsym) = self.labels.get(id as usize) else {
8382                    continue;
8383                };
8384                if lsym == u32::MAX {
8385                    continue; // tombstoned
8386                }
8387                let nbrs = tv.neighbors(etype_sym, Direction::Out, id);
8388                let nbrs = nbrs.as_ref();
8389                if nbrs.is_empty() {
8390                    continue;
8391                }
8392                edges += nbrs.len() as u64;
8393                src_syms.insert(lsym);
8394                for &nbr in nbrs {
8395                    if let Some(&dsym) = self.labels.get(nbr as usize) {
8396                        if dsym != u32::MAX {
8397                            dst_syms.insert(dsym);
8398                        }
8399                    }
8400                }
8401                if sample.is_none() {
8402                    sample = Some((id, nbrs[0]));
8403                }
8404            }
8405            let resolve = |syms: &BTreeSet<u32>| -> Vec<String> {
8406                syms.iter()
8407                    .filter_map(|&s| self.syms.resolve(s))
8408                    .map(ToString::to_string)
8409                    .collect()
8410            };
8411            out.push(EdgeTypeCensus {
8412                edge_type: edge_type.to_string(),
8413                edges,
8414                src_labels: resolve(&src_syms),
8415                dst_labels: resolve(&dst_syms),
8416                rules: rules_by_type
8417                    .get(edge_type)
8418                    .map(|rs| rs.iter().map(ToString::to_string).collect())
8419                    .unwrap_or_default(),
8420                sample: sample.and_then(|(s, d)| {
8421                    Some((
8422                        self.ids.key_of(s)?.to_string(),
8423                        self.ids.key_of(d)?.to_string(),
8424                    ))
8425                }),
8426            });
8427        }
8428        out.sort_by(|a, b| a.edge_type.cmp(&b.edge_type));
8429        out
8430    }
8431
8432    /// All directed edges of `edge_type`, with the raw value of `weight_prop`
8433    /// on each edge when given.
8434    ///
8435    /// `weight` is `Some(f)` only when `weight_prop` is set and the edge
8436    /// carries that property with a numeric (`Int`/`Float`) value; otherwise
8437    /// `None` — callers that want a default weight (e.g. `1.0` for missing
8438    /// props) apply it themselves, matching the convention used internally
8439    /// by [`GraphDb::pagerank`], [`GraphDb::connected_components`],
8440    /// [`GraphDb::degree_centrality`], and [`GraphDb::communities`].
8441    ///
8442    /// Sorted by `(src, dst)` for determinism. Reads the unified topology
8443    /// (manual + rule-derived edges).  An unknown `edge_type` returns an
8444    /// empty vec.
8445    pub fn weighted_edges(
8446        &self,
8447        edge_type: &str,
8448        weight_prop: Option<&str>,
8449    ) -> Vec<(String, String, Option<f64>)> {
8450        let Some(etype_sym) = self.syms.get(edge_type) else {
8451            return Vec::new();
8452        };
8453        let tv = self.topo_view();
8454        let ep = self.edge_props_view();
8455        let mut out = Vec::new();
8456        for id in 0..self.ids.len() as u32 {
8457            let Some(key) = self.ids.key_of(id) else {
8458                continue;
8459            };
8460            let Some(&sym) = self.labels.get(id as usize) else {
8461                continue;
8462            };
8463            if sym == u32::MAX {
8464                continue; // tombstoned
8465            }
8466            for &nbr in tv.neighbors(etype_sym, Direction::Out, id).as_ref() {
8467                let Some(dst_key) = self.ids.key_of(nbr) else {
8468                    continue;
8469                };
8470                let weight = weight_prop.and_then(|prop| match ep.get(etype_sym, id, nbr, prop) {
8471                    Some(Value::Float(f)) => Some(f),
8472                    Some(Value::Int(i)) => Some(i as f64),
8473                    _ => None,
8474                });
8475                out.push((key.to_string(), dst_key.to_string(), weight));
8476            }
8477        }
8478        out.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
8479        out
8480    }
8481
8482    pub fn nodes_with_label(&self, label: &str) -> Vec<NodeRef<'_, F>> {
8483        self.view()
8484            .nodes_with_label(label)
8485            .into_iter()
8486            .map(|id| NodeRef { db: self, id })
8487            .collect()
8488    }
8489
8490    pub fn find_nodes(&self, label: &str, filter: &Filter) -> Vec<NodeRef<'_, F>> {
8491        let view = self.view();
8492        view.nodes_with_label(label)
8493            .into_iter()
8494            .filter(|&id| {
8495                eval_filter(filter, &|field| {
8496                    view.prop(id, field).map(|vr| vr.into_value())
8497                })
8498            })
8499            .map(|id| NodeRef { db: self, id })
8500            .collect()
8501    }
8502
8503    /// Returns `true` if any approximate (HNSW) VectorSimilar rule covers
8504    /// `field`.  Use as a capability probe: when `true`, `find_similar_vector`
8505    /// with `label = None` will use the native ANN path rather than the O(n)
8506    /// brute-force scan.
8507    pub fn has_vector_rule(&self, field: &str) -> bool {
8508        self.engine.hnsw_has_rule(field)
8509    }
8510
8511    /// How many HNSW graphs this handle has built from scratch since it was
8512    /// opened (one per side of an approximate rule).
8513    ///
8514    /// An open that restored every graph from the snapshot reports `0`.
8515    /// Exposed for tests that assert the open path reuses the persisted index
8516    /// rather than rebuilding it; not part of the stable surface.
8517    #[doc(hidden)]
8518    pub fn hnsw_build_count(&self) -> u64 {
8519        self.engine.hnsw_build_count()
8520    }
8521
8522    /// How many rules this handle still holds a lazily-decoded HNSW graph for.
8523    ///
8524    /// Zero before the first ANN query on a clean open, and again once the
8525    /// live indexes own the graphs. See [`core_rules::RuleEngine::lazy_hnsw_len`].
8526    /// Exposed for tests that assert the lazy copies are released; not part of
8527    /// the stable surface.
8528    #[doc(hidden)]
8529    pub fn lazy_hnsw_len(&self) -> usize {
8530        self.engine.lazy_hnsw_len()
8531    }
8532
8533    /// Find nodes whose `field` vector is most similar to `q` (cosine
8534    /// similarity), returning up to `k` results with similarity ≥ `min`,
8535    /// sorted descending.
8536    ///
8537    /// When `label` is `None` the search spans all labels (via
8538    /// `hnsw_search_any_dst` or a full brute-force scan); when `label` is
8539    /// `Some(lbl)` it restricts to nodes with that label.
8540    ///
8541    /// Uses the HNSW index when one is available (fast path); otherwise falls
8542    /// back to an O(n) brute-force scan.
8543    ///
8544    /// **The index supplies candidates, never scores.** Its own distances are
8545    /// `f32` (accurate to ~1e-6, so an exact duplicate scores 0.9999999), so
8546    /// every candidate is re-scored from the `f64` property vectors by
8547    /// [`exact_vector_similarity`] before `min`, the ordering and the reported
8548    /// score are decided. `k + VECTOR_RESCORE_MARGIN` candidates are fetched so
8549    /// the re-ordering cannot drop a true top-`k` member; see that constant for
8550    /// the rule. The score a caller receives is therefore the same number the
8551    /// brute-force path would have produced, to `f64` precision, and `min = 1.0`
8552    /// finds an exact duplicate.
8553    pub fn find_similar_vector(
8554        &self,
8555        field: &str,
8556        label: Option<&str>,
8557        q: &[f64],
8558        k: usize,
8559        min: f64,
8560    ) -> Vec<(String, f64)> {
8561        // Ensure any HNSW blobs retained from the snapshot are deserialized
8562        // before the first ANN query on a clean-open (no-WAL) path.  The
8563        // section read has to come first: on a clean open nothing else has
8564        // called it, so without it `retained_hnsw_blobs` is empty,
8565        // `ensure_hnsw_loaded` caches an empty map in its `OnceLock`, and every
8566        // approximate query on the handle runs brute force — correct results,
8567        // silently off the index.  Both calls are idempotent and cheap once hot.
8568        self.ensure_v8_base_sections_loaded();
8569        self.engine.ensure_hnsw_loaded();
8570        // L2-normalise query for cosine via dot product.
8571        let norm: f64 = q.iter().map(|x| x * x).sum::<f64>().sqrt();
8572        if norm == 0.0 {
8573            return vec![];
8574        }
8575        let q_unit: Vec<f64> = q.iter().map(|x| x / norm).collect();
8576
8577        // Try HNSW fast path.
8578        // `None` label searches across all VectorSimilar rules covering `field`
8579        // (merging their results); `Some(lbl)` restricts to rules whose
8580        // dst_label matches.  Returns `None` when no populated HNSW index
8581        // covers the request — the O(n) brute-force fallback handles that case.
8582        let over_k = k.saturating_add(VECTOR_RESCORE_MARGIN);
8583        let hnsw_hits = match label {
8584            Some(lbl) => self.engine.hnsw_search_dst(field, lbl, &q_unit, over_k),
8585            None => self.engine.hnsw_search_any_dst(field, &q_unit, over_k),
8586        };
8587        if let Some(hits) = hnsw_hits {
8588            // Candidates only: the index's `f32` similarity is discarded and
8589            // each hit is re-scored against the `f64` vectors.
8590            let view = self.view();
8591            let mut out: Vec<(String, f64)> = hits
8592                .into_iter()
8593                .filter_map(|(id, _)| {
8594                    let sim = exact_vector_similarity(&view, id, field, &q_unit)?;
8595                    if sim < min {
8596                        return None;
8597                    }
8598                    Some((self.ids.key_of(id)?.to_string(), sim))
8599                })
8600                .collect();
8601            out.sort_by(|a, b| {
8602                b.1.partial_cmp(&a.1)
8603                    .unwrap_or(std::cmp::Ordering::Equal)
8604                    .then_with(|| a.0.cmp(&b.0))
8605            });
8606            out.truncate(k);
8607            return out;
8608        }
8609
8610        // Brute-force fallback: O(n) scan (only reached when no HNSW index
8611        // covers the request).
8612        let view = self.view();
8613        let candidate_ids: Vec<u32> = match label {
8614            Some(lbl) => view.nodes_with_label(lbl),
8615            None => view.nodes_all(),
8616        };
8617        let mut scored: Vec<(String, f64)> = candidate_ids
8618            .into_iter()
8619            .filter_map(|id| {
8620                let dot = exact_vector_similarity(&view, id, field, &q_unit)?;
8621                if dot < min {
8622                    return None;
8623                }
8624                let key = self.ids.key_of(id)?.to_string();
8625                Some((key, dot))
8626            })
8627            .collect();
8628        scored.sort_by(|a, b| {
8629            b.1.partial_cmp(&a.1)
8630                .unwrap_or(std::cmp::Ordering::Equal)
8631                .then_with(|| a.0.cmp(&b.0))
8632        });
8633        scored.truncate(k);
8634        scored
8635    }
8636
8637    /// Like [`find_similar_vector`] but restricts results to nodes visible in
8638    /// `mask`. Hidden nodes never appear in results; the mask is applied
8639    /// **before** k-truncation so a caller still receives up to `k` visible
8640    /// hits.
8641    ///
8642    /// # HNSW path (widening beam)
8643    ///
8644    /// When an HNSW index covers the request, the beam starts at an over-fetch
8645    /// of `k × n / |visible|` (plus the rescore margin) when the mask's
8646    /// selectivity is known from the index length, otherwise at `k` plus that
8647    /// margin. If fewer than `k` visible candidates remain after the mask and
8648    /// `min` filter, the beam doubles — the same ×2 loop exact `VectorSimilar`
8649    /// rules use, capped at `ef_max()` (`EF_MAX` = 4,096). Reaching the cap,
8650    /// or a beam that comes back short of its own width, falls through to the
8651    /// exhaustive masked scan rather than returning a short result.
8652    ///
8653    /// Every surviving candidate is re-scored from the `f64` property vectors,
8654    /// exactly as [`find_similar_vector`] does and for the same reason.
8655    ///
8656    /// # Brute-force path
8657    ///
8658    /// When no HNSW index covers the request, or the beam cannot admit `k`
8659    /// hits, the function builds a masked [`GraphView`] so that `nodes_all` /
8660    /// `nodes_with_label` return only visible nodes, guaranteeing exact `k`
8661    /// results (or all visible nodes if fewer than `k` exist).
8662    pub fn find_similar_vector_masked(
8663        &self,
8664        field: &str,
8665        label: Option<&str>,
8666        q: &[f64],
8667        k: usize,
8668        min: f64,
8669        mask: &crate::mask::NodeMask,
8670    ) -> Vec<(String, f64)> {
8671        // Section read before the blob decode — see `find_similar_vector`.
8672        self.ensure_v8_base_sections_loaded();
8673        self.engine.ensure_hnsw_loaded();
8674        let norm: f64 = q.iter().map(|x| x * x).sum::<f64>().sqrt();
8675        if norm == 0.0 || k == 0 || mask.is_empty() {
8676            return vec![];
8677        }
8678        let q_unit: Vec<f64> = q.iter().map(|x| x / norm).collect();
8679
8680        let index_len = match label {
8681            Some(lbl) => self.engine.hnsw_dst_len(field, lbl, q_unit.len()),
8682            None => self.engine.hnsw_any_dst_len(field, q_unit.len()),
8683        };
8684        if let Some(n) = index_len {
8685            // Same ceiling the exact-rule widening loop in `hnsw_candidates`
8686            // consults — including the `with_ef_max` test hook.
8687            let cap = ef_max();
8688            let visible = mask.len();
8689            let mut ef = k.saturating_add(VECTOR_RESCORE_MARGIN);
8690            if visible > 0 && n > 0 {
8691                let over = k
8692                    .saturating_mul(n)
8693                    .div_ceil(visible)
8694                    .saturating_add(VECTOR_RESCORE_MARGIN);
8695                ef = ef.max(over);
8696            }
8697            loop {
8698                let hits = match label {
8699                    Some(lbl) => self
8700                        .engine
8701                        .hnsw_search_dst_with_ef(field, lbl, &q_unit, ef, ef),
8702                    None => self
8703                        .engine
8704                        .hnsw_search_any_dst_with_ef(field, &q_unit, ef, ef),
8705                };
8706                let Some(hits) = hits else {
8707                    break;
8708                };
8709                let full = hits.len() == ef;
8710                let mut out = self.score_masked_hnsw_hits(&hits, field, &q_unit, min, mask);
8711                if out.len() >= k {
8712                    out.truncate(k);
8713                    return out;
8714                }
8715                // Short of its width (frontier exhausted) or at the ceiling:
8716                // a wider beam reaches nothing new, so the scan answers.
8717                if !full || ef >= cap {
8718                    break;
8719                }
8720                ef = ef.saturating_mul(2);
8721            }
8722        }
8723
8724        // Brute-force fallback — masked view ensures only visible nodes are
8725        // enumerated by nodes_all(); nodes_with_label() does not filter by
8726        // mask so we apply view.visible() explicitly for the labeled case.
8727        let view = self.view_masked(mask);
8728        let candidate_ids: Vec<u32> = match label {
8729            Some(lbl) => view
8730                .nodes_with_label(lbl)
8731                .into_iter()
8732                .filter(|&id| view.visible(id))
8733                .collect(),
8734            None => view.nodes_all(),
8735        };
8736        let mut scored: Vec<(String, f64)> = candidate_ids
8737            .into_iter()
8738            .filter_map(|id| {
8739                let dot = exact_vector_similarity(&view, id, field, &q_unit)?;
8740                if dot < min {
8741                    return None;
8742                }
8743                let key = self.ids.key_of(id)?.to_string();
8744                Some((key, dot))
8745            })
8746            .collect();
8747        scored.sort_by(|a, b| {
8748            b.1.partial_cmp(&a.1)
8749                .unwrap_or(std::cmp::Ordering::Equal)
8750                .then_with(|| a.0.cmp(&b.0))
8751        });
8752        scored.truncate(k);
8753        scored
8754    }
8755
8756    /// Re-score HNSW candidates from the `f64` vectors, drop hidden / below-`min`
8757    /// hits, order by score then key. The index's own `f32` similarity is discarded.
8758    fn score_masked_hnsw_hits(
8759        &self,
8760        hits: &[(u32, f64)],
8761        field: &str,
8762        q_unit: &[f64],
8763        min: f64,
8764        mask: &crate::mask::NodeMask,
8765    ) -> Vec<(String, f64)> {
8766        let view = self.view_masked(mask);
8767        let mut out: Vec<(String, f64)> = hits
8768            .iter()
8769            .copied()
8770            .filter(|&(id, _)| mask.visible.contains(&id))
8771            .filter_map(|(id, _)| {
8772                let sim = exact_vector_similarity(&view, id, field, q_unit)?;
8773                if sim < min {
8774                    return None;
8775                }
8776                Some((self.ids.key_of(id)?.to_string(), sim))
8777            })
8778            .collect();
8779        out.sort_by(|a, b| {
8780            b.1.partial_cmp(&a.1)
8781                .unwrap_or(std::cmp::Ordering::Equal)
8782                .then_with(|| a.0.cmp(&b.0))
8783        });
8784        out
8785    }
8786
8787    /// Read a single property from an edge.
8788    ///
8789    /// Returns `None` when the edge does not exist, the field is absent, or any
8790    /// of the string keys cannot be resolved to interned ids.  Only edge props
8791    /// written by rules (weight fields) are accessible without a `set_edge_prop`
8792    /// binding; topology-only edges (no props set) return `None` for every field.
8793    pub fn get_edge_prop(
8794        &self,
8795        edge_type: &str,
8796        src_key: &str,
8797        dst_key: &str,
8798        field: &str,
8799    ) -> Option<Value> {
8800        let etype = self.syms.get(edge_type)?;
8801        let src = self.ids.get(src_key)?;
8802        let dst = self.ids.get(dst_key)?;
8803        self.edge_props_view().get(etype, src, dst, field)
8804    }
8805
8806    /// Lex → parse → plan → execute `cypher` over a read-only view.
8807    /// Every pipeline `Err(String)` becomes `GraphError::QueryError` with a
8808    /// stage prefix (`lex:` / `parse:` / `plan:` / `execute:`).
8809    pub fn query(&self, cypher: &str, params: &BTreeMap<String, Value>) -> Result<ResultSet> {
8810        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
8811            detail: format!("lex: {e}"),
8812        })?;
8813        let union = parse_read(&tokens).map_err(|e| GraphError::QueryError {
8814            detail: format!("parse: {e}"),
8815        })?;
8816        let t0 = std::time::Instant::now();
8817        let result = execute_union(&self.view(), &union, &Params(params)).map_err(|e| {
8818            GraphError::QueryError {
8819                detail: format!("execute: {e}"),
8820            }
8821        });
8822        let elapsed_ms = t0.elapsed().as_millis() as u64;
8823        let threshold = self.slow_query_threshold_ms;
8824        if threshold > 0 && elapsed_ms >= threshold {
8825            eprintln!("[mushroomdb] slow query ({elapsed_ms}ms): {cypher}");
8826            let entry = SlowQueryEntry {
8827                ms: elapsed_ms,
8828                query: cypher.to_string(),
8829                at_commit: self.commit_seq,
8830            };
8831            if let Ok(mut log) = self.slow_queries.lock() {
8832                if log.entries.len() == SLOW_QUERY_RING_CAP {
8833                    log.entries.pop_front();
8834                }
8835                log.entries.push_back(entry);
8836                log.total += 1;
8837            }
8838        }
8839        result
8840    }
8841
8842    /// Convenience entry-point that accepts a slice of `(name, value)` pairs
8843    /// instead of a pre-built `BTreeMap`.  Equivalent to building the map and
8844    /// calling [`GraphDb::query`].
8845    pub fn query_with_params(&self, cypher: &str, params: &[(&str, Value)]) -> Result<ResultSet> {
8846        let map: BTreeMap<String, Value> = params
8847            .iter()
8848            .map(|(k, v)| (k.to_string(), v.clone()))
8849            .collect();
8850        self.query(cypher, &map)
8851    }
8852
8853    /// Execute a Cypher write statement (CREATE / MATCH…SET / MATCH…DELETE / MERGE).
8854    ///
8855    /// All mutations flow through the same `insert_node` / `set_prop` /
8856    /// `delete_edge` / `insert_edge` path as the Rust API so the rule engine
8857    /// fires and the WAL captures everything with one fsync per statement.
8858    ///
8859    /// Returns a one-row [`ResultSet`] with columns `created`, `properties_set`,
8860    /// and `deleted` matching the write-result contract.
8861    ///
8862    /// **Mutation routing**: mutations are collected into a single
8863    /// [`BatchBuilder`] and committed atomically (one WAL `Batch` frame, one
8864    /// fsync). The MATCH phase for SET/DELETE uses a read-only `execute` call
8865    /// over `self.view()` — the borrow is dropped before the batch is opened.
8866    ///
8867    /// **Limitations (v1)**:
8868    /// - SET RHS must be a literal, `$param`, or arithmetic; bare property copy → named error.
8869    /// - `DETACH DELETE n` → calls `delete_node` for each matched node (removes all edges).
8870    /// - Bare `DELETE n` → error if n has any incident edges; succeeds for isolated nodes.
8871    /// - MERGE supports `ON CREATE SET` / `ON MATCH SET` in the same write batch.
8872    /// - Deleting a derived edge → named error "cannot delete derived edge".
8873    pub fn query_write(
8874        &mut self,
8875        cypher: &str,
8876        params: &BTreeMap<String, Value>,
8877    ) -> Result<ResultSet> {
8878        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
8879            detail: format!("lex: {e}"),
8880        })?;
8881        let stmt = parse_write(&tokens).map_err(|e| GraphError::QueryError {
8882            detail: format!("parse: {e}"),
8883        })?;
8884        self.exec_write_stmt(stmt, params)
8885    }
8886
8887    fn exec_write_stmt(
8888        &mut self,
8889        stmt: WriteStatement,
8890        params: &BTreeMap<String, Value>,
8891    ) -> Result<ResultSet> {
8892        match stmt {
8893            WriteStatement::Create(s) => self.exec_create(s, params),
8894            WriteStatement::MatchSet(s) => self.exec_match_set(s, params),
8895            WriteStatement::MatchDelete(s) => self.exec_match_delete(s, params),
8896            WriteStatement::MatchDeleteNode(s) => self.exec_match_delete_node(s, params),
8897            WriteStatement::Merge(s) => self.exec_merge(s, params),
8898        }
8899    }
8900
8901    fn exec_create(
8902        &mut self,
8903        stmt: core_query::cypher::CreateStmt,
8904        params: &BTreeMap<String, Value>,
8905    ) -> Result<ResultSet> {
8906        // Extract the node key from props: require a string-valued `id` field.
8907        let mut var_to_key: BTreeMap<String, String> = BTreeMap::new();
8908        for node in &stmt.nodes {
8909            let var = node.var.as_deref().unwrap_or("_cn0");
8910            let key = node
8911                .props
8912                .iter()
8913                .find(|(f, _)| f == "id")
8914                .and_then(|(_, v)| {
8915                    if let Value::Str(s) = v {
8916                        Some(s.clone())
8917                    } else {
8918                        None
8919                    }
8920                })
8921                .ok_or_else(|| GraphError::QueryError {
8922                    detail: format!(
8923                        "CREATE node ({}:{}) requires a string 'id' property",
8924                        var, node.label
8925                    ),
8926                })?;
8927            var_to_key.insert(var.to_string(), key);
8928        }
8929
8930        let mut batch = self.batch();
8931        let mut created: usize = 0;
8932        for node in &stmt.nodes {
8933            let var = node.var.as_deref().unwrap_or("_cn0");
8934            let key = &var_to_key[var];
8935            batch.insert_node(&node.label, key, node.props.clone());
8936            created += 1;
8937        }
8938        for edge in &stmt.edges {
8939            let src_key = var_to_key
8940                .get(&edge.src_var)
8941                .ok_or_else(|| GraphError::QueryError {
8942                    detail: format!("CREATE edge src variable '{}' is not bound", edge.src_var),
8943                })?;
8944            let dst_key = var_to_key
8945                .get(&edge.dst_var)
8946                .ok_or_else(|| GraphError::QueryError {
8947                    detail: format!("CREATE edge dst variable '{}' is not bound", edge.dst_var),
8948                })?;
8949            batch.insert_edge(&edge.etype, src_key, dst_key);
8950        }
8951        batch.commit()?;
8952
8953        // Optional RETURN clause: project created bindings as a read result.
8954        if let Some(returns) = stmt.returns {
8955            // Each created node is looked up by its key via a separate MATCH pattern.
8956            // Multiple single-node patterns cross-join to produce 1 output row with
8957            // all variables bound (each pattern returns exactly 1 row).
8958            let patterns: Vec<Pattern> = stmt
8959                .nodes
8960                .iter()
8961                .map(|node| {
8962                    let var = node.var.as_deref().unwrap_or("_cn0");
8963                    let key = var_to_key[var].clone();
8964                    Pattern {
8965                        start: NodePat {
8966                            var: Some(var.to_string()),
8967                            label: Some(node.label.clone()),
8968                            props: vec![("id".to_string(), Operand::Lit(Value::Str(key)))],
8969                        },
8970                        chain: vec![],
8971                        shortest: false,
8972                    }
8973                })
8974                .collect();
8975            let q = Query {
8976                matches: patterns,
8977                optional_clauses: vec![],
8978                where_expr: None,
8979                unwinds: vec![],
8980                post_unwind_where: None,
8981                stages: vec![],
8982                returns,
8983                distinct: false,
8984                order_by: vec![],
8985                skip: None,
8986                limit: None,
8987            };
8988            let ops = plan(&q).map_err(|e| GraphError::QueryError {
8989                detail: format!("plan: {e}"),
8990            })?;
8991            return execute(&self.view(), &ops, &Params(params)).map_err(|e| {
8992                GraphError::QueryError {
8993                    detail: format!("execute: {e}"),
8994                }
8995            });
8996        }
8997
8998        let mut rs = write_result_set();
8999        rs.push_row(vec![
9000            Some(Value::Int(created as i64)),
9001            Some(Value::Int(0)),
9002            Some(Value::Int(0)),
9003        ]);
9004        Ok(rs)
9005    }
9006
9007    fn exec_match_set(
9008        &mut self,
9009        stmt: core_query::cypher::MatchSetStmt,
9010        params: &BTreeMap<String, Value>,
9011    ) -> Result<ResultSet> {
9012        let project_returns = stmt.returns.clone();
9013        // Collect unique node vars targeted by SET clauses, plus RETURN bindings
9014        // so the post-write projection can look them up by key.
9015        let mut set_vars: Vec<String> = Vec::new();
9016        for s in &stmt.sets {
9017            if !set_vars.contains(&s.var) {
9018                set_vars.push(s.var.clone());
9019            }
9020        }
9021        let rel_vars = pattern_rel_vars(&stmt.matches);
9022        let mut lookup_vars = set_vars.clone();
9023        for v in pattern_node_vars(&stmt.matches) {
9024            add_var(&mut lookup_vars, &v);
9025        }
9026        if let Some(ref returns) = project_returns {
9027            for v in ret_node_vars(returns) {
9028                if !rel_vars.iter().any(|r| r == &v) {
9029                    add_var(&mut lookup_vars, &v);
9030                }
9031            }
9032        }
9033
9034        // Synthesize a read query: MATCH … WHERE … RETURN <lookup_vars>, <set_values…>
9035        // SET values are projected as ScalarExpr items so that arithmetic expressions
9036        // (e.g. `SET n.score = n.score * 1.5`) are evaluated in the matched-row context.
9037        let mut set_returns: Vec<RetItem> = lookup_vars
9038            .iter()
9039            .map(|v| RetItem {
9040                value: RetVal::Var(v.clone()),
9041                alias: None,
9042            })
9043            .collect();
9044        // One computed column per SET clause; alias is `__sv_<i>`.
9045        let set_val_cols: Vec<String> = stmt
9046            .sets
9047            .iter()
9048            .enumerate()
9049            .map(|(i, _)| format!("__sv_{i}"))
9050            .collect();
9051        for (sc, col) in stmt.sets.iter().zip(&set_val_cols) {
9052            set_returns.push(RetItem {
9053                value: RetVal::ScalarExpr(sc.value.clone()),
9054                alias: Some(col.clone()),
9055            });
9056        }
9057        // Capture relationship types while r is bound; SET does not change them.
9058        for r in &rel_vars {
9059            set_returns.push(RetItem {
9060                value: RetVal::FuncCall {
9061                    name: "type".into(),
9062                    args: vec![Operand::Var(r.clone())],
9063                },
9064                alias: Some(rel_type_alias(r)),
9065            });
9066        }
9067
9068        let read_q = Query {
9069            matches: stmt.matches.clone(),
9070            optional_clauses: vec![],
9071            where_expr: stmt.where_expr.clone(),
9072            unwinds: vec![],
9073            post_unwind_where: None,
9074            stages: vec![],
9075            returns: set_returns,
9076            distinct: false,
9077            order_by: vec![],
9078            skip: None,
9079            limit: None,
9080        };
9081        let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
9082            detail: format!("plan: {e}"),
9083        })?;
9084        // MATCH phase is read-only; borrow ends before batch opens.
9085        //
9086        // When a role-scoped write is in flight, run the MATCH read through
9087        // view_masked so hidden nodes are invisible → hidden ≡ absent ≡
9088        // zero-rows (no SetProp ops generated, no existence-oracle 403).
9089        // Full-authority writes (pending_write_authz=None) keep view().
9090        let match_rs = {
9091            let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
9092            if let Some(ref mask) = mask_opt {
9093                execute(&self.view_masked(mask), &ops, &Params(params))
9094            } else {
9095                execute(&self.view(), &ops, &Params(params))
9096            }
9097        }
9098        .map_err(|e| GraphError::QueryError {
9099            detail: format!("execute: {e}"),
9100        })?;
9101
9102        // Collect (key, field, value) for each matched row × each SET clause.
9103        let mut set_ops: Vec<(String, String, Value)> = Vec::new();
9104        for row_i in 0..match_rs.len() {
9105            for (sc, col) in stmt.sets.iter().zip(&set_val_cols) {
9106                let key = match match_rs.get(row_i, &sc.var) {
9107                    Some(Value::Str(k)) => k.clone(),
9108                    _ => {
9109                        return Err(GraphError::QueryError {
9110                            detail: format!(
9111                                "SET variable '{}' did not resolve to a node key",
9112                                sc.var
9113                            ),
9114                        })
9115                    }
9116                };
9117                // The SET value was already evaluated by the executor.
9118                let value = match match_rs.get(row_i, col) {
9119                    Some(v) => v.clone(),
9120                    None => {
9121                        return Err(GraphError::QueryError {
9122                            detail: format!(
9123                                "SET value for {}.{} evaluated to null",
9124                                sc.var, sc.field
9125                            ),
9126                        })
9127                    }
9128                };
9129                set_ops.push((key, sc.field.clone(), value));
9130            }
9131        }
9132
9133        // Apply as one atomic batch.
9134        let props_set = set_ops.len();
9135        let mut batch = self.batch();
9136        for (key, field, value) in set_ops {
9137            batch.set_prop(&key, &field, value);
9138        }
9139        batch.commit()?;
9140
9141        if let Some(returns) = project_returns {
9142            return project_set_return_rows(self, &rel_vars, &match_rs, &returns, params);
9143        }
9144
9145        let mut rs = write_result_set();
9146        rs.push_row(vec![
9147            Some(Value::Int(0)),
9148            Some(Value::Int(props_set as i64)),
9149            Some(Value::Int(0)),
9150        ]);
9151        Ok(rs)
9152    }
9153
9154    fn exec_match_delete(
9155        &mut self,
9156        stmt: core_query::cypher::MatchDeleteStmt,
9157        params: &BTreeMap<String, Value>,
9158    ) -> Result<ResultSet> {
9159        // Collect unique node vars needed to identify edge endpoints.
9160        let mut node_vars: Vec<String> = Vec::new();
9161        for ed in &stmt.deletes {
9162            if !node_vars.contains(&ed.src_var) {
9163                node_vars.push(ed.src_var.clone());
9164            }
9165            if !node_vars.contains(&ed.dst_var) {
9166                node_vars.push(ed.dst_var.clone());
9167            }
9168        }
9169
9170        // Synthesize read query.
9171        let returns: Vec<RetItem> = node_vars
9172            .iter()
9173            .map(|v| RetItem {
9174                value: RetVal::Var(v.clone()),
9175                alias: None,
9176            })
9177            .collect();
9178        let read_q = Query {
9179            matches: stmt.matches,
9180            optional_clauses: vec![],
9181            where_expr: stmt.where_expr,
9182            unwinds: vec![],
9183            post_unwind_where: None,
9184            stages: vec![],
9185            returns,
9186            distinct: false,
9187            order_by: vec![],
9188            skip: None,
9189            limit: None,
9190        };
9191        let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
9192            detail: format!("plan: {e}"),
9193        })?;
9194        // Role-scoped writes: mask the MATCH read phase so hidden nodes are
9195        // invisible → hidden ≡ absent ≡ zero-rows (spec §3.1, hidden ≡ absent).
9196        let match_rs = {
9197            let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
9198            if let Some(ref mask) = mask_opt {
9199                execute(&self.view_masked(mask), &ops, &Params(params))
9200            } else {
9201                execute(&self.view(), &ops, &Params(params))
9202            }
9203        }
9204        .map_err(|e| GraphError::QueryError {
9205            detail: format!("execute: {e}"),
9206        })?;
9207
9208        // Collect (etype, src_key, dst_key) for each row × each delete target.
9209        let mut del_ops: Vec<(String, String, String)> = Vec::new();
9210        for row_i in 0..match_rs.len() {
9211            for ed in &stmt.deletes {
9212                let src_key = match match_rs.get(row_i, &ed.src_var) {
9213                    Some(Value::Str(k)) => k.clone(),
9214                    _ => {
9215                        return Err(GraphError::QueryError {
9216                            detail: format!(
9217                                "DELETE src variable '{}' did not resolve to a node key",
9218                                ed.src_var
9219                            ),
9220                        })
9221                    }
9222                };
9223                let dst_key = match match_rs.get(row_i, &ed.dst_var) {
9224                    Some(Value::Str(k)) => k.clone(),
9225                    _ => {
9226                        return Err(GraphError::QueryError {
9227                            detail: format!(
9228                                "DELETE dst variable '{}' did not resolve to a node key",
9229                                ed.dst_var
9230                            ),
9231                        })
9232                    }
9233                };
9234                del_ops.push((ed.etype.clone(), src_key, dst_key));
9235            }
9236        }
9237
9238        // Apply as one atomic batch.
9239        let deleted = del_ops.len();
9240        let mut batch = self.batch();
9241        for (etype, src_key, dst_key) in del_ops {
9242            batch.delete_edge(&etype, &src_key, &dst_key);
9243        }
9244        batch.commit().map_err(|e| match e {
9245            GraphError::RuleOwned { .. } => GraphError::QueryError {
9246                detail: "cannot delete derived edge; retract via the rule or change the property"
9247                    .to_string(),
9248            },
9249            other => other,
9250        })?;
9251
9252        let mut rs = write_result_set();
9253        rs.push_row(vec![
9254            Some(Value::Int(0)),
9255            Some(Value::Int(0)),
9256            Some(Value::Int(deleted as i64)),
9257        ]);
9258        Ok(rs)
9259    }
9260
9261    /// Execute `MATCH … [DETACH] DELETE <node_var> [, …]`.
9262    ///
9263    /// Collects the matching node keys via an ephemeral read query, then calls
9264    /// `delete_node` on each one.  When `stmt.detach` is `false` (bare DELETE)
9265    /// the executor first checks that the node has no incident edges; if any
9266    /// remain it returns a named error matching openCypher semantics.
9267    fn exec_match_delete_node(
9268        &mut self,
9269        stmt: MatchDeleteNodeStmt,
9270        params: &BTreeMap<String, Value>,
9271    ) -> Result<ResultSet> {
9272        // Build a read query returning only the node keys we need.
9273        let returns: Vec<RetItem> = stmt
9274            .node_vars
9275            .iter()
9276            .map(|v| RetItem {
9277                value: RetVal::Var(v.clone()),
9278                alias: None,
9279            })
9280            .collect();
9281        let read_q = Query {
9282            matches: stmt.matches,
9283            optional_clauses: vec![],
9284            where_expr: stmt.where_expr,
9285            unwinds: vec![],
9286            post_unwind_where: None,
9287            stages: vec![],
9288            returns,
9289            distinct: false,
9290            order_by: vec![],
9291            skip: None,
9292            limit: None,
9293        };
9294        let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
9295            detail: format!("plan: {e}"),
9296        })?;
9297        // Role-scoped writes: mask the MATCH read phase so hidden nodes are
9298        // invisible → hidden ≡ absent ≡ zero-rows (spec §3.1, hidden ≡ absent).
9299        let match_rs = {
9300            let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
9301            if let Some(ref mask) = mask_opt {
9302                execute(&self.view_masked(mask), &ops, &Params(params))
9303            } else {
9304                execute(&self.view(), &ops, &Params(params))
9305            }
9306        }
9307        .map_err(|e| GraphError::QueryError {
9308            detail: format!("execute: {e}"),
9309        })?;
9310
9311        // Collect unique node keys to delete (deduplicate across rows × vars).
9312        let mut keys: Vec<String> = Vec::new();
9313        for row_i in 0..match_rs.len() {
9314            for var in &stmt.node_vars {
9315                if let Some(Value::Str(k)) = match_rs.get(row_i, var) {
9316                    if !keys.contains(k) {
9317                        keys.push(k.clone());
9318                    }
9319                }
9320            }
9321        }
9322
9323        if !stmt.detach {
9324            // openCypher bare DELETE: error if any matched node has incident edges.
9325            for key in &keys {
9326                if let Some(id) = self.ids.get(key) {
9327                    let tv = self.topo_view();
9328                    let has_edges = tv.etypes().any(|et| {
9329                        !tv.neighbors(et, Direction::Out, id).is_empty()
9330                            || !tv.neighbors(et, Direction::In, id).is_empty()
9331                    });
9332                    if has_edges {
9333                        return Err(GraphError::QueryError {
9334                            detail: format!(
9335                                "Cannot delete node `{key}` because it still has incident edges. \
9336                                 Use DETACH DELETE to remove the node and all its edges."
9337                            ),
9338                        });
9339                    }
9340                }
9341            }
9342        }
9343
9344        let mut nodes_deleted = 0i64;
9345        let mut edges_deleted = 0i64;
9346        for key in keys {
9347            match self.delete_node(&key) {
9348                Ok(report) => {
9349                    nodes_deleted += 1;
9350                    edges_deleted += (report.manual_edges + report.derived_edges) as i64;
9351                }
9352                Err(GraphError::KeyNotFound { .. }) => {
9353                    // Node may have been deleted by an earlier iteration (e.g., via
9354                    // multiple MATCH rows for the same node).  Safe to skip.
9355                }
9356                Err(e) => return Err(e),
9357            }
9358        }
9359
9360        let mut rs = write_result_set();
9361        rs.push_row(vec![
9362            Some(Value::Int(0)),
9363            Some(Value::Int(0)),
9364            Some(Value::Int(nodes_deleted + edges_deleted)),
9365        ]);
9366        Ok(rs)
9367    }
9368
9369    /// Props the MERGE create arm inserts: the identifying key, plus `ns` when
9370    /// the pattern named one, or the executing role's sole namespace when it
9371    /// did not. A role bound to two or more namespaces cannot choose, and is
9372    /// refused with [`MERGE_CREATE_NEEDS_ONE_NAMESPACE`]. The authorizer still
9373    /// refuses a named `ns` the role cannot write.
9374    fn merge_create_props(
9375        &self,
9376        key_field: &str,
9377        key_value: &Value,
9378        named_ns: Option<&Value>,
9379    ) -> Result<Vec<(String, Value)>> {
9380        let mut props = vec![(key_field.to_string(), key_value.clone())];
9381        if let Some(ns) = named_ns {
9382            props.push((NS_PROP.to_string(), ns.clone()));
9383            return Ok(props);
9384        }
9385        if let Some(ns) = self.merge_create_stamp_ns()? {
9386            props.push((NS_PROP.to_string(), Value::Str(ns)));
9387        }
9388        Ok(props)
9389    }
9390
9391    /// The namespace a role-scoped MERGE create stamps when the pattern does
9392    /// not name `ns`. `None` = unscoped / full authority, so the node lands in
9393    /// `default`.
9394    fn merge_create_stamp_ns(&self) -> Result<Option<String>> {
9395        let Some(authz) = self.pending_write_authz.as_ref() else {
9396            return Ok(None);
9397        };
9398        let Some(def) = self.role_def_for(&authz.role) else {
9399            return Ok(None);
9400        };
9401        match def.namespaces.as_deref() {
9402            Some([only]) => Ok(Some(only.clone())),
9403            Some(_) => Err(GraphError::RoleWriteDenied {
9404                reason: MERGE_CREATE_NEEDS_ONE_NAMESPACE.to_string(),
9405            }),
9406            None => Ok(None),
9407        }
9408    }
9409
9410    fn exec_merge(
9411        &mut self,
9412        stmt: core_query::cypher::MergeStmt,
9413        params: &BTreeMap<String, Value>,
9414    ) -> Result<ResultSet> {
9415        // MERGE: check if a node with the given key already exists.
9416        let key = match &stmt.key_value {
9417            Value::Str(s) => s.clone(),
9418            _ => {
9419                return Err(GraphError::QueryError {
9420                    detail: format!(
9421                        "MERGE key value must be a string (got {:?})",
9422                        stmt.key_value
9423                    ),
9424                })
9425            }
9426        };
9427
9428        if let Some(var) = stmt.var.as_deref() {
9429            for sc in stmt.on_create.iter().chain(&stmt.on_match) {
9430                if sc.var != var {
9431                    return Err(GraphError::QueryError {
9432                        detail: format!(
9433                            "SET variable '{}' does not match MERGE variable '{var}'",
9434                            sc.var
9435                        ),
9436                    });
9437                }
9438            }
9439        }
9440
9441        // ── MERGE authz pre-check (when role-scoped) ─────────────────────────
9442        //
9443        // MERGE scope precondition: check create OR update scope for the
9444        // declared label BEFORE calling `has_node` (timing-oracle closure,
9445        // spec §6.2 "MERGE visibility oracle" item: hidden ≡ absent for
9446        // unscoped roles — the scope denial fires without touching the key store).
9447        //
9448        // Clone to avoid holding a borrow on `self.pending_write_authz` while
9449        // also calling `self.ids.get(key)`.
9450        let merge_existed: bool = if let Some(authz) = self.pending_write_authz.clone() {
9451            let has_create = authz.scope.create_labels.contains(&stmt.label);
9452            let has_update = authz.scope.update_labels.contains(&stmt.label);
9453            if !has_create && !has_update {
9454                // Scope-before-lookup: 403 without has_node call (timing oracle
9455                // closure — see test_merge_unscoped_no_key_lookup).
9456                return Err(GraphError::RoleWriteDenied {
9457                    reason: format!(
9458                        "role-bound token: label '{}' not in write scope (create_labels)",
9459                        stmt.label
9460                    ),
9461                });
9462            }
9463            // Key lookup under mask.
9464            match self.ids.get(key.as_str()) {
9465                Some(id) if authz.mask.contains_id(id) => {
9466                    // Visible: must have update scope to proceed to match arm.
9467                    if !has_update {
9468                        return Err(GraphError::RoleWriteDenied {
9469                            reason: format!(
9470                                "role-bound token: label '{}' not in write scope (update_labels)",
9471                                stmt.label
9472                            ),
9473                        });
9474                    }
9475                    true // existed = true → match arm
9476                }
9477                Some(_) => {
9478                    // Hidden: same error as absent to the role (spec §3.1/§3.3).
9479                    return Err(GraphError::RoleWriteDenied {
9480                        reason: "role-bound token: target node not visible".into(),
9481                    });
9482                }
9483                None => {
9484                    // Absent: must have create scope to proceed to the create arm.
9485                    //
9486                    // Update-only roles (create_labels empty, update_labels set):
9487                    // return the SAME "not visible" error as the hidden-key branch
9488                    // so hidden ≡ absent — no distinguishing oracle (spec §6.1
9489                    // "confirm existence of hidden nodes: No").
9490                    //
9491                    // Create-scoped roles (has_create=true): absent → create arm
9492                    // as before.  The accepted structural key-existence disclosure
9493                    // (§THREAT-MODEL) applies only when the role holds create scope.
9494                    if !has_create {
9495                        return Err(GraphError::RoleWriteDenied {
9496                            reason: "role-bound token: target node not visible".into(),
9497                        });
9498                    }
9499                    false // existed = false → create arm
9500                }
9501            }
9502        } else {
9503            // Full authority: use the existing non-masked has_node check.
9504            self.has_node(&key)
9505        };
9506
9507        let existed = merge_existed;
9508        let create_props = if existed {
9509            None
9510        } else {
9511            Some(self.merge_create_props(&stmt.key_field, &stmt.key_value, stmt.ns.as_ref())?)
9512        };
9513        let mut created = 0i64;
9514        if create_props.is_some() || !stmt.on_match.is_empty() {
9515            let mut batch = self.batch();
9516            if let Some(props) = create_props {
9517                batch.insert_node(&stmt.label, &key, props);
9518                for sc in &stmt.on_create {
9519                    let value = resolve_merge_set_value(&sc.value, params)?;
9520                    batch.set_prop(&key, &sc.field, value);
9521                }
9522                created = 1;
9523            } else {
9524                for sc in &stmt.on_match {
9525                    let value = resolve_merge_set_value(&sc.value, params)?;
9526                    batch.set_prop(&key, &sc.field, value);
9527                }
9528            }
9529            batch.commit()?;
9530        }
9531
9532        // Refresh the role mask so the just-created node is visible to this
9533        // statement's RETURN (read-after-write). Safe: create_labels ⊆ read labels
9534        // (apply_schema subset rule), so the new node's label is already in the
9535        // role's read scope — this never widens beyond the role's declared labels.
9536        if !existed {
9537            if let Some(role) = self.pending_write_authz.as_ref().map(|a| a.role.clone()) {
9538                let new_mask = self.mask_for_role(&role)?;
9539                if let Some(a) = self.pending_write_authz.as_mut() {
9540                    a.mask = new_mask;
9541                }
9542            }
9543        }
9544
9545        // Optional RETURN clause: project the node (created or matched) as a read result.
9546        if let Some(returns) = stmt.returns {
9547            let var = stmt.var.as_deref().unwrap_or("_mn0");
9548            let q = Query {
9549                matches: vec![Pattern {
9550                    start: NodePat {
9551                        var: Some(var.to_string()),
9552                        label: Some(stmt.label.clone()),
9553                        props: vec![("id".to_string(), Operand::Lit(stmt.key_value.clone()))],
9554                    },
9555                    chain: vec![],
9556                    shortest: false,
9557                }],
9558                optional_clauses: vec![],
9559                where_expr: None,
9560                unwinds: vec![],
9561                post_unwind_where: None,
9562                stages: vec![],
9563                returns,
9564                distinct: false,
9565                order_by: vec![],
9566                skip: None,
9567                limit: None,
9568            };
9569            let ops = plan(&q).map_err(|e| GraphError::QueryError {
9570                detail: format!("plan: {e}"),
9571            })?;
9572            // Use view_masked when a role-scoped write is in flight so the
9573            // post-merge projection is consistent with the masked read phase.
9574            let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
9575            return (if let Some(ref mask) = mask_opt {
9576                execute(&self.view_masked(mask), &ops, &Params(params))
9577            } else {
9578                execute(&self.view(), &ops, &Params(params))
9579            })
9580            .map_err(|e| GraphError::QueryError {
9581                detail: format!("execute: {e}"),
9582            });
9583        }
9584
9585        let mut rs = write_result_set();
9586        rs.push_row(vec![
9587            Some(Value::Int(created)),
9588            Some(Value::Int(0)),
9589            Some(Value::Int(0)),
9590        ]);
9591        Ok(rs)
9592    }
9593
9594    /// Return all rule-owned edges between `key_a` and `key_b` (either direction),
9595    /// annotated with rule name, edge type, direction, and weight.
9596    /// Results are sorted by (rule, edge_type).
9597    /// Returns `Err(KeyNotFound)` if either key is unknown.
9598    pub fn explain(&self, key_a: &str, key_b: &str) -> Result<Vec<Explanation>> {
9599        self.ensure_v8_base_sections_loaded();
9600        let id_a = self
9601            .ids
9602            .get(key_a)
9603            .ok_or_else(|| GraphError::KeyNotFound { key: key_a.into() })?;
9604        let id_b = self
9605            .ids
9606            .get(key_b)
9607            .ok_or_else(|| GraphError::KeyNotFound { key: key_b.into() })?;
9608
9609        let mut results = Vec::new();
9610
9611        // Walk the smaller incident set so explain is O(min(deg(a), deg(b)))
9612        // rather than O(total provenance).
9613        let scan = if self.engine.provenance_touching_len(id_a)
9614            <= self.engine.provenance_touching_len(id_b)
9615        {
9616            id_a
9617        } else {
9618            id_b
9619        };
9620        for (rule_name, etype, src, dst) in self.engine.provenance_touching(scan) {
9621            if !((src == id_a && dst == id_b) || (src == id_b && dst == id_a)) {
9622                continue;
9623            }
9624            let Some(rule_def) = self.engine.rules().find(|r| r.name == rule_name) else {
9625                continue;
9626            };
9627            let edge_type = match self.syms.resolve(etype) {
9628                Some(s) => s.to_string(),
9629                None => continue,
9630            };
9631            // Provenance (src, dst) ids come from the archived PROVENANCE section
9632            // (large, no eager CRC).  A corrupt section can produce ids that are
9633            // out of range; return Corrupt rather than panic.
9634            let src_key = self
9635                .ids
9636                .key_of(src)
9637                .ok_or_else(|| GraphError::Corrupt {
9638                    detail: format!("v8: provenance src id {src} not in id table"),
9639                })?
9640                .to_string();
9641            let dst_key = self
9642                .ids
9643                .key_of(dst)
9644                .ok_or_else(|| GraphError::Corrupt {
9645                    detail: format!("v8: provenance dst id {dst} not in id table"),
9646                })?
9647                .to_string();
9648            let stored = rule_def.weight_prop.as_deref().and_then(|prop| {
9649                self.edge_props_view()
9650                    .get(etype, src, dst, prop)
9651                    .and_then(|v| {
9652                        if let Value::Float(f) = v {
9653                            Some(f)
9654                        } else {
9655                            None
9656                        }
9657                    })
9658            });
9659            // Rules that store no weight (KeyMatch/FieldEqual defaults, auto-FK)
9660            // still have a score: recompute it from the predicate so explain
9661            // never reports "no score" for an edge the engine scored.  Via-hop
9662            // rules score over their via set, not over (src, dst), so leave
9663            // those None rather than report a number the rule did not produce.
9664            let weight = stored.or_else(|| {
9665                if rule_def.via_edge.is_some() {
9666                    return None;
9667                }
9668                let props_view = build_props_view(&self.props, &self.base);
9669                let src_get = |field: &str| props_view.get(src, field).map(|vr| vr.into_value());
9670                let dst_get = |field: &str| props_view.get(dst, field).map(|vr| vr.into_value());
9671                let src_view = NodeView {
9672                    key: &src_key,
9673                    props: &src_get,
9674                };
9675                let dst_view = NodeView {
9676                    key: &dst_key,
9677                    props: &dst_get,
9678                };
9679                evaluate(&rule_def.predicate, &src_view, &dst_view)
9680            });
9681            results.push(Explanation {
9682                rule: rule_name.to_string(),
9683                edge_type,
9684                src_key,
9685                dst_key,
9686                weight,
9687                predicate: PredicateSummary {
9688                    approximate: rule_def.approximate,
9689                    ..PredicateSummary::from(&rule_def.predicate)
9690                },
9691                via_edge: rule_def.via_edge.clone(),
9692            });
9693        }
9694
9695        results.sort_by(|a, b| a.rule.cmp(&b.rule).then(a.edge_type.cmp(&b.edge_type)));
9696        Ok(results)
9697    }
9698
9699    pub fn neighbors(&self, key: &str, edge_type: &str, dir: Direction) -> Result<Vec<String>> {
9700        let id = self
9701            .ids
9702            .get(key)
9703            .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
9704        let Some(sym) = self.syms.get(edge_type) else {
9705            return Ok(Vec::new());
9706        };
9707        self.topo_view()
9708            .neighbors(sym, dir, id)
9709            .iter()
9710            .map(|&n| {
9711                self.ids
9712                    .key_of(n)
9713                    .map(|k| k.to_string())
9714                    .ok_or_else(|| GraphError::Corrupt {
9715                        detail: format!("topology id {n} has no key"),
9716                    })
9717            })
9718            .collect::<Result<Vec<_>>>()
9719    }
9720
9721    /// Return the last-change commit sequence for `key`, or `None` if the node
9722    /// does not exist or has never been mutated since the last V5-V7 snapshot
9723    /// (horizon-bounded for legacy stores).
9724    ///
9725    /// The returned sequence is a monotonically increasing counter that starts
9726    /// at 1 for the first commit after `open` and increments with every
9727    /// successful write.  WAL replay at open also assigns sequences (1..N for N
9728    /// replayed frames), so sequences are consistent across snapshot+WAL cycles.
9729    ///
9730    /// For V5-V7 stores opened without a V8 snapshot, nodes that were present
9731    /// in the snapshot but not touched by any WAL frame will return `None`
9732    /// (horizon-bounded: CAS against such nodes is only safe after the first
9733    /// V8 snapshot or after the node is next mutated).
9734    pub fn last_changed(&self, key: &str) -> Option<u64> {
9735        let id = self.ids.get(key)?;
9736        self.last_change.get(&id).copied()
9737    }
9738
9739    /// The current commit sequence (number of successful commits since open,
9740    /// including WAL replay frames).  Useful for recording a baseline before
9741    /// a read-modify-write cycle.
9742    pub fn commit_seq(&self) -> u64 {
9743        self.commit_seq
9744    }
9745
9746    /// Check that all `preconds` are satisfied against the current db state.
9747    /// Returns `Err(GraphError::CasConflict)` on the first failing precondition.
9748    pub(crate) fn check_preconditions(&self, preconds: &[Precondition]) -> Result<()> {
9749        for precond in preconds {
9750            match precond {
9751                Precondition::NodeUnchangedSince { key, expected } => {
9752                    // Missing entry means the node predates the WAL window or
9753                    // does not exist; treat as 0 (before any commit).
9754                    let actual = self.last_changed(key).unwrap_or_default();
9755                    if actual != *expected {
9756                        return Err(GraphError::CasConflict {
9757                            key: key.clone(),
9758                            expected: *expected,
9759                            actual,
9760                        });
9761                    }
9762                }
9763                Precondition::NodeAbsent { key } => {
9764                    // Node must not exist (not live).
9765                    if self.ids.get(key).is_some() {
9766                        let actual = self.last_changed(key).unwrap_or(0);
9767                        return Err(GraphError::CasConflict {
9768                            key: key.clone(),
9769                            expected: u64::MAX,
9770                            actual,
9771                        });
9772                    }
9773                }
9774            }
9775        }
9776        Ok(())
9777    }
9778
9779    /// Apply a batch of mutations with compare-and-set preconditions.
9780    ///
9781    /// All preconditions are checked atomically before any operation is applied.
9782    /// If any precondition fails, the entire batch is rejected with
9783    /// [`GraphError::CasConflict`] and no WAL frame is written.
9784    ///
9785    /// # Returns
9786    /// `(nodes_inserted, edges_inserted)` on success, same as [`write_batch`].
9787    ///
9788    /// # Errors
9789    /// - [`GraphError::CasConflict`] if any precondition is not satisfied.
9790    /// - Any error that [`write_batch`] would return for the ops themselves.
9791    pub fn write_batch_cas(
9792        &mut self,
9793        preconds: Vec<Precondition>,
9794        ops: Vec<BatchOp>,
9795    ) -> Result<(usize, usize)> {
9796        self.check_preconditions(&preconds)?;
9797        self.commit_logged_batch(ops, None, None)
9798    }
9799
9800    /// Update the per-node last-change map for a WAL record at commit `seq`.
9801    ///
9802    /// Called after a successful apply to record which nodes were touched.
9803    /// For replay, called with the WAL-frame's replayed seq.
9804    ///
9805    /// Touch definition (see [`Precondition`] doc):
9806    /// - InsertNode / InsertNodeId / SetProp / SetPropId / RemoveProp → the node.
9807    /// - InsertEdge / InsertEdgeId / DeleteEdge → both src and dst.
9808    /// - DeleteNode → node tombstoned; last_changed() returns None so no update needed.
9809    /// - DerivedEdge markers, Intern, rule/view records → no-ops.
9810    /// - Batch → recurse into inner records.
9811    fn update_last_change_from_rec(&mut self, rec: &WalRecord, seq: u64) {
9812        match rec {
9813            WalRecord::InsertNode { key, .. }
9814            | WalRecord::SetProp { key, .. }
9815            | WalRecord::RemoveProp { key, .. } => {
9816                if let Some(id) = self.ids.get(key) {
9817                    self.last_change.insert(id, seq);
9818                }
9819            }
9820            WalRecord::InsertNodeId { key, .. } => {
9821                if let Some(id) = self.ids.get(key) {
9822                    self.last_change.insert(id, seq);
9823                }
9824            }
9825            WalRecord::SetPropId { id, .. } => {
9826                self.last_change.insert(*id, seq);
9827            }
9828            WalRecord::InsertEdge {
9829                src_key, dst_key, ..
9830            }
9831            | WalRecord::DeleteEdge {
9832                src_key, dst_key, ..
9833            } => {
9834                if let Some(src_id) = self.ids.get(src_key) {
9835                    self.last_change.insert(src_id, seq);
9836                }
9837                if let Some(dst_id) = self.ids.get(dst_key) {
9838                    self.last_change.insert(dst_id, seq);
9839                }
9840            }
9841            WalRecord::InsertEdgeId { src, dst, .. } => {
9842                self.last_change.insert(*src, seq);
9843                self.last_change.insert(*dst, seq);
9844            }
9845            // DeleteNode: node is tombstoned; last_changed(key) returns None for
9846            // deleted keys (ids.get() returns None post-tombstone), so no update needed.
9847            // History markers: state no-ops; the underlying mutation already
9848            // touched the relevant nodes' last_change entries.
9849            WalRecord::DeleteNode { .. }
9850            | WalRecord::DerivedEdgeAdded { .. }
9851            | WalRecord::DerivedEdgeRetracted { .. }
9852            | WalRecord::Intern { .. }
9853            | WalRecord::CreateRule { .. }
9854            | WalRecord::DeleteRule { .. }
9855            | WalRecord::RebuildRule { .. }
9856            | WalRecord::CreateView { .. }
9857            | WalRecord::DeleteView { .. }
9858            | WalRecord::EnableFulltext { .. }
9859            | WalRecord::DisableFulltext { .. }
9860            | WalRecord::EnableIndex { .. }
9861            | WalRecord::DisableIndex { .. } => {}
9862            // RenameNode: node id is stable; update last_change via the new key.
9863            // Called after apply(), so ids already reflects new_key.
9864            WalRecord::RenameNode { new_key, .. } => {
9865                if let Some(id) = self.ids.get(new_key) {
9866                    self.last_change.insert(id, seq);
9867                }
9868            }
9869            WalRecord::Batch(inner) => {
9870                for inner_rec in inner {
9871                    self.update_last_change_from_rec(inner_rec, seq);
9872                }
9873            }
9874        }
9875    }
9876
9877    pub fn node_count(&self) -> usize {
9878        self.ids.len()
9879    }
9880
9881    /// Configure archive retention: keep the `N` newest WAL archives at each
9882    /// [`snapshot_with`] call when `archive_wal: true`.
9883    ///
9884    /// `Some(N)` where N > 0 → prune oldest archives keeping the newest N.
9885    /// `Some(0)` or `None` → unlimited (no pruning).
9886    ///
9887    /// Pruning only ever happens inside [`snapshot_with`]; this method only
9888    /// stores the policy.  Archives below the retention limit are deleted
9889    /// oldest-first.  The horizon floor is updated so that
9890    /// [`was_linked`] / history APIs return `CommitOutOfRange` for commits
9891    /// in pruned archives rather than silently returning wrong data.
9892    pub fn set_wal_archive_retention(&mut self, keep: Option<u32>) {
9893        self.wal_archive_retention = keep;
9894    }
9895
9896    /// Delete any WAL archives that are fully below the current horizon floor.
9897    ///
9898    /// Orphaned archives arise when the floor is written first during retention
9899    /// pruning and then a crash interrupts the archive-delete sequence.  The
9900    /// opening cleanup ensures no subsequent read path sees stale data.
9901    ///
9902    /// Under the monotonic naming scheme, the archive name N equals the
9903    /// cumulative end-frame index of the archive in global commit space (i.e.
9904    /// the archive covers global frames `[prev_n, N)`).  An archive is
9905    /// fully orphaned when `N <= wal_horizon_floor`: all of its frames fall
9906    /// below the floor and have already been counted in it.
9907    fn cleanup_orphaned_archives(&mut self) -> Result<()> {
9908        if self.wal_horizon_floor == 0 {
9909            // Floor at 0 means no pruning has ever occurred; nothing to clean.
9910            return Ok(());
9911        }
9912        let archive_ns = self.fs.list_archives()?;
9913        for n in archive_ns {
9914            if n <= self.wal_horizon_floor {
9915                // Archive N ends at global frame N; all its frames are below
9916                // the floor (floor already accounts for them) → orphaned.
9917                self.fs.delete_archive(n).map_err(GraphError::Io)?;
9918            } else {
9919                // Archives are sorted ascending; first one above floor stops scan.
9920                break;
9921            }
9922        }
9923        Ok(())
9924    }
9925
9926    /// Collect all WAL frames from surviving archives (oldest-first) then the
9927    /// live WAL into one flat list, and return the total along with the number
9928    /// of archive frames at the front of the list.
9929    ///
9930    /// Commit indices into the returned list are LOCAL (0 = first frame of
9931    /// oldest surviving archive).  To obtain the GLOBAL index add
9932    /// `self.wal_horizon_floor`.
9933    fn all_frames(&self) -> Result<(Vec<WalRecord>, u64)> {
9934        let archive_ns = self.fs.list_archives()?;
9935        let mut all: Vec<WalRecord> = Vec::new();
9936        for n in archive_ns {
9937            let bytes = self.fs.read_archive(n)?;
9938            let (frames, _) = decode_all(&bytes);
9939            all.extend(frames);
9940        }
9941        let archive_count = all.len() as u64;
9942        let live_bytes = self.fs.read(FileId::Wal)?;
9943        let (live_frames, _) = decode_all(&live_bytes);
9944        all.extend(live_frames);
9945        Ok((all, archive_count))
9946    }
9947
9948    /// Return the total number of committed WAL frames visible in the current
9949    /// horizon window, including frames in surviving WAL archives.
9950    ///
9951    /// This is the exclusive upper bound for valid `at_commit` indices in
9952    /// `was_linked`. Valid indices are `wal_horizon_floor()..wal_total_commits()`.
9953    ///
9954    /// Returns the horizon floor when all surviving history is empty.
9955    pub fn wal_total_commits(&self) -> Result<u64> {
9956        let (frames, _) = self.all_frames()?;
9957        Ok(self.wal_horizon_floor + frames.len() as u64)
9958    }
9959
9960    /// The global frame index of the first commit reachable through surviving
9961    /// archives (0 when no archives have been pruned).
9962    pub fn wal_horizon_floor(&self) -> u64 {
9963        self.wal_horizon_floor
9964    }
9965
9966    /// Return the per-node change history for `key` by scanning the on-disk WAL.
9967    ///
9968    /// ## Horizon
9969    ///
9970    /// History reaches back only to the last WAL-truncating snapshot, exactly like `open_at`.
9971    /// Snapshots written with `keep_wal: true` preserve deeper history. This is the honest,
9972    /// zero-cost contract; a durable history log is out of scope.
9973    ///
9974    /// ## Derived edges
9975    ///
9976    /// Rule-created (derived) edges are **not** in the WAL and therefore do not appear in
9977    /// history. Only edges written directly by the application are recorded.
9978    ///
9979    /// ## Deleted nodes
9980    ///
9981    /// For nodes that have been deleted, dense-id records (SetPropId, InsertEdgeId) that
9982    /// predate the deletion may not resolve (the id is tombstoned in the live map). The
9983    /// string-keyed `DeleteNode` record still matches and produces a `NodeDeleted` entry.
9984    /// Prop/edge history of a deleted node may therefore be partially unresolvable.
9985    ///
9986    /// ## Dense-id edge entries and tombstoned partners
9987    ///
9988    /// Edge entries from dense-id WAL records (`InsertEdgeId`) are omitted when the partner
9989    /// endpoint's dense id is tombstoned. As a result, a live node's history can contain an
9990    /// `EdgeRemoved` (string-keyed, always resolves) without a corresponding `EdgeAdded`.
9991    /// Build commit-bounded alias intervals for `queried_key`.
9992    ///
9993    /// Returns a list of `(key, valid_from_inclusive, valid_until_exclusive)` tuples.
9994    /// A record written under `key` at commit `c` matches the queried identity iff
9995    /// `c >= valid_from && (valid_until.is_none() || c < valid_until)`.
9996    ///
9997    /// Each alias entry carries both a lower and an upper bound so that key-reuse
9998    /// after a rename is handled correctly: if "a" is renamed to "b" at commit 5,
9999    /// then a NEW node is created as "a" at commit 7 and renamed to "c" at commit 10,
10000    /// querying "c" must NOT surface identity-1's events (commits 0–4 under "a");
10001    /// only identity-2's events (commits 7–9 under "a") are in scope.
10002    ///
10003    /// Only **forward aliasing**: querying the *new* key surfaces events written
10004    /// under the *old* key.  The reverse direction is not supported.
10005    fn build_key_alias_intervals(
10006        &self,
10007        frames: &[core_storage::wal::WalRecord],
10008        queried_key: &str,
10009    ) -> Vec<(String, u64, Option<u64>)> {
10010        use core_storage::wal::WalRecord;
10011
10012        // Pre-pass: build reverse_rename and key_starts maps.
10013        let mut reverse_rename: HashMap<String, (String, u64)> = HashMap::new();
10014        let mut key_starts: HashMap<String, Vec<u64>> = HashMap::new();
10015
10016        for (local_i, frame) in frames.iter().enumerate() {
10017            let commit = self.wal_horizon_floor + local_i as u64;
10018            let records: &[WalRecord] = match frame {
10019                WalRecord::Batch(inner) => inner.as_slice(),
10020                single => std::slice::from_ref(single),
10021            };
10022            for rec in records {
10023                match rec {
10024                    WalRecord::InsertNode { key, .. } | WalRecord::InsertNodeId { key, .. } => {
10025                        key_starts.entry(key.clone()).or_default().push(commit);
10026                    }
10027                    WalRecord::RenameNode { old_key, new_key } => {
10028                        // new_key came into existence at this commit.
10029                        key_starts.entry(new_key.clone()).or_default().push(commit);
10030                        // Record the reverse rename: new_key was introduced by renaming old_key.
10031                        reverse_rename.insert(new_key.clone(), (old_key.clone(), commit));
10032                    }
10033                    _ => {}
10034                }
10035            }
10036        }
10037
10038        // Build alias intervals by following the reverse rename chain.
10039        let mut result: Vec<(String, u64, Option<u64>)> = Vec::new();
10040        let mut current_key = queried_key.to_string();
10041        let mut current_valid_until: Option<u64> = None;
10042
10043        loop {
10044            // valid_from: the most recent commit where current_key was assigned to this
10045            // identity.  For aliases (valid_until = Some(vu)), find the last start event
10046            // for the key strictly before vu — this is where the alias's occupancy by
10047            // this identity began, correctly excluding prior identities that reused the key.
10048            let valid_from = if let Some(vu) = current_valid_until {
10049                key_starts
10050                    .get(&current_key)
10051                    .and_then(|starts| starts.iter().rev().find(|&&s| s < vu).copied())
10052                    .unwrap_or(self.wal_horizon_floor)
10053            } else {
10054                // Queried key — no upper bound; may have been introduced at any commit.
10055                self.wal_horizon_floor
10056            };
10057
10058            result.push((current_key.clone(), valid_from, current_valid_until));
10059
10060            match reverse_rename.get(&current_key) {
10061                Some((old_key, rename_commit)) => {
10062                    current_valid_until = Some(*rename_commit);
10063                    current_key = old_key.clone();
10064                }
10065                None => break,
10066            }
10067        }
10068
10069        result
10070    }
10071
10072    /// Returns true if `record_key` matches any alias interval that covers `commit`.
10073    fn aliases_match(
10074        intervals: &[(String, u64, Option<u64>)],
10075        record_key: &str,
10076        commit: u64,
10077    ) -> bool {
10078        intervals
10079            .iter()
10080            .any(|(k, vf, vu)| k == record_key && commit >= *vf && vu.is_none_or(|u| commit < u))
10081    }
10082
10083    /// Return the change history of node `key` by scanning the on-disk WAL.
10084    ///
10085    /// ## Horizon
10086    ///
10087    /// History reaches back only as far as the retained WAL. The returned
10088    /// [`HistoryResult`](crate::history::HistoryResult) carries `total_commits`
10089    /// (the exclusive upper bound for valid commit indices) and `horizon` (the
10090    /// oldest commit still reachable). When `horizon > 0`, older events were
10091    /// pruned and are not in `items`.
10092    pub fn node_history(
10093        &self,
10094        key: &str,
10095    ) -> Result<crate::history::HistoryResult<crate::history::HistoryEntry>> {
10096        use crate::history::{HistoryChange, HistoryEntry, HistoryResult};
10097        use core_storage::wal::WalRecord;
10098
10099        let (frames, _) = self.all_frames()?;
10100        let total_commits = self.wal_horizon_floor + frames.len() as u64;
10101
10102        // Resolve commit-bounded alias intervals for `key` (handles renames in the WAL).
10103        let alias_intervals = self.build_key_alias_intervals(&frames, key);
10104
10105        let mut out: Vec<HistoryEntry> = Vec::new();
10106
10107        for (local_i, frame) in frames.iter().enumerate() {
10108            let commit = self.wal_horizon_floor + local_i as u64;
10109            // Collect the inner records to process — Batch is one commit, single records are one commit.
10110            let records: &[WalRecord] = match frame {
10111                WalRecord::Batch(inner) => inner.as_slice(),
10112                single => std::slice::from_ref(single),
10113            };
10114
10115            for rec in records {
10116                let change = match rec {
10117                    WalRecord::InsertNode { label, key: k, .. }
10118                        if Self::aliases_match(&alias_intervals, k, commit) =>
10119                    {
10120                        Some(HistoryChange::NodeInserted {
10121                            label: label.clone(),
10122                        })
10123                    }
10124                    WalRecord::InsertNodeId { label, key: k, .. }
10125                        if Self::aliases_match(&alias_intervals, k, commit) =>
10126                    {
10127                        let label_str = match self.syms.resolve(*label) {
10128                            Some(s) => s.to_string(),
10129                            None => continue,
10130                        };
10131                        Some(HistoryChange::NodeInserted { label: label_str })
10132                    }
10133                    WalRecord::SetProp {
10134                        key: k,
10135                        field,
10136                        value,
10137                    } if Self::aliases_match(&alias_intervals, k, commit) => {
10138                        Some(HistoryChange::PropSet {
10139                            field: field.clone(),
10140                            value: value.clone(),
10141                        })
10142                    }
10143                    WalRecord::SetPropId { id, field, value } => {
10144                        // Use key_of_historical (not key_of) so a node's prop_set
10145                        // events remain visible after the node is later deleted:
10146                        // key_of returns None for a tombstoned id, which would
10147                        // silently drop every PropSet between insert and delete.
10148                        // Mirrors the InsertEdgeId arm below and edge_history's
10149                        // own id-keyed arms.
10150                        match self.ids.key_of_historical(*id) {
10151                            // key_of_historical returns the last-known (possibly
10152                            // post-rename, possibly post-delete) key; compare to queried key.
10153                            Some(resolved) if resolved == key => {
10154                                let field_str = match self.syms.resolve(*field) {
10155                                    Some(s) => s.to_string(),
10156                                    None => continue,
10157                                };
10158                                Some(HistoryChange::PropSet {
10159                                    field: field_str,
10160                                    value: value.clone(),
10161                                })
10162                            }
10163                            _ => None,
10164                        }
10165                    }
10166                    WalRecord::RemoveProp { key: k, field }
10167                        if Self::aliases_match(&alias_intervals, k, commit) =>
10168                    {
10169                        Some(HistoryChange::PropRemoved {
10170                            field: field.clone(),
10171                        })
10172                    }
10173                    WalRecord::InsertEdge {
10174                        edge_type,
10175                        src_key,
10176                        dst_key,
10177                    } => {
10178                        if Self::aliases_match(&alias_intervals, src_key, commit) {
10179                            Some(HistoryChange::EdgeAdded {
10180                                edge_type: edge_type.clone(),
10181                                other: dst_key.clone(),
10182                                outgoing: true,
10183                            })
10184                        } else if Self::aliases_match(&alias_intervals, dst_key, commit) {
10185                            Some(HistoryChange::EdgeAdded {
10186                                edge_type: edge_type.clone(),
10187                                other: src_key.clone(),
10188                                outgoing: false,
10189                            })
10190                        } else {
10191                            None
10192                        }
10193                    }
10194                    WalRecord::InsertEdgeId { etype, src, dst } => {
10195                        let etype_str = match self.syms.resolve(*etype) {
10196                            Some(s) => s.to_string(),
10197                            None => continue,
10198                        };
10199                        // key_of_historical (not key_of): an edge added before
10200                        // either endpoint was later deleted must still resolve —
10201                        // see the SetPropId arm above and edge_history's
10202                        // InsertEdgeId arm, which use the same lookup for the
10203                        // same reason.
10204                        let src_key = self.ids.key_of_historical(*src);
10205                        let dst_key = self.ids.key_of_historical(*dst);
10206                        if src_key == Some(key) {
10207                            let other = match dst_key {
10208                                Some(s) => s.to_string(),
10209                                None => continue,
10210                            };
10211                            Some(HistoryChange::EdgeAdded {
10212                                edge_type: etype_str,
10213                                other,
10214                                outgoing: true,
10215                            })
10216                        } else if dst_key == Some(key) {
10217                            let other = match src_key {
10218                                Some(s) => s.to_string(),
10219                                None => continue,
10220                            };
10221                            Some(HistoryChange::EdgeAdded {
10222                                edge_type: etype_str,
10223                                other,
10224                                outgoing: false,
10225                            })
10226                        } else {
10227                            None
10228                        }
10229                    }
10230                    WalRecord::DeleteEdge {
10231                        edge_type,
10232                        src_key,
10233                        dst_key,
10234                    } => {
10235                        if Self::aliases_match(&alias_intervals, src_key, commit) {
10236                            Some(HistoryChange::EdgeRemoved {
10237                                edge_type: edge_type.clone(),
10238                                other: dst_key.clone(),
10239                                outgoing: true,
10240                            })
10241                        } else if Self::aliases_match(&alias_intervals, dst_key, commit) {
10242                            Some(HistoryChange::EdgeRemoved {
10243                                edge_type: edge_type.clone(),
10244                                other: src_key.clone(),
10245                                outgoing: false,
10246                            })
10247                        } else {
10248                            None
10249                        }
10250                    }
10251                    WalRecord::DeleteNode { key: k }
10252                        if Self::aliases_match(&alias_intervals, k, commit) =>
10253                    {
10254                        Some(HistoryChange::NodeDeleted)
10255                    }
10256                    // Skip: rule/view/fulltext/intern metadata; Batch wrapper handled above.
10257                    _ => None,
10258                };
10259
10260                if let Some(change) = change {
10261                    out.push(HistoryEntry { commit, change });
10262                }
10263            }
10264        }
10265
10266        Ok(HistoryResult {
10267            items: out,
10268            total_commits,
10269            horizon: self.wal_horizon_floor,
10270        })
10271    }
10272
10273    /// Return the per-edge change history between nodes `a` and `b` by scanning
10274    /// the on-disk WAL.
10275    ///
10276    /// ## Horizon
10277    ///
10278    /// History reaches back only to the last WAL-truncating snapshot, exactly
10279    /// like `node_history` and `open_at`. The returned [`HistoryResult`] carries
10280    /// `total_commits` (= number of WAL frames), which is the exclusive upper
10281    /// bound for valid commit indices.
10282    ///
10283    /// ## Derived edges
10284    ///
10285    /// Rule-derived edges appear via `DerivedEdgeAdded` / `DerivedEdgeRetracted`
10286    /// WAL markers written by `log_then_apply_with` after each rule-firing
10287    /// mutation. The `rule` field of those events carries the rule name.
10288    ///
10289    /// ## DeleteNode
10290    ///
10291    /// When a node is deleted, its manual incident edges are swept inline without
10292    /// individual `DeleteEdge` WAL records. `edge_history` detects `DeleteNode`
10293    /// events for either endpoint and synthesises `Retracted(rule:None)` events
10294    /// for each manual edge that was active at that point. Derived edges active at
10295    /// the time of deletion are handled by the `DerivedEdgeRetracted` marker that
10296    /// the engine appends immediately after the `DeleteNode` record; those events
10297    /// carry correct rule attribution and are emitted by the marker arm, not the
10298    /// synthetic sweep.
10299    ///
10300    /// ## Masks
10301    ///
10302    /// Like `node_history`, this method has no mask parameter and returns WAL
10303    /// history regardless of any role mask. For masked history semantics, apply
10304    /// the mask at the caller level.
10305    pub fn edge_history(
10306        &self,
10307        a: &str,
10308        b: &str,
10309    ) -> Result<crate::history::HistoryResult<crate::history::EdgeHistoryEvent>> {
10310        use crate::history::{EdgeEvent, EdgeHistoryEvent, HistoryResult};
10311        use core_storage::wal::WalRecord;
10312
10313        let (frames, _) = self.all_frames()?;
10314        let total_commits = self.wal_horizon_floor + frames.len() as u64;
10315
10316        // Resolve all historical names for a and b (handles RenameNode in the WAL).
10317        // Intervals are commit-bounded so recycled keys don't contaminate histories.
10318        let alias_a = self.build_key_alias_intervals(&frames, a);
10319        let alias_b = self.build_key_alias_intervals(&frames, b);
10320
10321        // Active edges between a and b tracked as (edge_type, src_key, dst_key, is_derived).
10322        // The is_derived flag is used by the DeleteNode sweep: manual edges are
10323        // swept with a synthetic Retracted(rule:None); derived edges are skipped
10324        // because the engine writes a DerivedEdgeRetracted marker immediately after
10325        // the DeleteNode record, which carries the correct rule attribution.
10326        let mut active: Vec<(String, String, String, bool)> = Vec::new();
10327        let mut out: Vec<EdgeHistoryEvent> = Vec::new();
10328
10329        for (local_i, frame) in frames.iter().enumerate() {
10330            let commit = self.wal_horizon_floor + local_i as u64;
10331            let records: &[WalRecord] = match frame {
10332                WalRecord::Batch(inner) => inner.as_slice(),
10333                single => std::slice::from_ref(single),
10334            };
10335
10336            for rec in records {
10337                match rec {
10338                    WalRecord::InsertEdge {
10339                        edge_type,
10340                        src_key,
10341                        dst_key,
10342                    } => {
10343                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
10344                            && Self::aliases_match(&alias_b, dst_key, commit);
10345                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
10346                            && Self::aliases_match(&alias_a, dst_key, commit);
10347                        if is_ab || is_ba {
10348                            active.push((
10349                                edge_type.clone(),
10350                                src_key.clone(),
10351                                dst_key.clone(),
10352                                false,
10353                            ));
10354                            out.push(EdgeHistoryEvent {
10355                                edge_type: edge_type.clone(),
10356                                commit,
10357                                event: EdgeEvent::Added,
10358                                rule: None,
10359                            });
10360                        }
10361                    }
10362                    WalRecord::InsertEdgeId { etype, src, dst } => {
10363                        let etype_str = match self.syms.resolve(*etype) {
10364                            Some(s) => s.to_string(),
10365                            None => continue,
10366                        };
10367                        // Use key_of_historical so tombstoned nodes (deleted
10368                        // later in the WAL) still resolve during the scan.
10369                        let src_key = self.ids.key_of_historical(*src);
10370                        let dst_key = self.ids.key_of_historical(*dst);
10371                        let is_ab = src_key == Some(a) && dst_key == Some(b);
10372                        let is_ba = src_key == Some(b) && dst_key == Some(a);
10373                        if is_ab || is_ba {
10374                            let src_str = src_key.unwrap().to_string();
10375                            let dst_str = dst_key.unwrap().to_string();
10376                            active.push((etype_str.clone(), src_str, dst_str, false));
10377                            out.push(EdgeHistoryEvent {
10378                                edge_type: etype_str,
10379                                commit,
10380                                event: EdgeEvent::Added,
10381                                rule: None,
10382                            });
10383                        }
10384                    }
10385                    WalRecord::DeleteEdge {
10386                        edge_type,
10387                        src_key,
10388                        dst_key,
10389                    } => {
10390                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
10391                            && Self::aliases_match(&alias_b, dst_key, commit);
10392                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
10393                            && Self::aliases_match(&alias_a, dst_key, commit);
10394                        if is_ab || is_ba {
10395                            // Remove the first matching active entry (flag ignored).
10396                            if let Some(pos) = active.iter().position(|(et, s, d, _)| {
10397                                et == edge_type && s == src_key && d == dst_key
10398                            }) {
10399                                active.remove(pos);
10400                            }
10401                            out.push(EdgeHistoryEvent {
10402                                edge_type: edge_type.clone(),
10403                                commit,
10404                                event: EdgeEvent::Retracted,
10405                                rule: None,
10406                            });
10407                        }
10408                    }
10409                    WalRecord::DeleteNode { key: k }
10410                        if Self::aliases_match(&alias_a, k, commit)
10411                            || Self::aliases_match(&alias_b, k, commit) =>
10412                    {
10413                        // Sweep: implicitly retract only MANUAL active edges.
10414                        // Derived active edges are skipped here because the rule
10415                        // engine appends a DerivedEdgeRetracted marker immediately
10416                        // after this DeleteNode record; that marker produces the
10417                        // single correctly-attributed Retracted event.  Derived
10418                        // entries are dropped from `active` (the marker arm's
10419                        // idempotent retain finds nothing to remove).
10420                        for (et, _, _, is_derived) in active.drain(..) {
10421                            if !is_derived {
10422                                out.push(EdgeHistoryEvent {
10423                                    edge_type: et,
10424                                    commit,
10425                                    event: EdgeEvent::Retracted,
10426                                    rule: None,
10427                                });
10428                            }
10429                            // Derived: drop silently; marker carries the Retracted event.
10430                        }
10431                    }
10432                    WalRecord::DerivedEdgeAdded {
10433                        rule,
10434                        edge_type: et,
10435                        src_key,
10436                        dst_key,
10437                    } => {
10438                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
10439                            && Self::aliases_match(&alias_b, dst_key, commit);
10440                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
10441                            && Self::aliases_match(&alias_a, dst_key, commit);
10442                        if is_ab || is_ba {
10443                            active.push((et.clone(), src_key.clone(), dst_key.clone(), true));
10444                            out.push(EdgeHistoryEvent {
10445                                edge_type: et.clone(),
10446                                commit,
10447                                event: EdgeEvent::Added,
10448                                rule: Some(rule.clone()),
10449                            });
10450                        }
10451                    }
10452                    WalRecord::DerivedEdgeRetracted {
10453                        rule,
10454                        edge_type: et,
10455                        src_key,
10456                        dst_key,
10457                    } => {
10458                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
10459                            && Self::aliases_match(&alias_b, dst_key, commit);
10460                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
10461                            && Self::aliases_match(&alias_a, dst_key, commit);
10462                        if is_ab || is_ba {
10463                            // Push unconditionally: a derived edge whose Added marker
10464                            // predates the history horizon has no `active` entry, but
10465                            // the retraction is still a real in-window event.
10466                            // Remove from active idempotently if present.
10467                            active.retain(|(aet, s, d, _)| {
10468                                !(aet == et && s == src_key && d == dst_key)
10469                            });
10470                            out.push(EdgeHistoryEvent {
10471                                edge_type: et.clone(),
10472                                commit,
10473                                event: EdgeEvent::Retracted,
10474                                rule: Some(rule.clone()),
10475                            });
10476                        }
10477                    }
10478                    // All other records (InsertNode, SetProp, CreateRule, etc.)
10479                    // do not affect edges between a and b.
10480                    _ => {}
10481                }
10482            }
10483        }
10484
10485        Ok(HistoryResult {
10486            items: out,
10487            total_commits,
10488            horizon: self.wal_horizon_floor,
10489        })
10490    }
10491
10492    /// Return `true` iff an edge of `edge_type` existed between `a` and `b`
10493    /// (in either direction) at the WAL commit `at_commit`.
10494    ///
10495    /// ## Horizon
10496    ///
10497    /// Valid commit indices are `0..total_commits` where `total_commits` is the
10498    /// number of WAL frames. An `at_commit >= total_commits` is outside the
10499    /// visible horizon and returns [`GraphError::CommitOutOfRange`].
10500    ///
10501    /// ## Derived edges
10502    ///
10503    /// Rule-derived edges are tracked via `DerivedEdgeAdded` / `DerivedEdgeRetracted`
10504    /// WAL markers appended at firing time (Task 1). `was_linked` reads these markers
10505    /// and therefore includes derived edges in its point-in-time evaluation,
10506    /// matching `edge_history`'s fidelity.
10507    pub fn was_linked(&self, a: &str, b: &str, edge_type: &str, at_commit: u64) -> Result<bool> {
10508        use core_storage::wal::WalRecord;
10509
10510        let (frames, _) = self.all_frames()?;
10511        let total_commits = self.wal_horizon_floor + frames.len() as u64;
10512
10513        // Horizon floor: commits in pruned archives are unreachable.
10514        if at_commit < self.wal_horizon_floor {
10515            return Err(GraphError::CommitOutOfRange {
10516                commit: at_commit,
10517                total: total_commits,
10518                floor: self.wal_horizon_floor,
10519            });
10520        }
10521        if at_commit >= total_commits {
10522            return Err(GraphError::CommitOutOfRange {
10523                commit: at_commit,
10524                total: total_commits,
10525                floor: self.wal_horizon_floor,
10526            });
10527        }
10528
10529        // Resolve all historical names for a and b (handles RenameNode in the WAL).
10530        // Intervals are commit-bounded so recycled keys don't contaminate point-in-time reads.
10531        let alias_a = self.build_key_alias_intervals(&frames, a);
10532        let alias_b = self.build_key_alias_intervals(&frames, b);
10533
10534        // Local index into surviving frames (0 = first frame of oldest archive).
10535        let local_commit = at_commit - self.wal_horizon_floor;
10536
10537        // Replay local frames 0..=local_commit, tracking active edges.
10538        let mut active: BTreeSet<(String, String, String)> = BTreeSet::new();
10539
10540        for (local_i, frame) in frames.iter().enumerate().take((local_commit + 1) as usize) {
10541            let commit = self.wal_horizon_floor + local_i as u64;
10542            let records: &[WalRecord] = match frame {
10543                WalRecord::Batch(inner) => inner.as_slice(),
10544                single => std::slice::from_ref(single),
10545            };
10546
10547            for rec in records {
10548                match rec {
10549                    WalRecord::InsertEdge {
10550                        edge_type: et,
10551                        src_key,
10552                        dst_key,
10553                    } => {
10554                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
10555                            && Self::aliases_match(&alias_b, dst_key, commit);
10556                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
10557                            && Self::aliases_match(&alias_a, dst_key, commit);
10558                        if is_ab || is_ba {
10559                            active.insert((et.clone(), src_key.clone(), dst_key.clone()));
10560                        }
10561                    }
10562                    WalRecord::InsertEdgeId { etype, src, dst } => {
10563                        let etype_str = match self.syms.resolve(*etype) {
10564                            Some(s) => s.to_string(),
10565                            None => continue,
10566                        };
10567                        // Use key_of_historical so tombstoned nodes resolve.
10568                        let src_key = self.ids.key_of_historical(*src);
10569                        let dst_key = self.ids.key_of_historical(*dst);
10570                        let is_ab = src_key == Some(a) && dst_key == Some(b);
10571                        let is_ba = src_key == Some(b) && dst_key == Some(a);
10572                        if is_ab || is_ba {
10573                            active.insert((
10574                                etype_str,
10575                                src_key.unwrap().to_string(),
10576                                dst_key.unwrap().to_string(),
10577                            ));
10578                        }
10579                    }
10580                    WalRecord::DeleteEdge {
10581                        edge_type: et,
10582                        src_key,
10583                        dst_key,
10584                    } => {
10585                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
10586                            && Self::aliases_match(&alias_b, dst_key, commit);
10587                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
10588                            && Self::aliases_match(&alias_a, dst_key, commit);
10589                        if is_ab || is_ba {
10590                            active.remove(&(et.clone(), src_key.clone(), dst_key.clone()));
10591                        }
10592                    }
10593                    WalRecord::DeleteNode { key: k }
10594                        if Self::aliases_match(&alias_a, k, commit)
10595                            || Self::aliases_match(&alias_b, k, commit) =>
10596                    {
10597                        // All edges touching the deleted node are gone.
10598                        active.retain(|(_, s, d)| s != k && d != k);
10599                    }
10600                    WalRecord::DerivedEdgeAdded {
10601                        edge_type: et,
10602                        src_key,
10603                        dst_key,
10604                        ..
10605                    } => {
10606                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
10607                            && Self::aliases_match(&alias_b, dst_key, commit);
10608                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
10609                            && Self::aliases_match(&alias_a, dst_key, commit);
10610                        if is_ab || is_ba {
10611                            active.insert((et.clone(), src_key.clone(), dst_key.clone()));
10612                        }
10613                    }
10614                    WalRecord::DerivedEdgeRetracted {
10615                        edge_type: et,
10616                        src_key,
10617                        dst_key,
10618                        ..
10619                    } => {
10620                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
10621                            && Self::aliases_match(&alias_b, dst_key, commit);
10622                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
10623                            && Self::aliases_match(&alias_a, dst_key, commit);
10624                        if is_ab || is_ba {
10625                            active.remove(&(et.clone(), src_key.clone(), dst_key.clone()));
10626                        }
10627                    }
10628                    _ => {}
10629                }
10630            }
10631        }
10632
10633        Ok(active.iter().any(|(et, _, _)| et == edge_type))
10634    }
10635
10636    /// Every edge incident to `key` — either endpoint — that existed at WAL
10637    /// commit `commit`, from ONE scan of the WAL.
10638    ///
10639    /// This is the bulk form of [`was_linked`](GraphDb::was_linked): answering
10640    /// "what did K's relationships look like at commit C" with one call instead
10641    /// of one [`edge_history`](GraphDb::edge_history) per candidate partner.
10642    /// The two agree edge for edge.
10643    ///
10644    /// Results are sorted by `(edge_type, src_key, dst_key)`.
10645    ///
10646    /// ## Horizon
10647    ///
10648    /// Valid commit indices are `wal_horizon_floor()..wal_total_commits()`;
10649    /// anything outside is [`GraphError::CommitOutOfRange`], exactly like
10650    /// `was_linked`. An unknown key is not an error — it simply had no edges.
10651    ///
10652    /// ## Derived edges
10653    ///
10654    /// `DerivedEdgeAdded` / `DerivedEdgeRetracted` markers carry rule
10655    /// attribution, so a rule-owned edge comes back with `derived: true` and
10656    /// `rule: Some(name)`.
10657    ///
10658    /// ## Renames
10659    ///
10660    /// `key` is matched through the same commit-bounded alias intervals
10661    /// `edge_history` uses, so querying a node's *current* key surfaces edges
10662    /// written under an earlier name. Endpoint keys in the result are reported
10663    /// under the name the node carries today, so they can be fed straight back
10664    /// into `node_info`, `explain` or another `edges_at`.
10665    ///
10666    /// ## Masks
10667    ///
10668    /// Like `edge_history` and `node_history`, this reads the WAL regardless of
10669    /// any role mask. Apply masking at the caller level.
10670    pub fn edges_at(&self, key: &str, commit: u64) -> Result<Vec<EdgeAt>> {
10671        use core_storage::wal::WalRecord;
10672
10673        let (frames, _) = self.all_frames()?;
10674        let total_commits = self.wal_horizon_floor + frames.len() as u64;
10675
10676        // Horizon floor: commits in pruned archives are unreachable.
10677        if commit < self.wal_horizon_floor || commit >= total_commits {
10678            return Err(GraphError::CommitOutOfRange {
10679                commit,
10680                total: total_commits,
10681                floor: self.wal_horizon_floor,
10682            });
10683        }
10684
10685        // Commit-bounded historical names of `key` (handles RenameNode).
10686        let alias = self.build_key_alias_intervals(&frames, key);
10687
10688        // Forward rename chain, for reporting endpoints under their current
10689        // names: old key → [(commit, new key)] in ascending commit order.
10690        // Built over the whole WAL, not just the prefix up to `commit`, because
10691        // a rename after `commit` still changes what the node is called today.
10692        let mut renames: HashMap<String, Vec<(u64, String)>> = HashMap::new();
10693        for (local_i, frame) in frames.iter().enumerate() {
10694            let c = self.wal_horizon_floor + local_i as u64;
10695            let records: &[WalRecord] = match frame {
10696                WalRecord::Batch(inner) => inner.as_slice(),
10697                single => std::slice::from_ref(single),
10698            };
10699            for rec in records {
10700                if let WalRecord::RenameNode { old_key, new_key } = rec {
10701                    renames
10702                        .entry(old_key.clone())
10703                        .or_default()
10704                        .push((c, new_key.clone()));
10705                }
10706            }
10707        }
10708
10709        // The name a node written as `k` at commit `from` carries today.
10710        // Follows the first rename at or after `from`, then keeps going. The
10711        // iteration cap bounds a rename cycle inside a single batch.
10712        let canon = |k: &str, from: u64| -> String {
10713            if renames.is_empty() {
10714                return k.to_string();
10715            }
10716            let mut cur = k.to_string();
10717            let mut at = from;
10718            for _ in 0..64 {
10719                match renames
10720                    .get(&cur)
10721                    .and_then(|v| v.iter().find(|(c, _)| *c >= at))
10722                {
10723                    Some((c, new)) => {
10724                        at = *c;
10725                        cur = new.clone();
10726                    }
10727                    None => break,
10728                }
10729            }
10730            cur
10731        };
10732
10733        let local_commit = commit - self.wal_horizon_floor;
10734        // (edge_type, src_key, dst_key) → (derived, rule)
10735        let mut active: BTreeMap<(String, String, String), (bool, Option<String>)> =
10736            BTreeMap::new();
10737
10738        for (local_i, frame) in frames.iter().enumerate().take((local_commit + 1) as usize) {
10739            let c = self.wal_horizon_floor + local_i as u64;
10740            let records: &[WalRecord] = match frame {
10741                WalRecord::Batch(inner) => inner.as_slice(),
10742                single => std::slice::from_ref(single),
10743            };
10744
10745            for rec in records {
10746                match rec {
10747                    WalRecord::InsertEdge {
10748                        edge_type,
10749                        src_key,
10750                        dst_key,
10751                    } => {
10752                        if Self::aliases_match(&alias, src_key, c)
10753                            || Self::aliases_match(&alias, dst_key, c)
10754                        {
10755                            active.insert(
10756                                (edge_type.clone(), canon(src_key, c), canon(dst_key, c)),
10757                                (false, None),
10758                            );
10759                        }
10760                    }
10761                    WalRecord::InsertEdgeId { etype, src, dst } => {
10762                        let Some(etype_str) = self.syms.resolve(*etype) else {
10763                            continue;
10764                        };
10765                        // `key_of_historical` resolves tombstoned ids too, and
10766                        // already returns the node's current key — no rename
10767                        // canonicalisation needed on this arm.
10768                        let (Some(src_key), Some(dst_key)) = (
10769                            self.ids.key_of_historical(*src),
10770                            self.ids.key_of_historical(*dst),
10771                        ) else {
10772                            continue;
10773                        };
10774                        if src_key == key || dst_key == key {
10775                            active.insert(
10776                                (
10777                                    etype_str.to_string(),
10778                                    src_key.to_string(),
10779                                    dst_key.to_string(),
10780                                ),
10781                                (false, None),
10782                            );
10783                        }
10784                    }
10785                    WalRecord::DeleteEdge {
10786                        edge_type,
10787                        src_key,
10788                        dst_key,
10789                    } => {
10790                        if Self::aliases_match(&alias, src_key, c)
10791                            || Self::aliases_match(&alias, dst_key, c)
10792                        {
10793                            active.remove(&(
10794                                edge_type.clone(),
10795                                canon(src_key, c),
10796                                canon(dst_key, c),
10797                            ));
10798                        }
10799                    }
10800                    WalRecord::DeleteNode { key: k } => {
10801                        if active.is_empty() {
10802                            continue;
10803                        }
10804                        if Self::aliases_match(&alias, k, c) {
10805                            // Our node is gone; every incident edge goes with it.
10806                            active.clear();
10807                        } else {
10808                            // A partner is gone; its edges to us go with it.
10809                            let ck = canon(k, c);
10810                            active.retain(|(_, s, d), _| *s != ck && *d != ck);
10811                        }
10812                    }
10813                    WalRecord::DerivedEdgeAdded {
10814                        rule,
10815                        edge_type,
10816                        src_key,
10817                        dst_key,
10818                    } => {
10819                        if Self::aliases_match(&alias, src_key, c)
10820                            || Self::aliases_match(&alias, dst_key, c)
10821                        {
10822                            active.insert(
10823                                (edge_type.clone(), canon(src_key, c), canon(dst_key, c)),
10824                                (true, Some(rule.clone())),
10825                            );
10826                        }
10827                    }
10828                    WalRecord::DerivedEdgeRetracted {
10829                        edge_type,
10830                        src_key,
10831                        dst_key,
10832                        ..
10833                    } => {
10834                        if Self::aliases_match(&alias, src_key, c)
10835                            || Self::aliases_match(&alias, dst_key, c)
10836                        {
10837                            active.remove(&(
10838                                edge_type.clone(),
10839                                canon(src_key, c),
10840                                canon(dst_key, c),
10841                            ));
10842                        }
10843                    }
10844                    // InsertNode, SetProp, CreateRule, … do not move edges.
10845                    _ => {}
10846                }
10847            }
10848        }
10849
10850        // BTreeMap iteration is already (edge_type, src, dst) order.
10851        Ok(active
10852            .into_iter()
10853            .map(|((edge_type, src_key, dst_key), (derived, rule))| EdgeAt {
10854                edge_type,
10855                src_key,
10856                dst_key,
10857                derived,
10858                rule,
10859            })
10860            .collect())
10861    }
10862
10863    /// The derived edges that would be retracted and derived if `key.field`
10864    /// were set to `value` — computed WITHOUT writing anything.
10865    ///
10866    /// Nothing is committed and nothing on `self` is mutated: the rule engine's
10867    /// provenance, its candidate indexes, the topology and the property columns
10868    /// are all cloned first, the change is applied to the clone, and the real
10869    /// per-node re-derivation (`RuleEngine::on_node_changed` — the same call
10870    /// `set_prop` makes during apply) runs against it. The derived-edge deltas
10871    /// it emits are the answer, so rule semantics — predicates, top-k,
10872    /// via-hops, chaining, weights — are the engine's, not a re-implementation.
10873    ///
10874    /// Works on a read-only handle.
10875    ///
10876    /// **While a rule's vector index is still building** (`RuleStats::building`)
10877    /// the clone carries no pending-build state, so this reports the edges that
10878    /// rule would derive — which the live store will not derive until its
10879    /// backfill runs. Right about the end state, early about the timing.
10880    ///
10881    /// Returns `Err(KeyNotFound)` for an unknown or tombstoned key and
10882    /// `Err(ViewPropReadOnly)` for a field a view owns — matching
10883    /// [`set_prop`](GraphDb::set_prop)'s validation. A change with no effect
10884    /// (the node already holds `value`, or no rule watches `field`) returns
10885    /// empty lists.
10886    ///
10887    /// ## Cost
10888    ///
10889    /// One clone of the property columns, the topology overlay, the symbol
10890    /// interner, the edge properties and the provenance map, plus one candidate
10891    /// re-index (O(nodes × rules)). That is much cheaper than copying the store
10892    /// directory, but it is not free — this is an interactive "what if", not a
10893    /// hot path.
10894    pub fn what_if_set_prop(&self, key: &str, field: &str, value: Value) -> Result<WhatIf> {
10895        // The engine's provenance, HNSW and IVF state live in the mmap'd base
10896        // until something asks for them. On a store opened cold from a snapshot
10897        // this is the first ask, and without it the clone below starts from an
10898        // empty provenance map: nothing to retract, so `lost` comes back empty.
10899        self.ensure_v8_base_sections_loaded();
10900
10901        let empty = WhatIf {
10902            lost: Vec::new(),
10903            gained: Vec::new(),
10904        };
10905
10906        if let Some(view_name) = self.view_store.view_for_prop(field) {
10907            return Err(GraphError::ViewPropReadOnly {
10908                view_name: view_name.to_string(),
10909            });
10910        }
10911        MutPreview::new(self).check_live_key(key)?;
10912        let id = self
10913            .ids
10914            .get(key)
10915            .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
10916
10917        let rules: Vec<RuleDef> = self.engine.rules().cloned().collect();
10918        if rules.is_empty() {
10919            return Ok(empty);
10920        }
10921
10922        // No rule watches this field → no derivation can change.
10923        if !rules.iter().any(|r| r.watched_fields().contains(field)) {
10924            return Ok(empty);
10925        }
10926
10927        let old_value = build_props_view(&self.props, &self.base)
10928            .get(id, field)
10929            .map(|vr| vr.into_value());
10930        if old_value.as_ref() == Some(&value) {
10931            return Ok(empty);
10932        }
10933
10934        // --- Clone every piece of state the re-derivation writes to. ---
10935        let mut props = self.props.clone();
10936        let mut topo = self.topo.clone();
10937        let mut syms = self.syms.clone();
10938        let mut edge_props = self.edge_props.clone();
10939
10940        let mut tripped: BTreeMap<String, bool> = BTreeMap::new();
10941        let mut fires: BTreeMap<String, u64> = BTreeMap::new();
10942        for r in &rules {
10943            tripped.insert(r.name.clone(), self.engine.is_tripped(&r.name));
10944            fires.insert(r.name.clone(), self.engine.fire_count(&r.name));
10945        }
10946        // `provenance()` decodes retained snapshot bytes on first use; the
10947        // engine clone needs the real map, not an empty one.
10948        let provenance = self.engine.provenance().clone();
10949        let mut engine = core_rules::RuleEngine::from_persist(rules, provenance, tripped, fires);
10950
10951        // Build the candidate indexes from the state BEFORE the change, exactly
10952        // as apply() sees them: `on_node_changed` withdraws the node under its
10953        // old value and refiles it under the new one, so the index must not
10954        // already reflect the change.
10955        engine.reindex_all_load_state(
10956            &self.ids,
10957            &syms,
10958            &self.labels,
10959            build_props_view(&self.props, &self.base),
10960            self.engine.export_ivf_state(),
10961            self.engine.export_hnsw_state_passthrough(),
10962        );
10963        engine.set_emit_deltas(true);
10964
10965        // --- Apply the hypothetical change and re-derive. ---
10966        props.set(id, field, value);
10967        {
10968            let mut gm = make_graph_mut(
10969                &self.ids,
10970                &mut syms,
10971                &self.labels,
10972                build_props_view(&props, &self.base),
10973                &mut topo,
10974                &self.base,
10975                &mut edge_props,
10976            );
10977            engine.on_node_changed(id, Some((field, old_value)), &mut gm);
10978        }
10979
10980        let mut lost: BTreeSet<EdgeAt> = BTreeSet::new();
10981        let mut gained: BTreeSet<EdgeAt> = BTreeSet::new();
10982        for d in engine.drain_deltas() {
10983            let edge = EdgeAt {
10984                edge_type: d.edge_type,
10985                src_key: d.src_key,
10986                dst_key: d.dst_key,
10987                derived: true,
10988                rule: Some(d.rule),
10989            };
10990            if d.fired {
10991                gained.insert(edge);
10992            } else {
10993                lost.insert(edge);
10994            }
10995        }
10996        // An edge retracted and re-derived within the same re-derivation (top-k
10997        // churn) is not a change the caller would see.
10998        let churn: Vec<EdgeAt> = lost.intersection(&gained).cloned().collect();
10999        for e in churn {
11000            lost.remove(&e);
11001            gained.remove(&e);
11002        }
11003
11004        Ok(WhatIf {
11005            lost: lost.into_iter().collect(),
11006            gained: gained.into_iter().collect(),
11007        })
11008    }
11009
11010    pub fn edge_count(&self) -> u64 {
11011        self.topo_view().edge_count()
11012    }
11013
11014    /// Live/tombstone/edge counts plus per-rule provenance size, trip latch,
11015    /// and fire counter (includes rebuild evaluations). Rules are sorted by name.
11016    pub fn stats(&self) -> Stats {
11017        self.ensure_v8_base_sections_loaded();
11018        let building = self.engine.builds_in_progress();
11019        let rules: Vec<RuleStats> = self
11020            .engine
11021            .rules()
11022            .map(|r| RuleStats {
11023                name: r.name.clone(),
11024                edges: self
11025                    .engine
11026                    .provenance()
11027                    .get(&r.name)
11028                    .map(|s| s.len() as u64)
11029                    .unwrap_or(0),
11030                tripped: self.engine.is_tripped(&r.name),
11031                fires: self.engine.fire_count(&r.name),
11032                approximate: r.approximate,
11033                building: building.iter().find(|b| b.rule == r.name).cloned(),
11034            })
11035            .collect();
11036        Stats {
11037            nodes_live: self.ids.live_len(),
11038            nodes_tombstoned: self.ids.len() - self.ids.live_len(),
11039            edges: self.topo_view().edge_count(),
11040            rules,
11041            chain_truncations: self.engine.chain_truncations(),
11042            history_floor: self.wal_horizon_floor,
11043            namespaces: self.namespace_stats(),
11044        }
11045    }
11046
11047    /// On-disk size of the WAL file in bytes.
11048    ///
11049    /// Reads file metadata without loading WAL contents.  Returns `Err` for
11050    /// in-memory (`SimFs`) databases where no WAL file exists on disk.
11051    pub fn wal_size_bytes(&self) -> std::io::Result<u64> {
11052        let path = self.fs.wal_path().ok_or_else(|| {
11053            std::io::Error::new(
11054                std::io::ErrorKind::Unsupported,
11055                "wal_path not available for this Fs implementation",
11056            )
11057        })?;
11058        Ok(std::fs::metadata(path)?.len())
11059    }
11060
11061    /// Set the slow-query threshold.  Queries whose execution time equals or
11062    /// exceeds `ms` milliseconds are logged.  Pass `0` to disable.
11063    ///
11064    /// Use this setter in tests — the environment variable
11065    /// `MUSHROOMDB_SLOW_QUERY_MS` is process-global and races parallel test
11066    /// threads.
11067    pub fn set_slow_query_threshold_ms(&mut self, ms: u64) {
11068        self.slow_query_threshold_ms = ms;
11069    }
11070
11071    /// Snapshot of the slow-query ring buffer and lifetime counter.
11072    pub fn slow_query_snapshot(&self) -> SlowQuerySnapshot {
11073        let log = self.slow_queries.lock().unwrap_or_else(|e| e.into_inner());
11074        SlowQuerySnapshot {
11075            threshold_ms: self.slow_query_threshold_ms,
11076            count: log.total,
11077            last: log.entries.iter().cloned().collect(),
11078        }
11079    }
11080
11081    /// Instant the database was opened.  Used by consumers (e.g. `/metrics`)
11082    /// to compute uptime.
11083    pub fn started_at(&self) -> std::time::Instant {
11084        self.started_at
11085    }
11086
11087    /// On-disk snapshot format version this binary writes and reads.
11088    pub fn format_version() -> u16 {
11089        core_storage::snapshot::VERSION
11090    }
11091
11092    /// Test-support: total bytes appended (SimFs only usage).
11093    pub fn fs_total_appended(&self) -> usize
11094    where
11095        F: FsIntrospect,
11096    {
11097        self.fs.total_appended()
11098    }
11099
11100    /// Test-support: successful `Fs::sync` calls (SimFs / counting fs).
11101    pub fn fs_sync_count(&self) -> usize
11102    where
11103        F: FsIntrospect,
11104    {
11105        self.fs.sync_count()
11106    }
11107
11108    /// Consume the db, returning its fs (for crash simulation).
11109    pub fn into_fs(self) -> F {
11110        self.fs
11111    }
11112
11113    pub fn snapshot(&mut self) -> Result<()> {
11114        self.snapshot_with(SnapshotOptions::default())
11115    }
11116
11117    /// Snapshot with explicit options.
11118    ///
11119    /// # `keep_wal`
11120    ///
11121    /// When `keep_wal` is `false` (the default, same as [`snapshot`]):
11122    ///   - The WAL is replaced with a minimal baseline containing one
11123    ///     `EnableFulltext` record per active declaration.  All pre-snapshot
11124    ///     history is discarded; `open_at` can only reach post-snapshot commits.
11125    ///
11126    /// When `keep_wal` is `true`:
11127    ///   - The WAL is left intact.  All pre-snapshot commits remain reachable
11128    ///     via `open_at`.  The existing WAL already contains the original
11129    ///     `EnableFulltext` records, so no baseline re-write is needed; the
11130    ///     recovery guards in `apply()` silently skip any duplicate records on
11131    ///     replay.
11132    ///   - Crash window: a crash after the snapshot write but before the next
11133    ///     WAL write leaves the full pre-snapshot WAL intact.  On reopen the
11134    ///     snapshot is loaded and the WAL replayed idempotently over it — safe
11135    ///     because every `apply()` arm is idempotent when replayed over an
11136    ///     already-current snapshot.
11137    pub fn snapshot_with(&mut self, opts: SnapshotOptions) -> Result<()> {
11138        if self.read_only {
11139            return Err(GraphError::ReadOnly);
11140        }
11141        // A snapshot rewrites `wal.bin` through a tmp+rename, so a peer that is
11142        // appending ends up holding a descriptor on an unlinked inode and loses
11143        // commits it believes durable. Snapshotting therefore requires the
11144        // cross-process write lock, exactly as appending does. Unlike the WAL
11145        // append path this does not go through `log_then_apply_with`, so both
11146        // guards are repeated here.
11147        if self.degraded {
11148            return Err(GraphError::Io(std::io::Error::other(
11149                "database degraded after group-commit fsync failure; reopen required",
11150            )));
11151        }
11152        if self.lock_denied {
11153            return Err(GraphError::Busy { holder: None });
11154        }
11155        // Capture whether snapshot.bin already existed BEFORE this snapshot write.
11156        // Used by the archive path's conservative genesis-chain check: if a prior
11157        // snapshot exists but wal.truncated does not, we cannot distinguish a
11158        // legacy store (may have been truncated in an older code version) from a
11159        // new store that only used keep_wal=true.  Conservative: refuse genesis in
11160        // both cases.  Must be sampled here, before the snapshot write below.
11161        let had_prior_snapshot = self.fs.snapshot_path().map(|p| p.exists()).unwrap_or(false);
11162        self.ensure_v8_base_sections_loaded();
11163        // Ensure provenance is decoded before to_persist() clones it.
11164        self.engine.ensure_provenance_loaded_mut();
11165        let (rule_defs_typed, provenance, rule_tripped, rule_fires) = self.engine.to_persist();
11166        let rule_defs = rule_defs_typed
11167            .iter()
11168            .map(|r| bincode::serialize(r).expect("RuleDef serialize cannot fail"))
11169            .collect();
11170        // Collect HNSW state and IVF state.  When indexes are not yet
11171        // populated (clean open, no mutation since open), pass the retained
11172        // raw bytes through directly so that migrate/snapshot does not
11173        // silently discard fitted approximate-rule indexes.
11174        let hnsw_state = self.engine.export_hnsw_state_passthrough();
11175        let ivf_bytes = if !self.engine.indexes_populated() {
11176            // Pass retained IVF bytes through unchanged (no re-encode).
11177            self.engine.retained_ivf_bytes_clone().unwrap_or_default()
11178        } else {
11179            // Indexes live: encode from current state.
11180            let raw_ivf = self.engine.export_ivf_state();
11181            let ivf_state_map: BTreeMap<String, core_storage::snapshot::PerRuleIvfState> = raw_ivf
11182                .into_iter()
11183                .map(|(name, ((sc, sa, sd), (dc, da, dd)))| {
11184                    (
11185                        name,
11186                        core_storage::snapshot::PerRuleIvfState {
11187                            src: core_storage::snapshot::SideIvfState {
11188                                centroids: sc,
11189                                clusters: sa,
11190                                drift: sd,
11191                            },
11192                            dst: core_storage::snapshot::SideIvfState {
11193                                centroids: dc,
11194                                clusters: da,
11195                                drift: dd,
11196                            },
11197                        },
11198                    )
11199                })
11200                .collect();
11201            if ivf_state_map.is_empty() {
11202                Vec::new()
11203            } else {
11204                bincode::serialize(&ivf_state_map).expect("IVF state serialize cannot fail")
11205            }
11206        };
11207        let view_defs: Vec<Vec<u8>> = self
11208            .view_store
11209            .views()
11210            .map(|v| bincode::serialize(v).expect("ViewDef serialize cannot fail"))
11211            .collect();
11212        if self.base.is_some() {
11213            // V8 merge-snapshot path: encode base+overlay into a new V8 snapshot,
11214            // write it atomically, remap it as the new base, then clear the overlay.
11215            let meta = V8Meta {
11216                labels: self.labels.clone(),
11217                edge_props: self.edge_props.clone(),
11218                rule_defs,
11219                provenance,
11220                rule_tripped,
11221                rule_fires,
11222                ivf_bytes,
11223                view_defs,
11224                wal_truncated: !opts.keep_wal,
11225                hnsw: hnsw_state,
11226                last_change: self.last_change.clone(),
11227            };
11228            let mut buf: Vec<u8> = Vec::new();
11229            {
11230                // Clone the Arc so the old base stays alive while we encode.
11231                // The borrow of archived_csr (into old_base's mmap) is released
11232                // at the end of this block, before we replace self.base.
11233                let old_base = self.base.clone().expect("is_some checked above");
11234                let archived_csr = old_base.topology().map_err(|e| GraphError::Corrupt {
11235                    detail: format!("v8 snapshot: topology section: {e:?}"),
11236                })?;
11237                let archived_cols = old_base.columns().map_err(|e| GraphError::Corrupt {
11238                    detail: format!("v8 snapshot: columns section: {e:?}"),
11239                })?;
11240                // `None` when the base predates V9 — the migration path: its
11241                // string columns still carry their own tables and this snapshot
11242                // is the rewrite that collapses them into section 12.
11243                let archived_strings =
11244                    old_base
11245                        .string_table()
11246                        .transpose()
11247                        .map_err(|e| GraphError::Corrupt {
11248                            detail: format!("v8 snapshot: strings section: {e:?}"),
11249                        })?;
11250                let archived_edge_props =
11251                    old_base
11252                        .edge_props_section()
11253                        .map_err(|e| GraphError::Corrupt {
11254                            detail: format!("v8 snapshot: edge_props section: {e:?}"),
11255                        })?;
11256                let edge_props_raw =
11257                    old_base
11258                        .edge_props_raw_bytes()
11259                        .map_err(|e| GraphError::Corrupt {
11260                            detail: format!("v8 snapshot: edge_props raw bytes: {e:?}"),
11261                        })?;
11262                let prov_raw =
11263                    old_base
11264                        .provenance_raw_bytes()
11265                        .map_err(|e| GraphError::Corrupt {
11266                            detail: format!("v8 snapshot: provenance raw bytes: {e:?}"),
11267                        })?;
11268                encode_v8(
11269                    Some(archived_csr),
11270                    Some(archived_cols),
11271                    archived_strings,
11272                    Some((archived_edge_props, edge_props_raw)),
11273                    Some(prov_raw),
11274                    &self.topo,
11275                    &self.props,
11276                    &self.ids,
11277                    &self.syms,
11278                    &meta,
11279                    &mut buf,
11280                )?;
11281            }
11282            self.fs.write_atomic(FileId::Snapshot, &buf)?;
11283            // Remap the freshly-written snapshot as the new base.
11284            // C2: use file mmap on RealFs; fall back to from_bytes on SimFs.
11285            let new_base = if let Some(snap_path) = self.fs.snapshot_path() {
11286                core_storage::v8::MappedBase::map(&snap_path)
11287            } else {
11288                core_storage::v8::MappedBase::from_bytes(buf)
11289            }
11290            .map_err(|e| GraphError::Corrupt {
11291                detail: format!("v8 snapshot: remap new base: {e:?}"),
11292            })?;
11293            self.base = Some(Arc::new(new_base));
11294            // Clear the overlay and prop tombstones — all data is now in the new base.
11295            self.topo = Topology::new();
11296            self.props = core_storage::columns::ColumnStore::new();
11297        } else {
11298            // Legacy path (V5–V7 stores without a V8 base).
11299            //
11300            // Memory-diet path: build V8Meta directly from &self — no SnapshotState
11301            // clone and no encode_v8_from_state intermediate clones.  The big
11302            // structures (self.topo, self.props) are borrowed, not cloned.
11303            // self.edge_props is moved (not cloned) because we immediately clear it
11304            // when we remap the new V8 snapshot as self.base (see below).
11305            //
11306            // Eliminates from peak RSS vs. the old SnapshotState path:
11307            //   • self.topo.clone()      (~topology HashMap footprint)
11308            //   • self.props.clone()     (~column-store footprint)
11309            //   • encode_v8_from_state V8Meta secondary clones (labels, edge_props, …)
11310            let meta = V8Meta {
11311                labels: self.labels.clone(),
11312                wal_truncated: !opts.keep_wal,
11313                // Move edge_props out so the large overlay is freed when meta
11314                // drops at end of this block (self.edge_props is now empty; reads
11315                // after base assignment go through the mmap'd base section).
11316                edge_props: std::mem::take(&mut self.edge_props),
11317                rule_defs,
11318                provenance,
11319                rule_tripped,
11320                rule_fires,
11321                ivf_bytes,
11322                view_defs,
11323                hnsw: hnsw_state,
11324                last_change: self.last_change.clone(),
11325            };
11326            let mut buf = Vec::new();
11327            encode_v8(
11328                None,
11329                None,
11330                None,
11331                None,
11332                None,
11333                &self.topo,
11334                &self.props,
11335                &self.ids,
11336                &self.syms,
11337                &meta,
11338                &mut buf,
11339            )?;
11340            // meta (and the moved edge_props inside it) is no longer needed;
11341            // drop it before the write to keep the peak window narrow.
11342            drop(meta);
11343            self.fs.write_atomic(FileId::Snapshot, &buf)?;
11344            // Remap the freshly-written V8 snapshot as self.base.
11345            // On RealFs: drop the encode buffer before mmap to recover ~1.9 GiB.
11346            // On SimFs (tests): pass buf to from_bytes.
11347            let new_base = if let Some(snap_path) = self.fs.snapshot_path() {
11348                drop(buf);
11349                core_storage::v8::MappedBase::map(&snap_path)
11350            } else {
11351                core_storage::v8::MappedBase::from_bytes(buf)
11352            }
11353            .map_err(|e| GraphError::Corrupt {
11354                detail: format!("v8 snapshot: remap new base (legacy path): {e:?}"),
11355            })?;
11356            self.base = Some(Arc::new(new_base));
11357            // Free the large heap-allocated decoded state — all data is now in the
11358            // mmap'd base.  Mirrors the V8 merge-snapshot path (see above).
11359            // self.edge_props was already moved into meta and is effectively empty.
11360            self.topo = Topology::new();
11361            self.props = core_storage::columns::ColumnStore::new();
11362        }
11363
11364        if opts.archive_wal {
11365            // History-preserving snapshot (Task 4):
11366            //   1. Snapshot already written above (write_atomic → fsynced).
11367            //   2. Rename WAL → wal.<commit_seq>.archive  (atomic, same fs).
11368            //      Crash window B: crash here leaves archive present, WAL
11369            //      absent.  Reopen: snapshot loaded (full state), no WAL
11370            //      replay.  Archive is NOT replayed into live state — it is
11371            //      pre-snapshot by construction.  Safe.
11372            //   3. Optionally write genesis marker (first archive only, no
11373            //      prior WAL truncation).
11374            //   4. Prune old archives (retention), update horizon floor.
11375            //      Pruning invalidates the genesis chain; delete marker.
11376            //   5. Write new minimal baseline WAL (write_atomic).
11377            //      Crash window C: crash here leaves new archive plus no live
11378            //      WAL.  Same as window B — handled above.
11379            //
11380            // Sample existing archives BEFORE the rename so we can detect
11381            // whether this is the first archive.
11382            let existing_archives = self.fs.list_archives()?;
11383            let is_first_archive = existing_archives.is_empty();
11384
11385            // Compute a globally-monotonic archive name: the name equals the
11386            // cumulative end-frame index of the archive in global commit space.
11387            //
11388            // Using `commit_seq` directly is UNSOUND across sessions: on reopen
11389            // commit_seq is seeded from max(last_change), which underestimates
11390            // the WAL depth when trailing commits (e.g. insert_edge) do not
11391            // update last_change.  A session-2 archive could then receive a name
11392            // ≤ the session-1 archive, causing incorrect sort order or collision.
11393            //
11394            // Instead: read and decode the live WAL here (before the rename) to
11395            // get its exact frame count, then add it to the last known global
11396            // end-frame index (the name of the most recent existing archive, or
11397            // wal_horizon_floor if no archives exist).  This is O(WAL size) but
11398            // snapshot is already serialising the full graph state, so the cost
11399            // is dominated.
11400            let live_wal_bytes_for_name = self.fs.read(FileId::Wal)?;
11401            let (live_frames_for_name, _) = decode_all(&live_wal_bytes_for_name);
11402            let archive_n = existing_archives
11403                .last()
11404                .copied()
11405                .unwrap_or(self.wal_horizon_floor)
11406                + live_frames_for_name.len() as u64;
11407            self.fs.archive_wal(archive_n)?;
11408
11409            // Genesis marker: written once when the first archive is taken
11410            // from a store that has never undergone a WAL-truncating snapshot.
11411            // When present, `open_at` may replay archive-resident commits from
11412            // empty state (the archive chain covers from global index 0).
11413            //
11414            // Two conditions must ALL hold:
11415            //   1. This is the first archive (existing_archives was empty).
11416            //   2. No snapshot.bin existed before this operation (had_prior_snapshot=false).
11417            //      A WAL-truncating snapshot (keep_wal=false) always writes snapshot.bin
11418            //      before truncating the WAL, so if any prior truncating snapshot was taken
11419            //      — even in a previous session — snapshot.bin is present and this condition
11420            //      is false.  This subsumes the cross-session truncation case without
11421            //      requiring a separate wal.truncated sidecar file.
11422            //      For legacy stores (snapshot.bin written by an older code version that
11423            //      may have truncated the WAL), the same conservative refusal applies:
11424            //      we cannot prove the chain is complete, so we refuse genesis (cost =
11425            //      no as-of-through-archives; never silent wrong data).
11426            //      On SimFs (snapshot_path() == None) had_prior_snapshot is always false,
11427            //      so SimFs always passes this check.
11428            if is_first_archive && !had_prior_snapshot {
11429                self.fs.write_genesis_marker()?;
11430                self.archive_genesis_chain = true;
11431            }
11432
11433            // Retention pruning: keep newest `keep` archives; delete oldest.
11434            // Pruning is the ONLY deletion site for archives.
11435            //
11436            // Crash-safety ordering (C1 fix):
11437            //   1. Count frames in surplus archives (reads only — no mutation).
11438            //   2. Advance and PERSIST the horizon floor FIRST via write-then-
11439            //      rename (atomic).  A crash after this point leaves orphaned
11440            //      archives on disk, but the floor is correct.  The opening
11441            //      cleanup sweep (`cleanup_orphaned_archives`) removes them on
11442            //      the next open, so the store is always safe to reopen.
11443            //   3. Delete the genesis marker (floor > 0 already blocks open_at
11444            //      via the conjunctive gate; marker cleanup is belt-and-suspenders).
11445            //   4. Delete surplus archives.  A crash between any two deletes
11446            //      leaves the floor committed and orphaned archives cleaned at
11447            //      next open — never a stale floor with a missing archive prefix.
11448            if let Some(keep) = self.wal_archive_retention {
11449                if keep > 0 {
11450                    let archives = self.fs.list_archives()?;
11451                    // archives is sorted ascending (oldest first)
11452                    if archives.len() as u32 > keep {
11453                        let surplus = archives.len() - keep as usize;
11454                        // Step 1: count pruned frames (reads, no mutation).
11455                        let mut pruned_frames = 0u64;
11456                        for &n in &archives[..surplus] {
11457                            let bytes = self.fs.read_archive(n)?;
11458                            let (frames, _) = decode_all(&bytes);
11459                            pruned_frames += frames.len() as u64;
11460                        }
11461                        // Step 2: advance and persist floor FIRST.
11462                        self.wal_horizon_floor += pruned_frames;
11463                        self.fs.write_horizon_floor(self.wal_horizon_floor)?;
11464                        // Step 3: delete genesis marker (floor > 0 already
11465                        // blocks open_at; this is belt-and-suspenders cleanup).
11466                        if pruned_frames > 0 && self.archive_genesis_chain {
11467                            self.fs.delete_genesis_marker()?;
11468                            self.archive_genesis_chain = false;
11469                        }
11470                        // Step 4: delete surplus archives.  Crash here →
11471                        // orphaned archives; cleaned at next open.
11472                        for &n in &archives[..surplus] {
11473                            self.fs.delete_archive(n)?;
11474                        }
11475                    }
11476                }
11477            }
11478
11479            // Write new minimal baseline WAL (mirrors the keep_wal=false path).
11480            let mut baseline_wal: Vec<u8> = Vec::new();
11481            for (label, field) in self.fulltext.enabled_pairs() {
11482                let rec = WalRecord::EnableFulltext {
11483                    label: label.clone(),
11484                    field: field.clone(),
11485                };
11486                baseline_wal.extend_from_slice(&encode_record(&rec));
11487            }
11488            for (label, field) in self.prop_index.enabled_pairs() {
11489                let rec = WalRecord::EnableIndex {
11490                    label: label.clone(),
11491                    field: field.clone(),
11492                };
11493                baseline_wal.extend_from_slice(&encode_record(&rec));
11494            }
11495            self.fs.write_atomic(FileId::Wal, &baseline_wal)?;
11496        } else if opts.keep_wal {
11497            // keep_wal=true: WAL is left untouched.  The existing WAL already
11498            // contains the EnableFulltext records from the original enable calls;
11499            // replay is idempotent (guards in apply() skip already-live entries).
11500            // No baseline re-write is needed or safe here — the full WAL history
11501            // must remain intact for open_at to reach pre-snapshot commits.
11502        } else {
11503            // keep_wal=false (default): truncate by replacing the WAL with a
11504            // minimal baseline of one EnableFulltext record per active pair.
11505            //
11506            // Crash-ordering: write_atomic is atomic.
11507            //   • Crash before snapshot write  → WAL unchanged.  Safe.
11508            //   • Crash after snapshot write but before this WAL write → full
11509            //     pre-snapshot WAL still present; open_with replays idempotently.
11510            //   • Crash after both writes → normal post-snapshot state.
11511            //
11512            // Genesis chain: a WAL-truncating snapshot breaks the archive chain
11513            // for any archives taken AFTER this point (their WAL slices would
11514            // not start at genesis).  Delete any existing genesis marker so that
11515            // open_at refuses archive-resident commits.  Future sessions are
11516            // covered by had_prior_snapshot: snapshot.bin written here persists
11517            // across sessions and prevents a later archiving session from
11518            // incorrectly claiming a complete genesis chain.
11519            if self.archive_genesis_chain {
11520                self.fs.delete_genesis_marker()?;
11521                self.archive_genesis_chain = false;
11522            }
11523            let mut baseline_wal: Vec<u8> = Vec::new();
11524            for (label, field) in self.fulltext.enabled_pairs() {
11525                let rec = WalRecord::EnableFulltext {
11526                    label: label.clone(),
11527                    field: field.clone(),
11528                };
11529                baseline_wal.extend_from_slice(&encode_record(&rec));
11530            }
11531            for (label, field) in self.prop_index.enabled_pairs() {
11532                let rec = WalRecord::EnableIndex {
11533                    label: label.clone(),
11534                    field: field.clone(),
11535                };
11536                baseline_wal.extend_from_slice(&encode_record(&rec));
11537            }
11538            self.fs.write_atomic(FileId::Wal, &baseline_wal)?;
11539        }
11540        // After snapshot the overlay may have changed (V8 merge path clears
11541        // self.topo and self.props). Refresh the MVCC fold so future readers
11542        // see the post-snapshot state rather than stale overlay data.
11543        self.fold_now();
11544        // We wrote the snapshot and (unless keep_wal) replaced the WAL, so both
11545        // markers this handle uses to detect other processes' work must be
11546        // re-taken from disk. Skipping this would make our own snapshot look
11547        // like a peer's on the next staleness check and force a needless
11548        // reload.
11549        self.wal_consumed = self.fs.wal_len().map_err(GraphError::Io)?;
11550        self.snapshot_ident = self.fs.snapshot_ident().map_err(GraphError::Io)?;
11551        Ok(())
11552    }
11553}
11554
11555/// Queued mutation for a [`BatchBuilder`] or [`GraphDb::commit_group`].
11556///
11557/// The `submit_batch` / `commit_group` APIs accept `Vec<BatchOp>` so that
11558/// callers can build a set of mutations without holding `&mut GraphDb` and
11559/// hand them off to the group-committing writer for durable, batched I/O.
11560pub enum BatchOp {
11561    InsertNode {
11562        label: String,
11563        key: String,
11564        props: Vec<(String, Value)>,
11565    },
11566    InsertEdge {
11567        edge_type: String,
11568        src_key: String,
11569        dst_key: String,
11570    },
11571    SetProp {
11572        key: String,
11573        field: String,
11574        value: Value,
11575    },
11576    RemoveProp {
11577        key: String,
11578        field: String,
11579    },
11580    DeleteEdge {
11581        edge_type: String,
11582        src_key: String,
11583        dst_key: String,
11584    },
11585    DeleteNode {
11586        key: String,
11587    },
11588    CreateRule(RuleDef),
11589    DeleteRule {
11590        name: String,
11591    },
11592    /// Rename a node's key. Validated: old must exist, new must not.
11593    RenameNode {
11594        old_key: String,
11595        new_key: String,
11596    },
11597    /// Insert an edge, auto-creating any missing endpoint as a plain node with
11598    /// `placeholder_label` and no props. Rules fire and last-change is updated
11599    /// for each created endpoint (normal InsertNode semantics in the batch frame).
11600    InsertEdgeUpsert {
11601        edge_type: String,
11602        src_key: String,
11603        dst_key: String,
11604        placeholder_label: String,
11605    },
11606}
11607
11608/// Three-way node visibility status used by `check_single_op_authz`.
11609enum NodeAuthzStatus {
11610    /// Node exists in the store and is in the role's read mask.
11611    Visible(String), // carries the node's label
11612    /// Node exists in the store but is NOT in the role's read mask.
11613    Hidden,
11614    /// Node does not exist in the store.
11615    Absent,
11616}
11617
11618/// Overlay of ops already accepted earlier in the same batch. Never written
11619/// back to the database — validation only.
11620#[derive(Default)]
11621struct Overlay {
11622    extra_keys: BTreeSet<String>,
11623    deleted_keys: BTreeSet<String>,
11624    extra_props: BTreeMap<(String, String), Value>,
11625    removed_props: BTreeSet<(String, String)>,
11626    extra_edges: BTreeSet<(String, String, String)>,
11627    deleted_edges: BTreeSet<(String, String, String)>,
11628    extra_rules: BTreeSet<String>,
11629    deleted_rules: BTreeSet<String>,
11630    /// `rule name → (via_edge, edge_type)` for every via-hop rule accepted
11631    /// earlier in this batch. Feeds the rule-chain cycle check, which otherwise
11632    /// sees only the rules already committed to the engine. Keyed by name so a
11633    /// later `DeleteRule` in the same batch drops the arc with the rule.
11634    extra_rule_arcs: BTreeMap<String, (String, String)>,
11635}
11636
11637/// Read-only view of live db state plus a batch overlay. Shared by single-op
11638/// public methods (empty overlay) and `commit_batch`.
11639struct MutPreview<'a, F: Fs> {
11640    db: &'a GraphDb<F>,
11641    overlay: Overlay,
11642}
11643
11644/// Shortest path from `start` to `target` following `arcs` (`from → to`), or
11645/// `None` if `target` is unreachable.
11646///
11647/// Used for rule-chain cycle detection, where an arc is "a rule hops over
11648/// `from` and writes `to`". Breadth-first over BTree-ordered adjacency, so the
11649/// reported path is stable for a given rule set, and iterative so a pathological
11650/// rule graph cannot overflow the stack.
11651fn find_cycle_through(arcs: &[(String, String)], start: &str, target: &str) -> Option<Vec<String>> {
11652    let mut adj: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
11653    for (from, to) in arcs {
11654        adj.entry(from.as_str()).or_default().insert(to.as_str());
11655    }
11656    let mut parent: BTreeMap<&str, &str> = BTreeMap::new();
11657    let mut visited: BTreeSet<&str> = BTreeSet::new();
11658    let mut queue: std::collections::VecDeque<&str> = std::collections::VecDeque::new();
11659    visited.insert(start);
11660    queue.push_back(start);
11661    while let Some(node) = queue.pop_front() {
11662        if node == target {
11663            let mut path = vec![node.to_string()];
11664            let mut cur = node;
11665            while let Some(&p) = parent.get(cur) {
11666                path.push(p.to_string());
11667                cur = p;
11668            }
11669            path.reverse();
11670            return Some(path);
11671        }
11672        for &next in adj.get(node).into_iter().flatten() {
11673            if visited.insert(next) {
11674                parent.insert(next, node);
11675                queue.push_back(next);
11676            }
11677        }
11678    }
11679    None
11680}
11681
11682impl<'a, F: Fs> MutPreview<'a, F> {
11683    fn new(db: &'a GraphDb<F>) -> Self {
11684        Self {
11685            db,
11686            overlay: Overlay::default(),
11687        }
11688    }
11689
11690    fn has_key(&self, key: &str) -> bool {
11691        if self.overlay.extra_keys.contains(key) {
11692            return true;
11693        }
11694        if self.overlay.deleted_keys.contains(key) {
11695            return false;
11696        }
11697        self.db.ids.get(key).is_some()
11698    }
11699
11700    fn has_prop(&self, key: &str, field: &str) -> bool {
11701        if !self.has_key(key) {
11702            return false;
11703        }
11704        let k = (key.to_string(), field.to_string());
11705        if self.overlay.removed_props.contains(&k) {
11706            return false;
11707        }
11708        if self.overlay.extra_props.contains_key(&k) {
11709            return true;
11710        }
11711        // Fresh identity (first insert in this batch, or delete+reinsert):
11712        // ignore props still sitting on the soon-to-be-tombstoned slot.
11713        if self.overlay.extra_keys.contains(key) {
11714            return false;
11715        }
11716        self.db.get_prop(key, field).is_some()
11717    }
11718
11719    fn has_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
11720        let k = (
11721            edge_type.to_string(),
11722            src_key.to_string(),
11723            dst_key.to_string(),
11724        );
11725        if self.overlay.deleted_edges.contains(&k) {
11726            return false;
11727        }
11728        if self.overlay.extra_edges.contains(&k) {
11729            return true;
11730        }
11731        // A key created in this batch (including reinsert) has no db edges.
11732        if self.overlay.extra_keys.contains(src_key) || self.overlay.extra_keys.contains(dst_key) {
11733            return false;
11734        }
11735        if self.overlay.deleted_keys.contains(src_key)
11736            || self.overlay.deleted_keys.contains(dst_key)
11737        {
11738            return false;
11739        }
11740        let Some(src) = self.db.ids.get(src_key) else {
11741            return false;
11742        };
11743        let Some(dst) = self.db.ids.get(dst_key) else {
11744            return false;
11745        };
11746        let Some(sym) = self.db.syms.get(edge_type) else {
11747            return false;
11748        };
11749        self.db
11750            .topo_view()
11751            .neighbors(sym, Direction::Out, src)
11752            .binary_search(&dst)
11753            .is_ok()
11754    }
11755
11756    fn has_rule(&self, name: &str) -> bool {
11757        if self.overlay.extra_rules.contains(name) {
11758            return true;
11759        }
11760        if self.overlay.deleted_rules.contains(name) {
11761            return false;
11762        }
11763        self.db.engine.rules().any(|r| r.name == name)
11764    }
11765
11766    fn is_rule_owned(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
11767        if self.overlay.extra_keys.contains(src_key) || self.overlay.extra_keys.contains(dst_key) {
11768            return false;
11769        }
11770        if self.overlay.deleted_keys.contains(src_key)
11771            || self.overlay.deleted_keys.contains(dst_key)
11772        {
11773            return false;
11774        }
11775        let Some(src) = self.db.ids.get(src_key) else {
11776            return false;
11777        };
11778        let Some(dst) = self.db.ids.get(dst_key) else {
11779            return false;
11780        };
11781        let Some(et) = self.db.syms.get(edge_type) else {
11782            return false;
11783        };
11784        // extra_rules is deliberately not consulted: a CreateRule earlier in
11785        // this batch has not fired, so it contributes no provenance. That is
11786        // the documented rule-window gap (see GraphDb::batch).
11787        if self.overlay.deleted_rules.is_empty() {
11788            return self.db.engine.is_owned(et, src, dst);
11789        }
11790        for (rule, triples) in self.db.engine.provenance() {
11791            if self.overlay.deleted_rules.contains(rule) {
11792                continue;
11793            }
11794            if triples.contains(&(et, src, dst)) {
11795                return true;
11796            }
11797        }
11798        false
11799    }
11800
11801    fn check_insert_node(&self, key: &str) -> Result<()> {
11802        if self.has_key(key) {
11803            Err(GraphError::DuplicateKey { key: key.into() })
11804        } else {
11805            Ok(())
11806        }
11807    }
11808
11809    fn check_live_key(&self, key: &str) -> Result<()> {
11810        if self.has_key(key) {
11811            Ok(())
11812        } else {
11813            Err(GraphError::KeyNotFound { key: key.into() })
11814        }
11815    }
11816
11817    fn prepare_insert_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
11818        for k in [src_key, dst_key] {
11819            if !self.has_key(k) {
11820                return Err(GraphError::KeyNotFound { key: k.into() });
11821            }
11822        }
11823        if self.is_rule_owned(edge_type, src_key, dst_key) {
11824            return Err(GraphError::RuleOwned {
11825                detail: format!("edge {edge_type} {src_key}→{dst_key} is rule-owned"),
11826            });
11827        }
11828        // A user-written edge stays inside one namespace. Derived edges do not
11829        // come through here — the engine adds them directly — and the rule
11830        // scoping check is what keeps those pure.
11831        let src_ns = self.namespace_in_batch(src_key);
11832        let dst_ns = self.namespace_in_batch(dst_key);
11833        if src_ns != dst_ns {
11834            return Err(GraphError::CrossNamespace {
11835                src: src_key.to_string(),
11836                src_ns,
11837                dst: dst_key.to_string(),
11838                dst_ns,
11839            });
11840        }
11841        Ok(!self.has_edge(edge_type, src_key, dst_key))
11842    }
11843
11844    fn prepare_remove_prop(&self, key: &str, field: &str) -> Result<bool> {
11845        self.check_live_key(key)?;
11846        // Removing `ns` is changing the namespace — to `default`, the namespace
11847        // an absent property names. It goes through this one choke-point and NOT
11848        // through `rewrite_wal_dense` (a `RemoveProp` needs no dense rewrite), so
11849        // the immutability rule has to be stated here as well. Without it the
11850        // node silently lands in `default` on the next open: the cross-namespace
11851        // edge guard is defeated and a default-bound role reads a tenant's node.
11852        if field == NS_PROP {
11853            let from = self.namespace_in_batch(key);
11854            if from != NS_DEFAULT {
11855                return Err(GraphError::NamespaceImmutable {
11856                    key: key.to_string(),
11857                    from,
11858                    to: NS_DEFAULT.to_string(),
11859                });
11860            }
11861            // Already in `default`: the removal changes no namespace. It is the
11862            // no-op `set_prop` to the current namespace is, not an error.
11863            return Ok(false);
11864        }
11865        Ok(self.has_prop(key, field))
11866    }
11867
11868    fn prepare_delete_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
11869        for k in [src_key, dst_key] {
11870            if !self.has_key(k) {
11871                return Err(GraphError::KeyNotFound { key: k.into() });
11872            }
11873        }
11874        // Provenance-owned OR a live rule would derive this pair. User-first
11875        // edges that a later rule matches are not in `owned`, but deleting
11876        // them would leave a hole `rebuild_rule` immediately fills.
11877        if self.is_rule_owned(edge_type, src_key, dst_key) {
11878            return Err(GraphError::RuleOwned {
11879                detail: format!(
11880                    "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
11881                     delete or change the owning rule"
11882                ),
11883            });
11884        }
11885        if self.would_derive(edge_type, src_key, dst_key) {
11886            return Err(GraphError::RuleOwned {
11887                detail: format!(
11888                    "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
11889                     delete or change the owning rule, or a live rule would re-derive it"
11890                ),
11891            });
11892        }
11893        Ok(self.has_edge(edge_type, src_key, dst_key))
11894    }
11895
11896    /// True if any live rule (minus overlay-deleted names) would derive
11897    /// `(edge_type, src, dst)` from current overlay-visible props/labels.
11898    /// CreateRule names in `extra_rules` are ignored — same documented
11899    /// same-batch rule-window as [`Self::is_rule_owned`].
11900    fn would_derive(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
11901        if src_key == dst_key {
11902            return false;
11903        }
11904        let Some(src_label) = self.label_of(src_key) else {
11905            return false;
11906        };
11907        let Some(dst_label) = self.label_of(dst_key) else {
11908            return false;
11909        };
11910        for rule in self.db.engine.rules() {
11911            if self.overlay.deleted_rules.contains(&rule.name) {
11912                continue;
11913            }
11914            if rule.edge_type != edge_type {
11915                continue;
11916            }
11917            if rule.src_label != src_label || rule.dst_label != dst_label {
11918                continue;
11919            }
11920            let src_props = |f: &str| self.prop_value(src_key, f);
11921            let dst_props = |f: &str| self.prop_value(dst_key, f);
11922            let src_view = NodeView {
11923                key: src_key,
11924                props: &src_props,
11925            };
11926            let dst_view = NodeView {
11927                key: dst_key,
11928                props: &dst_props,
11929            };
11930            if evaluate(&rule.predicate, &src_view, &dst_view).is_some() {
11931                return true;
11932            }
11933        }
11934        false
11935    }
11936
11937    fn label_of(&self, key: &str) -> Option<String> {
11938        if self.overlay.deleted_keys.contains(key) {
11939            return None;
11940        }
11941        // Fresh identities created in this batch have no stored label in the
11942        // overlay; they cannot be provenance-owned yet either.
11943        let id = self.db.ids.get(key)?;
11944        let sym = self.db.labels.get(id as usize).copied()?;
11945        if sym == u32::MAX {
11946            return None;
11947        }
11948        self.db.syms.resolve(sym).map(str::to_string)
11949    }
11950
11951    /// The namespace `key` is in as this batch sees it — including a node
11952    /// inserted earlier in the same batch, which the store does not have yet.
11953    fn namespace_in_batch(&self, key: &str) -> String {
11954        namespace_of_value(self.prop_value(key, NS_PROP).as_ref()).to_string()
11955    }
11956
11957    fn prop_value(&self, key: &str, field: &str) -> Option<Value> {
11958        if !self.has_key(key) {
11959            return None;
11960        }
11961        let k = (key.to_string(), field.to_string());
11962        if self.overlay.removed_props.contains(&k) {
11963            return None;
11964        }
11965        if let Some(v) = self.overlay.extra_props.get(&k) {
11966            return Some(v.clone());
11967        }
11968        if self.overlay.extra_keys.contains(key) {
11969            return None;
11970        }
11971        self.db.get_prop(key, field)
11972    }
11973
11974    fn check_create_rule(&self, def: &RuleDef) -> Result<()> {
11975        def.validate()
11976            .map_err(|e| GraphError::RuleInvalid { detail: e })?;
11977        if self.has_rule(&def.name) {
11978            return Err(GraphError::RuleInvalid {
11979                detail: format!("rule {:?} already exists", def.name),
11980            });
11981        }
11982        // Rule-chain cycle rejection. Derived edges feed via-hop rules, so a
11983        // rule set forms a graph whose arcs are "hops over `via_edge`, writes
11984        // `edge_type`". A cycle in that graph is a rule set that would re-fire
11985        // itself forever; the engine's depth cap would silently truncate it
11986        // instead, leaving an arbitrary partial result. Reject it here, the one
11987        // place that sees the whole rule set.
11988        //
11989        // Rules accepted earlier in the same batch count too: the overlay
11990        // carries their arcs, so a cycle cannot be assembled one op at a time.
11991        if let Some(via) = def.via_edge.as_deref() {
11992            if via == def.edge_type {
11993                return Err(GraphError::RuleInvalid {
11994                    detail: format!("rule chain cycle: {} -> {}", via, def.edge_type),
11995                });
11996            }
11997            let mut arcs: Vec<(String, String)> = self
11998                .db
11999                .engine
12000                .rules()
12001                .filter(|r| !self.overlay.deleted_rules.contains(&r.name))
12002                .filter_map(|r| r.via_edge.clone().map(|v| (v, r.edge_type.clone())))
12003                .collect();
12004            arcs.extend(self.overlay.extra_rule_arcs.values().cloned());
12005            arcs.push((via.to_string(), def.edge_type.clone()));
12006            if let Some(path) = find_cycle_through(&arcs, &def.edge_type, via) {
12007                return Err(GraphError::RuleInvalid {
12008                    detail: format!("rule chain cycle: {} -> {}", via, path.join(" -> ")),
12009                });
12010            }
12011        }
12012        Ok(())
12013    }
12014
12015    fn check_delete_rule(&self, name: &str) -> Result<()> {
12016        if self.has_rule(name) {
12017            Ok(())
12018        } else {
12019            Err(GraphError::RuleNotFound { name: name.into() })
12020        }
12021    }
12022
12023    fn note_insert_node(&mut self, key: &str, props: &[(String, Value)]) {
12024        self.overlay.deleted_keys.remove(key);
12025        self.overlay.extra_keys.insert(key.to_string());
12026        self.overlay.extra_props.retain(|(k, _), _| k != key);
12027        self.overlay.removed_props.retain(|(k, _)| k != key);
12028        for (field, value) in props {
12029            self.overlay
12030                .extra_props
12031                .insert((key.to_string(), field.clone()), value.clone());
12032        }
12033    }
12034
12035    fn note_insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) {
12036        let k = (
12037            edge_type.to_string(),
12038            src_key.to_string(),
12039            dst_key.to_string(),
12040        );
12041        self.overlay.deleted_edges.remove(&k);
12042        self.overlay.extra_edges.insert(k);
12043    }
12044
12045    fn note_set_prop(&mut self, key: &str, field: &str, value: &Value) {
12046        let k = (key.to_string(), field.to_string());
12047        self.overlay.removed_props.remove(&k);
12048        self.overlay.extra_props.insert(k, value.clone());
12049    }
12050
12051    fn note_remove_prop(&mut self, key: &str, field: &str) {
12052        let k = (key.to_string(), field.to_string());
12053        self.overlay.extra_props.remove(&k);
12054        self.overlay.removed_props.insert(k);
12055    }
12056
12057    fn note_delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) {
12058        let k = (
12059            edge_type.to_string(),
12060            src_key.to_string(),
12061            dst_key.to_string(),
12062        );
12063        self.overlay.extra_edges.remove(&k);
12064        self.overlay.deleted_edges.insert(k);
12065    }
12066
12067    fn note_delete_node(&mut self, key: &str) {
12068        self.overlay.extra_keys.remove(key);
12069        self.overlay.deleted_keys.insert(key.to_string());
12070        self.overlay.extra_props.retain(|(k, _), _| k != key);
12071        self.overlay.removed_props.retain(|(k, _)| k != key);
12072        self.overlay
12073            .extra_edges
12074            .retain(|(_, s, d)| s != key && d != key);
12075        self.overlay
12076            .deleted_edges
12077            .retain(|(_, s, d)| s != key && d != key);
12078    }
12079
12080    fn note_create_rule(&mut self, def: &RuleDef) {
12081        self.overlay.deleted_rules.remove(&def.name);
12082        self.overlay.extra_rules.insert(def.name.clone());
12083        // Rules accepted earlier in this batch are not in the engine yet, so
12084        // the cycle check would not see their arcs. Keep the arc, not just the
12085        // name, so a batch cannot smuggle in a cycle one op at a time.
12086        if let Some(via) = def.via_edge.clone() {
12087            self.overlay
12088                .extra_rule_arcs
12089                .insert(def.name.clone(), (via, def.edge_type.clone()));
12090        }
12091    }
12092
12093    fn check_rename_node(&self, old: &str, new: &str) -> Result<()> {
12094        if !self.has_key(old) {
12095            return Err(GraphError::KeyNotFound { key: old.into() });
12096        }
12097        if self.has_key(new) {
12098            return Err(GraphError::DuplicateKey { key: new.into() });
12099        }
12100        Ok(())
12101    }
12102
12103    fn note_rename_node(&mut self, old: &str, new: &str) {
12104        // Mark old as deleted so subsequent batch ops cannot reference it.
12105        self.overlay.extra_keys.remove(old);
12106        self.overlay.deleted_keys.insert(old.to_string());
12107        // Mark new as extra so subsequent batch ops can reference it.
12108        self.overlay.deleted_keys.remove(new);
12109        self.overlay.extra_keys.insert(new.to_string());
12110        // Migrate any overlay props from old key to new key.
12111        let new_str = new.to_string();
12112        let transferred: Vec<((String, String), Value)> = self
12113            .overlay
12114            .extra_props
12115            .iter()
12116            .filter(|((k, _), _)| k.as_str() == old)
12117            .map(|((_, f), v)| ((new_str.clone(), f.clone()), v.clone()))
12118            .collect();
12119        self.overlay
12120            .extra_props
12121            .retain(|(k, _), _| k.as_str() != old);
12122        for (k, v) in transferred {
12123            self.overlay.extra_props.insert(k, v);
12124        }
12125        // Migrate removed_props.
12126        let transferred_removed: Vec<(String, String)> = self
12127            .overlay
12128            .removed_props
12129            .iter()
12130            .filter(|(k, _)| k.as_str() == old)
12131            .map(|(_, f)| (new_str.clone(), f.clone()))
12132            .collect();
12133        self.overlay
12134            .removed_props
12135            .retain(|(k, _)| k.as_str() != old);
12136        for k in transferred_removed {
12137            self.overlay.removed_props.insert(k);
12138        }
12139    }
12140
12141    fn note_delete_rule(&mut self, name: &str) {
12142        self.overlay.extra_rules.remove(name);
12143        // Drop its chain arc too: a rule created and then deleted in the same
12144        // batch must not make a later, legal rule look like a cycle.
12145        self.overlay.extra_rule_arcs.remove(name);
12146        self.overlay.deleted_rules.insert(name.to_string());
12147        // Treat the deleted rule's current provenance as gone so a later
12148        // delete_edge of those triples is a no-op (matches sequential).
12149        if let Some(triples) = self.db.engine.provenance().get(name) {
12150            for &(et, s, d) in triples {
12151                let Some(etype) = self.db.syms.resolve(et) else {
12152                    continue;
12153                };
12154                let Some(src) = self.db.ids.key_of(s) else {
12155                    continue;
12156                };
12157                let Some(dst) = self.db.ids.key_of(d) else {
12158                    continue;
12159                };
12160                let k = (etype.to_string(), src.to_string(), dst.to_string());
12161                self.overlay.extra_edges.remove(&k);
12162                self.overlay.deleted_edges.insert(k);
12163            }
12164        }
12165    }
12166}
12167
12168/// Collects mutations and commits them as one WAL `Batch` frame.
12169///
12170/// Holds `&mut GraphDb` for its lifetime. Queue with the same method names
12171/// as [`GraphDb`]; call [`commit`](Self::commit) to validate, log, and apply.
12172/// See [`GraphDb::batch`] for validation and atomicity rules.
12173pub struct BatchBuilder<'a, F: Fs> {
12174    db: &'a mut GraphDb<F>,
12175    ops: Vec<BatchOp>,
12176}
12177
12178impl<'a, F: Fs> BatchBuilder<'a, F> {
12179    pub fn insert_node(
12180        &mut self,
12181        label: &str,
12182        key: &str,
12183        props: Vec<(String, Value)>,
12184    ) -> &mut Self {
12185        self.ops.push(BatchOp::InsertNode {
12186            label: label.into(),
12187            key: key.into(),
12188            props,
12189        });
12190        self
12191    }
12192
12193    pub fn insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> &mut Self {
12194        self.ops.push(BatchOp::InsertEdge {
12195            edge_type: edge_type.into(),
12196            src_key: src_key.into(),
12197            dst_key: dst_key.into(),
12198        });
12199        self
12200    }
12201
12202    pub fn set_prop(&mut self, key: &str, field: &str, value: Value) -> &mut Self {
12203        self.ops.push(BatchOp::SetProp {
12204            key: key.into(),
12205            field: field.into(),
12206            value,
12207        });
12208        self
12209    }
12210
12211    pub fn remove_prop(&mut self, key: &str, field: &str) -> &mut Self {
12212        self.ops.push(BatchOp::RemoveProp {
12213            key: key.into(),
12214            field: field.into(),
12215        });
12216        self
12217    }
12218
12219    pub fn delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> &mut Self {
12220        self.ops.push(BatchOp::DeleteEdge {
12221            edge_type: edge_type.into(),
12222            src_key: src_key.into(),
12223            dst_key: dst_key.into(),
12224        });
12225        self
12226    }
12227
12228    pub fn delete_node(&mut self, key: &str) -> &mut Self {
12229        self.ops.push(BatchOp::DeleteNode { key: key.into() });
12230        self
12231    }
12232
12233    pub fn create_rule(&mut self, def: RuleDef) -> &mut Self {
12234        self.ops.push(BatchOp::CreateRule(def));
12235        self
12236    }
12237
12238    pub fn delete_rule(&mut self, name: &str) -> &mut Self {
12239        self.ops.push(BatchOp::DeleteRule { name: name.into() });
12240        self
12241    }
12242
12243    /// Queue a node-rename in this batch.
12244    ///
12245    /// Validation (old exists, new not taken) runs at commit time.
12246    pub fn rename_node(&mut self, old_key: &str, new_key: &str) -> &mut Self {
12247        self.ops.push(BatchOp::RenameNode {
12248            old_key: old_key.into(),
12249            new_key: new_key.into(),
12250        });
12251        self
12252    }
12253
12254    /// Queue an edge insert with endpoint auto-creation.
12255    ///
12256    /// Any missing endpoint is created as a plain node `{key, label:
12257    /// placeholder_label, no props}` inside this batch frame. Rules fire and
12258    /// last-change is updated for each auto-created node.
12259    pub fn insert_edge_upsert(
12260        &mut self,
12261        edge_type: &str,
12262        src_key: &str,
12263        dst_key: &str,
12264        placeholder_label: &str,
12265    ) -> &mut Self {
12266        self.ops.push(BatchOp::InsertEdgeUpsert {
12267            edge_type: edge_type.into(),
12268            src_key: src_key.into(),
12269            dst_key: dst_key.into(),
12270            placeholder_label: placeholder_label.into(),
12271        });
12272        self
12273    }
12274
12275    /// Validate every queued op, then log one `Batch` frame and apply.
12276    /// Empty / all-noop batches return `Ok(())` without writing the WAL.
12277    /// A second `commit()` after a successful one is an empty-batch no-op
12278    /// (queued ops were taken).
12279    /// Takes `&mut self` so it chains after the queue methods (`b.insert_node(..).commit()`)
12280    /// and also works as `let mut b = db.batch(); b.insert_node(..); b.commit()`.
12281    ///
12282    /// **Rule-window limitation:** batch validation cannot see edges that a
12283    /// rule created earlier in the *same* batch will derive at apply time, so
12284    /// a `delete_edge` / `insert_edge` in that window is silently no-oped
12285    /// where sequential calls would return `Err(RuleOwned)`. State integrity
12286    /// is unaffected (idempotent apply, provenance intact). Create rules in
12287    /// their own batch, or sequentially, when later ops may touch derived
12288    /// edges.
12289    /// Validate every queued op and commit atomically.
12290    ///
12291    /// Returns `(nodes_inserted, edges_inserted)` — the counts of node and edge
12292    /// WAL records actually written (duplicate edges are silent no-ops and are
12293    /// NOT counted). Both are 0 when the batch is empty or all-noop.
12294    pub fn commit(&mut self) -> Result<(usize, usize)> {
12295        let ops = std::mem::take(&mut self.ops);
12296        self.db.commit_batch(ops)
12297    }
12298
12299    /// Same as [`commit`](Self::commit) but tail the inner events with
12300    /// [`MutationEvent::Ingested`] instead of [`MutationEvent::BatchApplied`].
12301    pub(crate) fn commit_ingest(&mut self, label: &str, inserted: usize) -> Result<(usize, usize)> {
12302        let ops = std::mem::take(&mut self.ops);
12303        self.db
12304            .commit_logged_batch(ops, Some((label.to_string(), inserted)), None)
12305    }
12306}
12307
12308pub struct NodeRef<'a, F: Fs> {
12309    db: &'a GraphDb<F>,
12310    id: u32,
12311}
12312
12313impl<'a, F: Fs> NodeRef<'a, F> {
12314    pub fn key(&self) -> &str {
12315        self.db.ids.key_of(self.id).expect("dense ids")
12316    }
12317
12318    pub fn label(&self) -> &str {
12319        let sym = self
12320            .db
12321            .labels
12322            .get(self.id as usize)
12323            .copied()
12324            .filter(|&s| s != u32::MAX)
12325            .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
12326        self.db.syms.resolve(sym).expect("interned label symbol")
12327    }
12328
12329    pub fn prop(&self, field: &str) -> Option<Value> {
12330        self.db
12331            .props_view()
12332            .get(self.id, field)
12333            .map(|vr| vr.into_value())
12334    }
12335
12336    /// All stored fields for this node, sorted by field name.
12337    ///
12338    /// Reads from the full base+overlay view so that props stored only in the
12339    /// V8 snapshot base (i.e. before any post-snapshot WAL writes) are visible.
12340    pub fn props(&self) -> BTreeMap<String, Value> {
12341        let mut out = BTreeMap::new();
12342        let pv = self.db.props_view();
12343        for field in pv.field_names() {
12344            if let Some(vr) = pv.get(self.id, &field) {
12345                out.insert(field, vr.into_value());
12346            }
12347        }
12348        out
12349    }
12350
12351    /// depth-N BFS as a ResultSet: columns ["key","label","depth"], BFS order.
12352    pub fn neighborhood(&self, depth: u32, edge_types: Option<&[&str]>, dir: Dir) -> ResultSet {
12353        let view = self.db.view();
12354        let resolved: Option<Vec<u32>> = edge_types.map(|names| {
12355            names
12356                .iter()
12357                .filter_map(|name| view.syms.get(name))
12358                .collect()
12359        });
12360        let nb = neighborhood(&view, self.id, depth, resolved.as_deref(), dir);
12361        let mut rs = ResultSet::new(vec!["key".into(), "label".into(), "depth".into()]);
12362        for (nid, d) in nb.nodes {
12363            let key = view.key_of(nid);
12364            let label = view
12365                .label_of(nid)
12366                .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
12367            rs.push_row(vec![
12368                Some(Value::Str(key.to_string())),
12369                Some(Value::Str(label.to_string())),
12370                Some(Value::Int(d as i64)),
12371            ]);
12372        }
12373        rs
12374    }
12375
12376    /// 1-hop, Both directions: edge-type name → sorted unique neighbor keys.
12377    pub fn grouped_by_edge_type(&self) -> BTreeMap<String, Vec<String>> {
12378        let view = self.db.view();
12379        let mut groups: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
12380        for e in expand(&view, self.id, None, Dir::Both) {
12381            // Skip edges with unknown etypes (only possible from corrupt large
12382            // TOPOLOGY section; function returns BTreeMap not Result).
12383            let Some(etype) = view.syms.resolve(e.etype) else {
12384                continue;
12385            };
12386            let etype = etype.to_string();
12387            let nbr = if e.src == self.id { e.dst } else { e.src };
12388            groups
12389                .entry(etype)
12390                .or_default()
12391                .insert(view.key_of(nbr).to_string());
12392        }
12393        groups
12394            .into_iter()
12395            .map(|(k, v)| (k, v.into_iter().collect()))
12396            .collect()
12397    }
12398}
12399
12400#[cfg(test)]
12401mod tests {
12402    use super::*;
12403    use core_rules::Predicate;
12404
12405    fn tmp_dir(name: &str) -> std::path::PathBuf {
12406        let d =
12407            std::env::temp_dir().join(format!("graphdb-db-unit-{}-{}", name, std::process::id()));
12408        let _ = std::fs::remove_dir_all(&d);
12409        d
12410    }
12411
12412    fn fk_rule() -> RuleDef {
12413        RuleDef {
12414            name: "works_at".into(),
12415            src_label: "Person".into(),
12416            dst_label: "Org".into(),
12417            predicate: Predicate::KeyMatch {
12418                field: "org_id".into(),
12419            },
12420            edge_type: "WORKS_AT".into(),
12421            weight_prop: None,
12422            max_edges: None,
12423            approximate: false,
12424            via_label: None,
12425            via_edge: None,
12426            via_dir: None,
12427            namespace: None,
12428        }
12429    }
12430
12431    /// Regression guard for the no-views delta-copy fast path.
12432    ///
12433    /// When no views are defined, `pending_deltas_since().to_vec()` must never
12434    /// be called — even during a large CreateRule backfill. The DELTA_COPY_COUNT
12435    /// thread-local is incremented inside every `if !view_store.is_empty()` block;
12436    /// a count of 0 after the entire sequence proves the guard fires correctly.
12437    #[test]
12438    fn no_delta_copy_when_no_views() {
12439        DELTA_COPY_COUNT.with(|c| c.set(0));
12440        let dir = tmp_dir("no-delta-copy");
12441        {
12442            let mut db = GraphDb::open(&dir).unwrap();
12443            // Insert 50 Org + 50 Person nodes with FK links.
12444            for i in 0..50u32 {
12445                db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
12446            }
12447            for i in 0..50u32 {
12448                db.insert_node(
12449                    "Person",
12450                    &format!("p{i}"),
12451                    vec![("org_id".into(), Value::Str(format!("o{i}")))],
12452                )
12453                .unwrap();
12454            }
12455            // CreateRule backfill should NOT invoke to_vec() when no views are defined.
12456            db.create_rule(fk_rule()).unwrap();
12457
12458            // Counter must stay 0 — no views, no copies.
12459            let copies = DELTA_COPY_COUNT.with(|c| c.get());
12460            assert_eq!(
12461                copies, 0,
12462                "pending_deltas_since().to_vec() called despite no views"
12463            );
12464
12465            // Derived edges must still be correct (the guard skips only the
12466            // empty delta propagation loop, not the rule application itself).
12467            let nbrs = db.neighbors("p0", "WORKS_AT", Direction::Out).unwrap();
12468            assert_eq!(
12469                nbrs,
12470                vec!["o0"],
12471                "rule must derive edges even with no views"
12472            );
12473        }
12474        let _ = std::fs::remove_dir_all(&dir);
12475    }
12476
12477    /// Gating regression: subscribe AFTER a backfill must see no stale events.
12478    /// subscribe BEFORE a backfill must see every edge-fire event.
12479    #[test]
12480    fn subscribe_after_backfill_no_stale_events() {
12481        let dir = tmp_dir("sub-after-backfill");
12482        {
12483            let mut db = GraphDb::open(&dir).unwrap();
12484            for i in 0..10u32 {
12485                db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
12486                db.insert_node(
12487                    "Person",
12488                    &format!("p{i}"),
12489                    vec![("org_id".into(), Value::Str(format!("o{i}")))],
12490                )
12491                .unwrap();
12492            }
12493            // Create rule BEFORE subscribing — emit_deltas is false during backfill.
12494            db.create_rule(fk_rule()).unwrap();
12495
12496            // Subscribe AFTER the backfill — queue must be empty (no stale events).
12497            let sub = db.subscribe_all_rules().unwrap();
12498            // No events should have queued for the prior backfill.
12499            assert!(
12500                sub.try_recv().is_none(),
12501                "subscribe after backfill must see no stale events"
12502            );
12503
12504            // Inserting a new node now should fire an event (emit_deltas is now true).
12505            db.insert_node("Org", "o_new", vec![]).unwrap();
12506            db.insert_node(
12507                "Person",
12508                "p_new",
12509                vec![("org_id".into(), Value::Str("o_new".into()))],
12510            )
12511            .unwrap();
12512            let ev = sub.recv_timeout(std::time::Duration::from_millis(200));
12513            assert!(
12514                ev.is_some(),
12515                "edge-fire event must arrive after subscribe (emit_deltas=true)"
12516            );
12517        }
12518        let _ = std::fs::remove_dir_all(&dir);
12519    }
12520
12521    /// Gating regression: subscribe BEFORE a backfill → events flow.
12522    #[test]
12523    fn subscribe_before_backfill_events_flow() {
12524        let dir = tmp_dir("sub-before-backfill");
12525        {
12526            let mut db = GraphDb::open(&dir).unwrap();
12527            // Subscribe FIRST — emit_deltas becomes true.
12528            let sub = db.subscribe_all_rules().unwrap();
12529
12530            for i in 0..5u32 {
12531                db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
12532                db.insert_node(
12533                    "Person",
12534                    &format!("p{i}"),
12535                    vec![("org_id".into(), Value::Str(format!("o{i}")))],
12536                )
12537                .unwrap();
12538            }
12539            // Backfill fires with emit_deltas=true → events queued.
12540            db.create_rule(fk_rule()).unwrap();
12541
12542            // Should receive at least one edge-fired event from the backfill.
12543            let mut received = 0usize;
12544            while sub.try_recv().is_some() {
12545                received += 1;
12546            }
12547            assert!(
12548                received > 0,
12549                "subscribe before backfill must receive edge-fire events (got 0)"
12550            );
12551        }
12552        let _ = std::fs::remove_dir_all(&dir);
12553    }
12554
12555    /// Companion: when a view IS defined, the delta path fires and view values update.
12556    #[test]
12557    fn delta_copy_fires_when_view_exists() {
12558        use core_rules::ViewSource;
12559        DELTA_COPY_COUNT.with(|c| c.set(0));
12560        let dir = tmp_dir("delta-copy-with-view");
12561        {
12562            let mut db = GraphDb::open(&dir).unwrap();
12563            db.insert_node("Org", "o1", vec![]).unwrap();
12564            db.insert_node(
12565                "Person",
12566                "p1",
12567                vec![("org_id".into(), Value::Str("o1".into()))],
12568            )
12569            .unwrap();
12570            // Declare a Degree view so is_empty() returns false.
12571            db.create_view(ViewDef {
12572                name: "degree_out".into(),
12573                label: "Person".into(),
12574                view_prop: "degree_out".into(),
12575                source: ViewSource::Degree {
12576                    edge_type: "WORKS_AT".into(),
12577                    direction: Direction::Out,
12578                },
12579            })
12580            .unwrap();
12581            db.create_rule(fk_rule()).unwrap();
12582
12583            // At least one delta copy should have happened (CreateRule backfill).
12584            let copies = DELTA_COPY_COUNT.with(|c| c.get());
12585            assert!(
12586                copies > 0,
12587                "expected delta copy to fire when a view is defined"
12588            );
12589
12590            // View value should be computed: p1 has one WORKS_AT out-edge.
12591            let info = db.node_info("p1").unwrap();
12592            let degree = info.props.get("degree_out");
12593            assert!(
12594                degree.is_some(),
12595                "view prop should be written to node props"
12596            );
12597        }
12598        let _ = std::fs::remove_dir_all(&dir);
12599    }
12600
12601    /// Regression: `open_at_with` must call `rebuild_all` after WAL replay so
12602    /// derived-edge-driven view values reflect the as-of state rather than just
12603    /// the initial backfill written at `CreateView` time.
12604    ///
12605    /// Base WAL frames (indices 0..=5 before history markers):
12606    ///   0: insert Org "o1"
12607    ///   1: create_view "employee_count" (Degree / WORKS_AT / In) on Org
12608    ///   2: create_rule fk_rule (WORKS_AT, Person→Org via org_id)
12609    ///   3: insert Person "p1" → rule fires WORKS_AT p1→o1 (degree = 1)  ← mid
12610    ///   4: insert Person "p2" → rule fires WORKS_AT p2→o1 (degree = 2)
12611    ///   5: insert Person "p3" → rule fires WORKS_AT p3→o1 (degree = 3)  ← latest
12612    ///
12613    /// Each rule-fire also appends a DerivedEdgeAdded history-marker frame (state
12614    /// no-op), so the total commit count is higher than the base frame count.
12615    /// The "latest" open_at commit is computed dynamically via `wal_commit_count_at`.
12616    ///
12617    /// Without `rebuild_all`, the as-of instance's "emp" view stays at the
12618    /// initial backfill value (0) instead of reflecting the replayed derived edges.
12619    #[test]
12620    fn open_at_derived_edge_view_values_correct() {
12621        use core_rules::ViewSource;
12622        let dir = tmp_dir("open-at-view-rebuild");
12623        {
12624            let mut db = GraphDb::open(&dir).unwrap();
12625            // frame 0
12626            db.insert_node("Org", "o1", vec![]).unwrap();
12627            // frame 1: create view — initial backfill sees 0 derived edges (none fired yet)
12628            db.create_view(ViewDef {
12629                name: "employee_count".into(),
12630                label: "Org".into(),
12631                view_prop: "emp".into(),
12632                source: ViewSource::Degree {
12633                    edge_type: "WORKS_AT".into(),
12634                    direction: Direction::In,
12635                },
12636            })
12637            .unwrap();
12638            // frame 2: create rule — no Persons yet; backfill is a no-op
12639            db.create_rule(fk_rule()).unwrap();
12640            // frame 3: p1 — rule fires WORKS_AT p1→o1; degree = 1
12641            db.insert_node(
12642                "Person",
12643                "p1",
12644                vec![("org_id".into(), Value::Str("o1".into()))],
12645            )
12646            .unwrap();
12647            // frame 4: p2 — degree = 2
12648            db.insert_node(
12649                "Person",
12650                "p2",
12651                vec![("org_id".into(), Value::Str("o1".into()))],
12652            )
12653            .unwrap();
12654            // frame 5: p3 — degree = 3
12655            db.insert_node(
12656                "Person",
12657                "p3",
12658                vec![("org_id".into(), Value::Str("o1".into()))],
12659            )
12660            .unwrap();
12661            // Sanity: normal open sees degree = 3.
12662            assert_eq!(
12663                db.get_view_prop("o1", "emp"),
12664                Some(Value::Int(3)),
12665                "normal db must show degree 3 after 3 derived edges"
12666            );
12667        } // WAL flushed
12668
12669        // Re-open normally to get the authoritative reference value.
12670        let normal_db = GraphDb::open(&dir).unwrap();
12671        let normal_emp = normal_db.get_view_prop("o1", "emp");
12672        assert_eq!(
12673            normal_emp,
12674            Some(Value::Int(3)),
12675            "re-opened normal db must show degree 3"
12676        );
12677
12678        // Latest as-of (last WAL commit): must match the normal open.
12679        // History-marker frames are appended after each rule-fire, so the total
12680        // commit count is computed dynamically rather than hardcoded.
12681        let total = crate::wal_commit_count_at(&dir).unwrap();
12682        let aof_latest = GraphDb::open_at(&dir, total - 1).unwrap();
12683        assert_eq!(
12684            aof_latest.get_view_prop("o1", "emp"),
12685            normal_emp,
12686            "open_at latest: derived-edge view must equal normal open (rebuild_all required)"
12687        );
12688
12689        // Mid-history as-of (commit 3 = p1 insert Batch frame): only p1; degree = 1.
12690        // The DerivedEdgeAdded marker for p1 is at frame 4 (state no-op on replay),
12691        // so replaying 0..=3 correctly re-derives only the p1→o1 edge.
12692        let aof_mid = GraphDb::open_at(&dir, 3).unwrap();
12693        assert_eq!(
12694            aof_mid.get_view_prop("o1", "emp"),
12695            Some(Value::Int(1)),
12696            "open_at mid-history: only p1 exists at frame 3, degree must be 1"
12697        );
12698
12699        let _ = std::fs::remove_dir_all(&dir);
12700    }
12701
12702    /// Pin: subscribe_* on an as-of instance must return Err(ReadOnly) —
12703    /// as-of instances never commit, so distribute_events never runs and any
12704    /// subscription would wait forever.
12705    #[test]
12706    fn subscribe_on_as_of_returns_read_only_error() {
12707        let dir = tmp_dir("sub-as-of-read-only");
12708        {
12709            let mut db = GraphDb::open(&dir).unwrap();
12710            db.insert_node("Org", "o1", vec![]).unwrap();
12711            db.create_rule(fk_rule()).unwrap();
12712        }
12713        let mut aof = GraphDb::open_at(&dir, 0).unwrap();
12714
12715        assert!(
12716            matches!(
12717                aof.subscribe_all_rules(),
12718                Err(core_storage::GraphError::ReadOnly)
12719            ),
12720            "subscribe_all_rules on as-of must return ReadOnly"
12721        );
12722        assert!(
12723            matches!(
12724                aof.subscribe_writes(),
12725                Err(core_storage::GraphError::ReadOnly)
12726            ),
12727            "subscribe_writes on as-of must return ReadOnly"
12728        );
12729        assert!(
12730            matches!(
12731                aof.subscribe_rule("works_at"),
12732                Err(core_storage::GraphError::ReadOnly)
12733            ),
12734            "subscribe_rule on as-of must return ReadOnly"
12735        );
12736        let _ = std::fs::remove_dir_all(&dir);
12737    }
12738
12739    /// Regression: a failed dense WAL rewrite must not leave speculative
12740    /// interns in `syms`. If it does, the next successful mutation logs an
12741    /// `Intern` record with an inflated id; replay (which never saw the
12742    /// orphans) assigns a smaller id and the WAL becomes unreplayable.
12743    #[test]
12744    fn dense_rewrite_error_rolls_back_speculative_interns() {
12745        let dir = tmp_dir("dense-rewrite-rollback");
12746        {
12747            let mut db = GraphDb::open(&dir).unwrap();
12748            db.insert_node("Person", "a", vec![]).unwrap();
12749
12750            // Bypass MutPreview validation to hit the rewrite's own error path
12751            // (same shape as an id-exhaustion failure mid-rewrite). The
12752            // InsertEdge arm interns the edge type before it resolves keys.
12753            let err = db.rewrite_wal_dense(vec![WalRecord::InsertEdge {
12754                edge_type: "ORPHAN_TYPE".into(),
12755                src_key: "missing".into(),
12756                dst_key: "a".into(),
12757            }]);
12758            assert!(err.is_err(), "rewrite of a missing src key must fail");
12759            assert_eq!(
12760                db.syms.get("ORPHAN_TYPE"),
12761                None,
12762                "failed rewrite must roll back speculative interns"
12763            );
12764
12765            // A later successful mutation must produce a replayable WAL.
12766            db.set_prop("a", "later_field", Value::Int(2)).unwrap();
12767        }
12768        let db = GraphDb::open(&dir).expect("WAL must replay after failed rewrite");
12769        assert_eq!(db.get_prop("a", "later_field"), Some(Value::Int(2)));
12770        let _ = std::fs::remove_dir_all(&dir);
12771    }
12772}