Skip to main content

core_api/
db.rs

1use crate::ingest::{IngestOptions, IngestReport};
2use crate::roles::{PropPredicate, 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, HashSet};
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        self.find_similar_vector_filtered(field, label, q, k, min, None, None, false)
8562            .expect("find_similar_vector_filtered is infallible without where_")
8563    }
8564
8565    /// Like [`find_similar_vector`] but restricts results to nodes visible in
8566    /// `mask`. Hidden nodes never appear in results; the mask is applied
8567    /// **before** k-truncation so a caller still receives up to `k` visible
8568    /// hits.
8569    ///
8570    /// # HNSW path (widening beam)
8571    ///
8572    /// When an HNSW index covers the request, the beam starts at an over-fetch
8573    /// of `k × n / |visible|` (plus the rescore margin) when the mask's
8574    /// selectivity is known from the index length, otherwise at `k` plus that
8575    /// margin. If fewer than `k` visible candidates remain after the mask and
8576    /// `min` filter, the beam doubles — the same ×2 loop exact `VectorSimilar`
8577    /// rules use, capped at `ef_max()` (`EF_MAX` = 4,096). Reaching the cap,
8578    /// or a beam that comes back short of its own width, falls through to the
8579    /// exhaustive masked scan rather than returning a short result.
8580    ///
8581    /// Every surviving candidate is re-scored from the `f64` property vectors,
8582    /// exactly as [`find_similar_vector`] does and for the same reason.
8583    ///
8584    /// # Brute-force path
8585    ///
8586    /// When no HNSW index covers the request, or the beam cannot admit `k`
8587    /// hits, the function builds a masked [`GraphView`] so that `nodes_all` /
8588    /// `nodes_with_label` return only visible nodes, guaranteeing exact `k`
8589    /// results (or all visible nodes if fewer than `k` exist).
8590    pub fn find_similar_vector_masked(
8591        &self,
8592        field: &str,
8593        label: Option<&str>,
8594        q: &[f64],
8595        k: usize,
8596        min: f64,
8597        mask: &crate::mask::NodeMask,
8598    ) -> Vec<(String, f64)> {
8599        self.find_similar_vector_filtered(field, label, q, k, min, Some(mask), None, false)
8600            .expect("find_similar_vector_filtered is infallible without where_")
8601    }
8602
8603    /// Exact or ANN kNN with optional key-list `mask` and property `where_`.
8604    ///
8605    /// `where_` present and failing [`PropPredicate::validate_named`] `"where"`
8606    /// → `QueryError`. `exact=true` or `where_=Some` skip HNSW and GEMM-brute
8607    /// the candidate set (`label ∩ mask ∩ holds(where)`). `mask` alone still
8608    /// uses HNSW when an index covers the field.
8609    #[allow(clippy::too_many_arguments)]
8610    pub fn find_similar_vector_filtered(
8611        &self,
8612        field: &str,
8613        label: Option<&str>,
8614        q: &[f64],
8615        k: usize,
8616        min: f64,
8617        mask: Option<&crate::mask::NodeMask>,
8618        where_: Option<&PropPredicate>,
8619        exact: bool,
8620    ) -> Result<Vec<(String, f64)>> {
8621        if let Some(pred) = where_ {
8622            pred.validate_named("where")
8623                .map_err(|detail| GraphError::QueryError { detail })?;
8624        }
8625
8626        // Ensure any HNSW blobs retained from the snapshot are deserialized
8627        // before the first ANN query on a clean-open (no-WAL) path.  The
8628        // section read has to come first: on a clean open nothing else has
8629        // called it, so without it `retained_hnsw_blobs` is empty,
8630        // `ensure_hnsw_loaded` caches an empty map in its `OnceLock`, and every
8631        // approximate query on the handle runs brute force — correct results,
8632        // silently off the index.  Both calls are idempotent and cheap once hot.
8633        self.ensure_v8_base_sections_loaded();
8634        self.engine.ensure_hnsw_loaded();
8635        let norm: f64 = q.iter().map(|x| x * x).sum::<f64>().sqrt();
8636        if norm == 0.0 {
8637            return Ok(vec![]);
8638        }
8639        if let Some(m) = mask {
8640            if k == 0 || m.is_empty() {
8641                return Ok(vec![]);
8642            }
8643        }
8644        let q_unit: Vec<f64> = q.iter().map(|x| x / norm).collect();
8645
8646        // `where` implies exact: a predicate must not ride a silent ANN.
8647        let skip_hnsw = exact || where_.is_some();
8648        if !skip_hnsw {
8649            if let Some(mask) = mask {
8650                if let Some(out) =
8651                    self.find_similar_hnsw_masked(field, label, &q_unit, k, min, mask)
8652                {
8653                    return Ok(out);
8654                }
8655            } else if let Some(out) = self.find_similar_hnsw(field, label, &q_unit, k, min) {
8656                return Ok(out);
8657            }
8658        }
8659
8660        let view = match mask {
8661            Some(m) => self.view_masked(m),
8662            None => self.view(),
8663        };
8664        let candidate_ids = Self::vector_candidates(&view, label, where_);
8665        Ok(self.brute_vector_hits(&view, candidate_ids, field, &q_unit, k, min))
8666    }
8667
8668    /// Unmasked HNSW path. `None` when no populated index covers the request.
8669    fn find_similar_hnsw(
8670        &self,
8671        field: &str,
8672        label: Option<&str>,
8673        q_unit: &[f64],
8674        k: usize,
8675        min: f64,
8676    ) -> Option<Vec<(String, f64)>> {
8677        // Try HNSW fast path.
8678        // `None` label searches across all VectorSimilar rules covering `field`
8679        // (merging their results); `Some(lbl)` restricts to rules whose
8680        // dst_label matches.  Returns `None` when no populated HNSW index
8681        // covers the request — the O(n) brute-force fallback handles that case.
8682        let over_k = k.saturating_add(VECTOR_RESCORE_MARGIN);
8683        let hits = match label {
8684            Some(lbl) => self.engine.hnsw_search_dst(field, lbl, q_unit, over_k)?,
8685            None => self.engine.hnsw_search_any_dst(field, q_unit, over_k)?,
8686        };
8687        // Candidates only: the index's `f32` similarity is discarded and
8688        // each hit is re-scored against the `f64` vectors.
8689        let view = self.view();
8690        let mut out: Vec<(String, f64)> = hits
8691            .into_iter()
8692            .filter_map(|(id, _)| {
8693                let sim = exact_vector_similarity(&view, id, field, q_unit)?;
8694                if sim < min {
8695                    return None;
8696                }
8697                Some((self.ids.key_of(id)?.to_string(), sim))
8698            })
8699            .collect();
8700        out.sort_by(|a, b| {
8701            b.1.partial_cmp(&a.1)
8702                .unwrap_or(std::cmp::Ordering::Equal)
8703                .then_with(|| a.0.cmp(&b.0))
8704        });
8705        out.truncate(k);
8706        Some(out)
8707    }
8708
8709    /// Masked HNSW widening beam. `None` when no index covers the request or
8710    /// the beam cannot admit `k` visible hits (caller falls through to brute).
8711    fn find_similar_hnsw_masked(
8712        &self,
8713        field: &str,
8714        label: Option<&str>,
8715        q_unit: &[f64],
8716        k: usize,
8717        min: f64,
8718        mask: &crate::mask::NodeMask,
8719    ) -> Option<Vec<(String, f64)>> {
8720        let index_len = match label {
8721            Some(lbl) => self.engine.hnsw_dst_len(field, lbl, q_unit.len()),
8722            None => self.engine.hnsw_any_dst_len(field, q_unit.len()),
8723        };
8724        let n = index_len?;
8725        // Same ceiling the exact-rule widening loop in `hnsw_candidates`
8726        // consults — including the `with_ef_max` test hook.
8727        let cap = ef_max();
8728        let visible = mask.len();
8729        let mut ef = k.saturating_add(VECTOR_RESCORE_MARGIN);
8730        if visible > 0 && n > 0 {
8731            let over = k
8732                .saturating_mul(n)
8733                .div_ceil(visible)
8734                .saturating_add(VECTOR_RESCORE_MARGIN);
8735            ef = ef.max(over);
8736        }
8737        loop {
8738            let hits = match label {
8739                Some(lbl) => self
8740                    .engine
8741                    .hnsw_search_dst_with_ef(field, lbl, q_unit, ef, ef),
8742                None => self
8743                    .engine
8744                    .hnsw_search_any_dst_with_ef(field, q_unit, ef, ef),
8745            };
8746            let hits = hits?;
8747            let full = hits.len() == ef;
8748            let mut out = self.score_masked_hnsw_hits(&hits, field, q_unit, min, mask);
8749            if out.len() >= k {
8750                out.truncate(k);
8751                return Some(out);
8752            }
8753            // Short of its width (frontier exhausted) or at the ceiling:
8754            // a wider beam reaches nothing new, so the scan answers.
8755            if !full || ef >= cap {
8756                return None;
8757            }
8758            ef = ef.saturating_mul(2);
8759        }
8760    }
8761
8762    /// `label ∩ mask ∩ holds(where)`. Index fast path when `label` is `Some`
8763    /// and `(label, where.field)` is enabled; otherwise scan with `visible()`.
8764    fn vector_candidates(
8765        view: &GraphView<'_>,
8766        label: Option<&str>,
8767        where_: Option<&PropPredicate>,
8768    ) -> Vec<u32> {
8769        if let (Some(lbl), Some(pred)) = (label, where_) {
8770            let indexed = view
8771                .prop_index
8772                .is_some_and(|idx| idx.is_enabled(lbl, &pred.field));
8773            if indexed {
8774                match (&pred.eq, &pred.in_) {
8775                    (Some(eq), None) => {
8776                        if let Some(ids) = view.nodes_with_prop(lbl, &pred.field, eq) {
8777                            return ids;
8778                        }
8779                    }
8780                    (None, Some(allowed)) => {
8781                        let mut seen = HashSet::new();
8782                        let mut out = Vec::new();
8783                        for v in allowed {
8784                            if let Some(ids) = view.nodes_with_prop(lbl, &pred.field, v) {
8785                                for id in ids {
8786                                    if seen.insert(id) {
8787                                        out.push(id);
8788                                    }
8789                                }
8790                            }
8791                        }
8792                        return out;
8793                    }
8794                    _ => {}
8795                }
8796            }
8797        }
8798
8799        let mut ids: Vec<u32> = match label {
8800            Some(lbl) => view
8801                .nodes_with_label(lbl)
8802                .into_iter()
8803                .filter(|&id| view.visible(id))
8804                .collect(),
8805            None => view.nodes_all(),
8806        };
8807        if let Some(pred) = where_ {
8808            ids.retain(|&id| match view.prop(id, &pred.field) {
8809                None => pred.holds(None),
8810                Some(vr) => pred.holds(Some(vr.as_value())),
8811            });
8812        }
8813        ids
8814    }
8815
8816    /// Exact brute kNN: pack candidates at `q_unit`'s dim, GEMV, keep
8817    /// `score >= min`, sort `(sim desc, key asc)`, truncate to `k`.
8818    fn brute_vector_hits(
8819        &self,
8820        view: &GraphView<'_>,
8821        candidate_ids: impl IntoIterator<Item = u32>,
8822        field: &str,
8823        q_unit: &[f64],
8824        k: usize,
8825        min: f64,
8826    ) -> Vec<(String, f64)> {
8827        let rows: Vec<(u32, std::borrow::Cow<'_, [f64]>)> = candidate_ids
8828            .into_iter()
8829            .filter_map(|id| crate::exact_knn::vector_f64(view, id, field).map(|v| (id, v)))
8830            .collect();
8831        let packed =
8832            crate::exact_knn::pack(rows.iter().map(|(id, v)| (*id, v.as_ref())), q_unit.len());
8833        let scores = crate::exact_knn::gemv(&packed, q_unit);
8834        let mut scored: Vec<(String, f64)> = packed
8835            .ids
8836            .iter()
8837            .zip(scores.iter())
8838            .filter_map(|(&id, &sim)| {
8839                if sim < min {
8840                    return None;
8841                }
8842                let key = self.ids.key_of(id)?.to_string();
8843                Some((key, sim))
8844            })
8845            .collect();
8846        scored.sort_by(|a, b| {
8847            b.1.partial_cmp(&a.1)
8848                .unwrap_or(std::cmp::Ordering::Equal)
8849                .then_with(|| a.0.cmp(&b.0))
8850        });
8851        scored.truncate(k);
8852        scored
8853    }
8854
8855    /// Exact cosine top-k for each key in `keys`, scored only against `keys`.
8856    ///
8857    /// `min` is cosine similarity in [-1, 1], inclusive (`score >= min`), the
8858    /// same unit and inequality as `find_similar_vector`. Self-matches are
8859    /// excluded. Unknown keys, keys with no `field`, zero-norm or wrong-dim
8860    /// embeddings are omitted as both query and candidate. Duplicate keys are
8861    /// collapsed, first-seen order. Empty `keys` → empty `Ok(vec![])`. Never
8862    /// uses HNSW. `n > PAIRWISE_MAX_N` → `QueryError`.
8863    #[allow(clippy::type_complexity)]
8864    pub fn pairwise_similar(
8865        &self,
8866        keys: &[&str],
8867        field: &str,
8868        k: usize,
8869        min: f64,
8870    ) -> Result<Vec<(String, Vec<(String, f64)>)>> {
8871        let mut seen = HashSet::new();
8872        let mut unique_ids = Vec::new();
8873        for key in keys {
8874            let Some(id) = self.ids.get(key) else {
8875                continue;
8876            };
8877            if seen.insert(id) {
8878                unique_ids.push(id);
8879            }
8880        }
8881        let max_n = crate::exact_knn::pairwise_max_n();
8882        if unique_ids.len() > max_n {
8883            return Err(GraphError::QueryError {
8884                detail: format!(
8885                    "pairwise_similar: n={} exceeds PAIRWISE_MAX_N ({max_n})",
8886                    unique_ids.len()
8887                ),
8888            });
8889        }
8890        if unique_ids.is_empty() {
8891            return Ok(Vec::new());
8892        }
8893
8894        let view = self.view();
8895        let mut rows: Vec<(u32, std::borrow::Cow<'_, [f64]>)> = Vec::new();
8896        let mut counts: HashMap<usize, usize> = HashMap::new();
8897        for id in unique_ids {
8898            let Some(v) = crate::exact_knn::vector_f64(&view, id, field) else {
8899                continue;
8900            };
8901            let norm: f64 = v.iter().map(|x| x * x).sum::<f64>().sqrt();
8902            if norm == 0.0 {
8903                continue;
8904            }
8905            *counts.entry(v.len()).or_default() += 1;
8906            rows.push((id, v));
8907        }
8908        if rows.is_empty() {
8909            return Ok(Vec::new());
8910        }
8911        let dim = counts
8912            .into_iter()
8913            .max_by_key(|&(d, c)| (c, d))
8914            .map(|(d, _)| d)
8915            .expect("rows non-empty");
8916        let packed = crate::exact_knn::pack(rows.iter().map(|(id, v)| (*id, v.as_ref())), dim);
8917        let n = packed.ids.len();
8918        if n == 0 {
8919            return Ok(Vec::new());
8920        }
8921        let src_keys: Vec<String> = packed
8922            .ids
8923            .iter()
8924            .map(|&id| self.ids.key_of(id).unwrap_or("").to_string())
8925            .collect();
8926
8927        let mut out = Vec::with_capacity(n);
8928        if n <= crate::exact_knn::pairwise_gram_max() {
8929            let sims = crate::exact_knn::gram(&packed);
8930            for i in 0..n {
8931                out.push(Self::topk_from_row(
8932                    &src_keys,
8933                    i,
8934                    &sims[i * n..(i + 1) * n],
8935                    k,
8936                    min,
8937                ));
8938            }
8939        } else {
8940            for i in 0..n {
8941                let row = &packed.data[i * packed.dim..(i + 1) * packed.dim];
8942                let scores = crate::exact_knn::gemv(&packed, row);
8943                out.push(Self::topk_from_row(&src_keys, i, &scores, k, min));
8944            }
8945        }
8946        Ok(out)
8947    }
8948
8949    /// Neighbours of packed row `i`: drop self, keep `score >= min`, sort
8950    /// `(sim desc, key asc)`, truncate to `k`. Packed srcs with no survivors
8951    /// still appear as `(src, [])`.
8952    fn topk_from_row(
8953        src_keys: &[String],
8954        i: usize,
8955        scores: &[f64],
8956        k: usize,
8957        min: f64,
8958    ) -> (String, Vec<(String, f64)>) {
8959        let mut neigh: Vec<(String, f64)> = scores
8960            .iter()
8961            .enumerate()
8962            .filter_map(|(j, &sim)| {
8963                if i == j || sim < min {
8964                    return None;
8965                }
8966                Some((src_keys[j].clone(), sim))
8967            })
8968            .collect();
8969        neigh.sort_by(|a, b| {
8970            b.1.partial_cmp(&a.1)
8971                .unwrap_or(std::cmp::Ordering::Equal)
8972                .then_with(|| a.0.cmp(&b.0))
8973        });
8974        neigh.truncate(k);
8975        (src_keys[i].clone(), neigh)
8976    }
8977
8978    /// Re-score HNSW candidates from the `f64` vectors, drop hidden / below-`min`
8979    /// hits, order by score then key. The index's own `f32` similarity is discarded.
8980    fn score_masked_hnsw_hits(
8981        &self,
8982        hits: &[(u32, f64)],
8983        field: &str,
8984        q_unit: &[f64],
8985        min: f64,
8986        mask: &crate::mask::NodeMask,
8987    ) -> Vec<(String, f64)> {
8988        let view = self.view_masked(mask);
8989        let mut out: Vec<(String, f64)> = hits
8990            .iter()
8991            .copied()
8992            .filter(|&(id, _)| mask.visible.contains(&id))
8993            .filter_map(|(id, _)| {
8994                let sim = exact_vector_similarity(&view, id, field, q_unit)?;
8995                if sim < min {
8996                    return None;
8997                }
8998                Some((self.ids.key_of(id)?.to_string(), sim))
8999            })
9000            .collect();
9001        out.sort_by(|a, b| {
9002            b.1.partial_cmp(&a.1)
9003                .unwrap_or(std::cmp::Ordering::Equal)
9004                .then_with(|| a.0.cmp(&b.0))
9005        });
9006        out
9007    }
9008
9009    /// Read a single property from an edge.
9010    ///
9011    /// Returns `None` when the edge does not exist, the field is absent, or any
9012    /// of the string keys cannot be resolved to interned ids.  Only edge props
9013    /// written by rules (weight fields) are accessible without a `set_edge_prop`
9014    /// binding; topology-only edges (no props set) return `None` for every field.
9015    pub fn get_edge_prop(
9016        &self,
9017        edge_type: &str,
9018        src_key: &str,
9019        dst_key: &str,
9020        field: &str,
9021    ) -> Option<Value> {
9022        let etype = self.syms.get(edge_type)?;
9023        let src = self.ids.get(src_key)?;
9024        let dst = self.ids.get(dst_key)?;
9025        self.edge_props_view().get(etype, src, dst, field)
9026    }
9027
9028    /// Lex → parse → plan → execute `cypher` over a read-only view.
9029    /// Every pipeline `Err(String)` becomes `GraphError::QueryError` with a
9030    /// stage prefix (`lex:` / `parse:` / `plan:` / `execute:`).
9031    pub fn query(&self, cypher: &str, params: &BTreeMap<String, Value>) -> Result<ResultSet> {
9032        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
9033            detail: format!("lex: {e}"),
9034        })?;
9035        let union = parse_read(&tokens).map_err(|e| GraphError::QueryError {
9036            detail: format!("parse: {e}"),
9037        })?;
9038        let t0 = std::time::Instant::now();
9039        let result = execute_union(&self.view(), &union, &Params(params)).map_err(|e| {
9040            GraphError::QueryError {
9041                detail: format!("execute: {e}"),
9042            }
9043        });
9044        let elapsed_ms = t0.elapsed().as_millis() as u64;
9045        let threshold = self.slow_query_threshold_ms;
9046        if threshold > 0 && elapsed_ms >= threshold {
9047            eprintln!("[mushroomdb] slow query ({elapsed_ms}ms): {cypher}");
9048            let entry = SlowQueryEntry {
9049                ms: elapsed_ms,
9050                query: cypher.to_string(),
9051                at_commit: self.commit_seq,
9052            };
9053            if let Ok(mut log) = self.slow_queries.lock() {
9054                if log.entries.len() == SLOW_QUERY_RING_CAP {
9055                    log.entries.pop_front();
9056                }
9057                log.entries.push_back(entry);
9058                log.total += 1;
9059            }
9060        }
9061        result
9062    }
9063
9064    /// Convenience entry-point that accepts a slice of `(name, value)` pairs
9065    /// instead of a pre-built `BTreeMap`.  Equivalent to building the map and
9066    /// calling [`GraphDb::query`].
9067    pub fn query_with_params(&self, cypher: &str, params: &[(&str, Value)]) -> Result<ResultSet> {
9068        let map: BTreeMap<String, Value> = params
9069            .iter()
9070            .map(|(k, v)| (k.to_string(), v.clone()))
9071            .collect();
9072        self.query(cypher, &map)
9073    }
9074
9075    /// Execute a Cypher write statement (CREATE / MATCH…SET / MATCH…DELETE / MERGE).
9076    ///
9077    /// All mutations flow through the same `insert_node` / `set_prop` /
9078    /// `delete_edge` / `insert_edge` path as the Rust API so the rule engine
9079    /// fires and the WAL captures everything with one fsync per statement.
9080    ///
9081    /// Returns a one-row [`ResultSet`] with columns `created`, `properties_set`,
9082    /// and `deleted` matching the write-result contract.
9083    ///
9084    /// **Mutation routing**: mutations are collected into a single
9085    /// [`BatchBuilder`] and committed atomically (one WAL `Batch` frame, one
9086    /// fsync). The MATCH phase for SET/DELETE uses a read-only `execute` call
9087    /// over `self.view()` — the borrow is dropped before the batch is opened.
9088    ///
9089    /// **Limitations (v1)**:
9090    /// - SET RHS must be a literal, `$param`, or arithmetic; bare property copy → named error.
9091    /// - `DETACH DELETE n` → calls `delete_node` for each matched node (removes all edges).
9092    /// - Bare `DELETE n` → error if n has any incident edges; succeeds for isolated nodes.
9093    /// - MERGE supports `ON CREATE SET` / `ON MATCH SET` in the same write batch.
9094    /// - Deleting a derived edge → named error "cannot delete derived edge".
9095    pub fn query_write(
9096        &mut self,
9097        cypher: &str,
9098        params: &BTreeMap<String, Value>,
9099    ) -> Result<ResultSet> {
9100        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
9101            detail: format!("lex: {e}"),
9102        })?;
9103        let stmt = parse_write(&tokens).map_err(|e| GraphError::QueryError {
9104            detail: format!("parse: {e}"),
9105        })?;
9106        self.exec_write_stmt(stmt, params)
9107    }
9108
9109    fn exec_write_stmt(
9110        &mut self,
9111        stmt: WriteStatement,
9112        params: &BTreeMap<String, Value>,
9113    ) -> Result<ResultSet> {
9114        match stmt {
9115            WriteStatement::Create(s) => self.exec_create(s, params),
9116            WriteStatement::MatchSet(s) => self.exec_match_set(s, params),
9117            WriteStatement::MatchDelete(s) => self.exec_match_delete(s, params),
9118            WriteStatement::MatchDeleteNode(s) => self.exec_match_delete_node(s, params),
9119            WriteStatement::Merge(s) => self.exec_merge(s, params),
9120        }
9121    }
9122
9123    fn exec_create(
9124        &mut self,
9125        stmt: core_query::cypher::CreateStmt,
9126        params: &BTreeMap<String, Value>,
9127    ) -> Result<ResultSet> {
9128        // Extract the node key from props: require a string-valued `id` field.
9129        let mut var_to_key: BTreeMap<String, String> = BTreeMap::new();
9130        for node in &stmt.nodes {
9131            let var = node.var.as_deref().unwrap_or("_cn0");
9132            let key = node
9133                .props
9134                .iter()
9135                .find(|(f, _)| f == "id")
9136                .and_then(|(_, v)| {
9137                    if let Value::Str(s) = v {
9138                        Some(s.clone())
9139                    } else {
9140                        None
9141                    }
9142                })
9143                .ok_or_else(|| GraphError::QueryError {
9144                    detail: format!(
9145                        "CREATE node ({}:{}) requires a string 'id' property",
9146                        var, node.label
9147                    ),
9148                })?;
9149            var_to_key.insert(var.to_string(), key);
9150        }
9151
9152        let mut batch = self.batch();
9153        let mut created: usize = 0;
9154        for node in &stmt.nodes {
9155            let var = node.var.as_deref().unwrap_or("_cn0");
9156            let key = &var_to_key[var];
9157            batch.insert_node(&node.label, key, node.props.clone());
9158            created += 1;
9159        }
9160        for edge in &stmt.edges {
9161            let src_key = var_to_key
9162                .get(&edge.src_var)
9163                .ok_or_else(|| GraphError::QueryError {
9164                    detail: format!("CREATE edge src variable '{}' is not bound", edge.src_var),
9165                })?;
9166            let dst_key = var_to_key
9167                .get(&edge.dst_var)
9168                .ok_or_else(|| GraphError::QueryError {
9169                    detail: format!("CREATE edge dst variable '{}' is not bound", edge.dst_var),
9170                })?;
9171            batch.insert_edge(&edge.etype, src_key, dst_key);
9172        }
9173        batch.commit()?;
9174
9175        // Optional RETURN clause: project created bindings as a read result.
9176        if let Some(returns) = stmt.returns {
9177            // Each created node is looked up by its key via a separate MATCH pattern.
9178            // Multiple single-node patterns cross-join to produce 1 output row with
9179            // all variables bound (each pattern returns exactly 1 row).
9180            let patterns: Vec<Pattern> = stmt
9181                .nodes
9182                .iter()
9183                .map(|node| {
9184                    let var = node.var.as_deref().unwrap_or("_cn0");
9185                    let key = var_to_key[var].clone();
9186                    Pattern {
9187                        start: NodePat {
9188                            var: Some(var.to_string()),
9189                            label: Some(node.label.clone()),
9190                            props: vec![("id".to_string(), Operand::Lit(Value::Str(key)))],
9191                        },
9192                        chain: vec![],
9193                        shortest: false,
9194                    }
9195                })
9196                .collect();
9197            let q = Query {
9198                matches: patterns,
9199                optional_clauses: vec![],
9200                where_expr: None,
9201                unwinds: vec![],
9202                post_unwind_where: None,
9203                stages: vec![],
9204                returns,
9205                distinct: false,
9206                order_by: vec![],
9207                skip: None,
9208                limit: None,
9209            };
9210            let ops = plan(&q).map_err(|e| GraphError::QueryError {
9211                detail: format!("plan: {e}"),
9212            })?;
9213            return execute(&self.view(), &ops, &Params(params)).map_err(|e| {
9214                GraphError::QueryError {
9215                    detail: format!("execute: {e}"),
9216                }
9217            });
9218        }
9219
9220        let mut rs = write_result_set();
9221        rs.push_row(vec![
9222            Some(Value::Int(created as i64)),
9223            Some(Value::Int(0)),
9224            Some(Value::Int(0)),
9225        ]);
9226        Ok(rs)
9227    }
9228
9229    fn exec_match_set(
9230        &mut self,
9231        stmt: core_query::cypher::MatchSetStmt,
9232        params: &BTreeMap<String, Value>,
9233    ) -> Result<ResultSet> {
9234        let project_returns = stmt.returns.clone();
9235        // Collect unique node vars targeted by SET clauses, plus RETURN bindings
9236        // so the post-write projection can look them up by key.
9237        let mut set_vars: Vec<String> = Vec::new();
9238        for s in &stmt.sets {
9239            if !set_vars.contains(&s.var) {
9240                set_vars.push(s.var.clone());
9241            }
9242        }
9243        let rel_vars = pattern_rel_vars(&stmt.matches);
9244        let mut lookup_vars = set_vars.clone();
9245        for v in pattern_node_vars(&stmt.matches) {
9246            add_var(&mut lookup_vars, &v);
9247        }
9248        if let Some(ref returns) = project_returns {
9249            for v in ret_node_vars(returns) {
9250                if !rel_vars.iter().any(|r| r == &v) {
9251                    add_var(&mut lookup_vars, &v);
9252                }
9253            }
9254        }
9255
9256        // Synthesize a read query: MATCH … WHERE … RETURN <lookup_vars>, <set_values…>
9257        // SET values are projected as ScalarExpr items so that arithmetic expressions
9258        // (e.g. `SET n.score = n.score * 1.5`) are evaluated in the matched-row context.
9259        let mut set_returns: Vec<RetItem> = lookup_vars
9260            .iter()
9261            .map(|v| RetItem {
9262                value: RetVal::Var(v.clone()),
9263                alias: None,
9264            })
9265            .collect();
9266        // One computed column per SET clause; alias is `__sv_<i>`.
9267        let set_val_cols: Vec<String> = stmt
9268            .sets
9269            .iter()
9270            .enumerate()
9271            .map(|(i, _)| format!("__sv_{i}"))
9272            .collect();
9273        for (sc, col) in stmt.sets.iter().zip(&set_val_cols) {
9274            set_returns.push(RetItem {
9275                value: RetVal::ScalarExpr(sc.value.clone()),
9276                alias: Some(col.clone()),
9277            });
9278        }
9279        // Capture relationship types while r is bound; SET does not change them.
9280        for r in &rel_vars {
9281            set_returns.push(RetItem {
9282                value: RetVal::FuncCall {
9283                    name: "type".into(),
9284                    args: vec![Operand::Var(r.clone())],
9285                },
9286                alias: Some(rel_type_alias(r)),
9287            });
9288        }
9289
9290        let read_q = Query {
9291            matches: stmt.matches.clone(),
9292            optional_clauses: vec![],
9293            where_expr: stmt.where_expr.clone(),
9294            unwinds: vec![],
9295            post_unwind_where: None,
9296            stages: vec![],
9297            returns: set_returns,
9298            distinct: false,
9299            order_by: vec![],
9300            skip: None,
9301            limit: None,
9302        };
9303        let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
9304            detail: format!("plan: {e}"),
9305        })?;
9306        // MATCH phase is read-only; borrow ends before batch opens.
9307        //
9308        // When a role-scoped write is in flight, run the MATCH read through
9309        // view_masked so hidden nodes are invisible → hidden ≡ absent ≡
9310        // zero-rows (no SetProp ops generated, no existence-oracle 403).
9311        // Full-authority writes (pending_write_authz=None) keep view().
9312        let match_rs = {
9313            let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
9314            if let Some(ref mask) = mask_opt {
9315                execute(&self.view_masked(mask), &ops, &Params(params))
9316            } else {
9317                execute(&self.view(), &ops, &Params(params))
9318            }
9319        }
9320        .map_err(|e| GraphError::QueryError {
9321            detail: format!("execute: {e}"),
9322        })?;
9323
9324        // Collect (key, field, value) for each matched row × each SET clause.
9325        let mut set_ops: Vec<(String, String, Value)> = Vec::new();
9326        for row_i in 0..match_rs.len() {
9327            for (sc, col) in stmt.sets.iter().zip(&set_val_cols) {
9328                let key = match match_rs.get(row_i, &sc.var) {
9329                    Some(Value::Str(k)) => k.clone(),
9330                    _ => {
9331                        return Err(GraphError::QueryError {
9332                            detail: format!(
9333                                "SET variable '{}' did not resolve to a node key",
9334                                sc.var
9335                            ),
9336                        })
9337                    }
9338                };
9339                // The SET value was already evaluated by the executor.
9340                let value = match match_rs.get(row_i, col) {
9341                    Some(v) => v.clone(),
9342                    None => {
9343                        return Err(GraphError::QueryError {
9344                            detail: format!(
9345                                "SET value for {}.{} evaluated to null",
9346                                sc.var, sc.field
9347                            ),
9348                        })
9349                    }
9350                };
9351                set_ops.push((key, sc.field.clone(), value));
9352            }
9353        }
9354
9355        // Apply as one atomic batch.
9356        let props_set = set_ops.len();
9357        let mut batch = self.batch();
9358        for (key, field, value) in set_ops {
9359            batch.set_prop(&key, &field, value);
9360        }
9361        batch.commit()?;
9362
9363        if let Some(returns) = project_returns {
9364            return project_set_return_rows(self, &rel_vars, &match_rs, &returns, params);
9365        }
9366
9367        let mut rs = write_result_set();
9368        rs.push_row(vec![
9369            Some(Value::Int(0)),
9370            Some(Value::Int(props_set as i64)),
9371            Some(Value::Int(0)),
9372        ]);
9373        Ok(rs)
9374    }
9375
9376    fn exec_match_delete(
9377        &mut self,
9378        stmt: core_query::cypher::MatchDeleteStmt,
9379        params: &BTreeMap<String, Value>,
9380    ) -> Result<ResultSet> {
9381        // Collect unique node vars needed to identify edge endpoints.
9382        let mut node_vars: Vec<String> = Vec::new();
9383        for ed in &stmt.deletes {
9384            if !node_vars.contains(&ed.src_var) {
9385                node_vars.push(ed.src_var.clone());
9386            }
9387            if !node_vars.contains(&ed.dst_var) {
9388                node_vars.push(ed.dst_var.clone());
9389            }
9390        }
9391
9392        // Synthesize read query.
9393        let returns: Vec<RetItem> = node_vars
9394            .iter()
9395            .map(|v| RetItem {
9396                value: RetVal::Var(v.clone()),
9397                alias: None,
9398            })
9399            .collect();
9400        let read_q = Query {
9401            matches: stmt.matches,
9402            optional_clauses: vec![],
9403            where_expr: stmt.where_expr,
9404            unwinds: vec![],
9405            post_unwind_where: None,
9406            stages: vec![],
9407            returns,
9408            distinct: false,
9409            order_by: vec![],
9410            skip: None,
9411            limit: None,
9412        };
9413        let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
9414            detail: format!("plan: {e}"),
9415        })?;
9416        // Role-scoped writes: mask the MATCH read phase so hidden nodes are
9417        // invisible → hidden ≡ absent ≡ zero-rows (spec §3.1, hidden ≡ absent).
9418        let match_rs = {
9419            let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
9420            if let Some(ref mask) = mask_opt {
9421                execute(&self.view_masked(mask), &ops, &Params(params))
9422            } else {
9423                execute(&self.view(), &ops, &Params(params))
9424            }
9425        }
9426        .map_err(|e| GraphError::QueryError {
9427            detail: format!("execute: {e}"),
9428        })?;
9429
9430        // Collect (etype, src_key, dst_key) for each row × each delete target.
9431        let mut del_ops: Vec<(String, String, String)> = Vec::new();
9432        for row_i in 0..match_rs.len() {
9433            for ed in &stmt.deletes {
9434                let src_key = match match_rs.get(row_i, &ed.src_var) {
9435                    Some(Value::Str(k)) => k.clone(),
9436                    _ => {
9437                        return Err(GraphError::QueryError {
9438                            detail: format!(
9439                                "DELETE src variable '{}' did not resolve to a node key",
9440                                ed.src_var
9441                            ),
9442                        })
9443                    }
9444                };
9445                let dst_key = match match_rs.get(row_i, &ed.dst_var) {
9446                    Some(Value::Str(k)) => k.clone(),
9447                    _ => {
9448                        return Err(GraphError::QueryError {
9449                            detail: format!(
9450                                "DELETE dst variable '{}' did not resolve to a node key",
9451                                ed.dst_var
9452                            ),
9453                        })
9454                    }
9455                };
9456                del_ops.push((ed.etype.clone(), src_key, dst_key));
9457            }
9458        }
9459
9460        // Apply as one atomic batch.
9461        let deleted = del_ops.len();
9462        let mut batch = self.batch();
9463        for (etype, src_key, dst_key) in del_ops {
9464            batch.delete_edge(&etype, &src_key, &dst_key);
9465        }
9466        batch.commit().map_err(|e| match e {
9467            GraphError::RuleOwned { .. } => GraphError::QueryError {
9468                detail: "cannot delete derived edge; retract via the rule or change the property"
9469                    .to_string(),
9470            },
9471            other => other,
9472        })?;
9473
9474        let mut rs = write_result_set();
9475        rs.push_row(vec![
9476            Some(Value::Int(0)),
9477            Some(Value::Int(0)),
9478            Some(Value::Int(deleted as i64)),
9479        ]);
9480        Ok(rs)
9481    }
9482
9483    /// Execute `MATCH … [DETACH] DELETE <node_var> [, …]`.
9484    ///
9485    /// Collects the matching node keys via an ephemeral read query, then calls
9486    /// `delete_node` on each one.  When `stmt.detach` is `false` (bare DELETE)
9487    /// the executor first checks that the node has no incident edges; if any
9488    /// remain it returns a named error matching openCypher semantics.
9489    fn exec_match_delete_node(
9490        &mut self,
9491        stmt: MatchDeleteNodeStmt,
9492        params: &BTreeMap<String, Value>,
9493    ) -> Result<ResultSet> {
9494        // Build a read query returning only the node keys we need.
9495        let returns: Vec<RetItem> = stmt
9496            .node_vars
9497            .iter()
9498            .map(|v| RetItem {
9499                value: RetVal::Var(v.clone()),
9500                alias: None,
9501            })
9502            .collect();
9503        let read_q = Query {
9504            matches: stmt.matches,
9505            optional_clauses: vec![],
9506            where_expr: stmt.where_expr,
9507            unwinds: vec![],
9508            post_unwind_where: None,
9509            stages: vec![],
9510            returns,
9511            distinct: false,
9512            order_by: vec![],
9513            skip: None,
9514            limit: None,
9515        };
9516        let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
9517            detail: format!("plan: {e}"),
9518        })?;
9519        // Role-scoped writes: mask the MATCH read phase so hidden nodes are
9520        // invisible → hidden ≡ absent ≡ zero-rows (spec §3.1, hidden ≡ absent).
9521        let match_rs = {
9522            let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
9523            if let Some(ref mask) = mask_opt {
9524                execute(&self.view_masked(mask), &ops, &Params(params))
9525            } else {
9526                execute(&self.view(), &ops, &Params(params))
9527            }
9528        }
9529        .map_err(|e| GraphError::QueryError {
9530            detail: format!("execute: {e}"),
9531        })?;
9532
9533        // Collect unique node keys to delete (deduplicate across rows × vars).
9534        let mut keys: Vec<String> = Vec::new();
9535        for row_i in 0..match_rs.len() {
9536            for var in &stmt.node_vars {
9537                if let Some(Value::Str(k)) = match_rs.get(row_i, var) {
9538                    if !keys.contains(k) {
9539                        keys.push(k.clone());
9540                    }
9541                }
9542            }
9543        }
9544
9545        if !stmt.detach {
9546            // openCypher bare DELETE: error if any matched node has incident edges.
9547            for key in &keys {
9548                if let Some(id) = self.ids.get(key) {
9549                    let tv = self.topo_view();
9550                    let has_edges = tv.etypes().any(|et| {
9551                        !tv.neighbors(et, Direction::Out, id).is_empty()
9552                            || !tv.neighbors(et, Direction::In, id).is_empty()
9553                    });
9554                    if has_edges {
9555                        return Err(GraphError::QueryError {
9556                            detail: format!(
9557                                "Cannot delete node `{key}` because it still has incident edges. \
9558                                 Use DETACH DELETE to remove the node and all its edges."
9559                            ),
9560                        });
9561                    }
9562                }
9563            }
9564        }
9565
9566        let mut nodes_deleted = 0i64;
9567        let mut edges_deleted = 0i64;
9568        for key in keys {
9569            match self.delete_node(&key) {
9570                Ok(report) => {
9571                    nodes_deleted += 1;
9572                    edges_deleted += (report.manual_edges + report.derived_edges) as i64;
9573                }
9574                Err(GraphError::KeyNotFound { .. }) => {
9575                    // Node may have been deleted by an earlier iteration (e.g., via
9576                    // multiple MATCH rows for the same node).  Safe to skip.
9577                }
9578                Err(e) => return Err(e),
9579            }
9580        }
9581
9582        let mut rs = write_result_set();
9583        rs.push_row(vec![
9584            Some(Value::Int(0)),
9585            Some(Value::Int(0)),
9586            Some(Value::Int(nodes_deleted + edges_deleted)),
9587        ]);
9588        Ok(rs)
9589    }
9590
9591    /// Props the MERGE create arm inserts: the identifying key, plus `ns` when
9592    /// the pattern named one, or the executing role's sole namespace when it
9593    /// did not. A role bound to two or more namespaces cannot choose, and is
9594    /// refused with [`MERGE_CREATE_NEEDS_ONE_NAMESPACE`]. The authorizer still
9595    /// refuses a named `ns` the role cannot write.
9596    fn merge_create_props(
9597        &self,
9598        key_field: &str,
9599        key_value: &Value,
9600        named_ns: Option<&Value>,
9601    ) -> Result<Vec<(String, Value)>> {
9602        let mut props = vec![(key_field.to_string(), key_value.clone())];
9603        if let Some(ns) = named_ns {
9604            props.push((NS_PROP.to_string(), ns.clone()));
9605            return Ok(props);
9606        }
9607        if let Some(ns) = self.merge_create_stamp_ns()? {
9608            props.push((NS_PROP.to_string(), Value::Str(ns)));
9609        }
9610        Ok(props)
9611    }
9612
9613    /// The namespace a role-scoped MERGE create stamps when the pattern does
9614    /// not name `ns`. `None` = unscoped / full authority, so the node lands in
9615    /// `default`.
9616    fn merge_create_stamp_ns(&self) -> Result<Option<String>> {
9617        let Some(authz) = self.pending_write_authz.as_ref() else {
9618            return Ok(None);
9619        };
9620        let Some(def) = self.role_def_for(&authz.role) else {
9621            return Ok(None);
9622        };
9623        match def.namespaces.as_deref() {
9624            Some([only]) => Ok(Some(only.clone())),
9625            Some(_) => Err(GraphError::RoleWriteDenied {
9626                reason: MERGE_CREATE_NEEDS_ONE_NAMESPACE.to_string(),
9627            }),
9628            None => Ok(None),
9629        }
9630    }
9631
9632    fn exec_merge(
9633        &mut self,
9634        stmt: core_query::cypher::MergeStmt,
9635        params: &BTreeMap<String, Value>,
9636    ) -> Result<ResultSet> {
9637        // MERGE: check if a node with the given key already exists.
9638        let key = match &stmt.key_value {
9639            Value::Str(s) => s.clone(),
9640            _ => {
9641                return Err(GraphError::QueryError {
9642                    detail: format!(
9643                        "MERGE key value must be a string (got {:?})",
9644                        stmt.key_value
9645                    ),
9646                })
9647            }
9648        };
9649
9650        if let Some(var) = stmt.var.as_deref() {
9651            for sc in stmt.on_create.iter().chain(&stmt.on_match) {
9652                if sc.var != var {
9653                    return Err(GraphError::QueryError {
9654                        detail: format!(
9655                            "SET variable '{}' does not match MERGE variable '{var}'",
9656                            sc.var
9657                        ),
9658                    });
9659                }
9660            }
9661        }
9662
9663        // ── MERGE authz pre-check (when role-scoped) ─────────────────────────
9664        //
9665        // MERGE scope precondition: check create OR update scope for the
9666        // declared label BEFORE calling `has_node` (timing-oracle closure,
9667        // spec §6.2 "MERGE visibility oracle" item: hidden ≡ absent for
9668        // unscoped roles — the scope denial fires without touching the key store).
9669        //
9670        // Clone to avoid holding a borrow on `self.pending_write_authz` while
9671        // also calling `self.ids.get(key)`.
9672        let merge_existed: bool = if let Some(authz) = self.pending_write_authz.clone() {
9673            let has_create = authz.scope.create_labels.contains(&stmt.label);
9674            let has_update = authz.scope.update_labels.contains(&stmt.label);
9675            if !has_create && !has_update {
9676                // Scope-before-lookup: 403 without has_node call (timing oracle
9677                // closure — see test_merge_unscoped_no_key_lookup).
9678                return Err(GraphError::RoleWriteDenied {
9679                    reason: format!(
9680                        "role-bound token: label '{}' not in write scope (create_labels)",
9681                        stmt.label
9682                    ),
9683                });
9684            }
9685            // Key lookup under mask.
9686            match self.ids.get(key.as_str()) {
9687                Some(id) if authz.mask.contains_id(id) => {
9688                    // Visible: must have update scope to proceed to match arm.
9689                    if !has_update {
9690                        return Err(GraphError::RoleWriteDenied {
9691                            reason: format!(
9692                                "role-bound token: label '{}' not in write scope (update_labels)",
9693                                stmt.label
9694                            ),
9695                        });
9696                    }
9697                    true // existed = true → match arm
9698                }
9699                Some(_) => {
9700                    // Hidden: same error as absent to the role (spec §3.1/§3.3).
9701                    return Err(GraphError::RoleWriteDenied {
9702                        reason: "role-bound token: target node not visible".into(),
9703                    });
9704                }
9705                None => {
9706                    // Absent: must have create scope to proceed to the create arm.
9707                    //
9708                    // Update-only roles (create_labels empty, update_labels set):
9709                    // return the SAME "not visible" error as the hidden-key branch
9710                    // so hidden ≡ absent — no distinguishing oracle (spec §6.1
9711                    // "confirm existence of hidden nodes: No").
9712                    //
9713                    // Create-scoped roles (has_create=true): absent → create arm
9714                    // as before.  The accepted structural key-existence disclosure
9715                    // (§THREAT-MODEL) applies only when the role holds create scope.
9716                    if !has_create {
9717                        return Err(GraphError::RoleWriteDenied {
9718                            reason: "role-bound token: target node not visible".into(),
9719                        });
9720                    }
9721                    false // existed = false → create arm
9722                }
9723            }
9724        } else {
9725            // Full authority: use the existing non-masked has_node check.
9726            self.has_node(&key)
9727        };
9728
9729        let existed = merge_existed;
9730        let create_props = if existed {
9731            None
9732        } else {
9733            Some(self.merge_create_props(&stmt.key_field, &stmt.key_value, stmt.ns.as_ref())?)
9734        };
9735        let mut created = 0i64;
9736        if create_props.is_some() || !stmt.on_match.is_empty() {
9737            let mut batch = self.batch();
9738            if let Some(props) = create_props {
9739                batch.insert_node(&stmt.label, &key, props);
9740                for sc in &stmt.on_create {
9741                    let value = resolve_merge_set_value(&sc.value, params)?;
9742                    batch.set_prop(&key, &sc.field, value);
9743                }
9744                created = 1;
9745            } else {
9746                for sc in &stmt.on_match {
9747                    let value = resolve_merge_set_value(&sc.value, params)?;
9748                    batch.set_prop(&key, &sc.field, value);
9749                }
9750            }
9751            batch.commit()?;
9752        }
9753
9754        // Refresh the role mask so the just-created node is visible to this
9755        // statement's RETURN (read-after-write). Safe: create_labels ⊆ read labels
9756        // (apply_schema subset rule), so the new node's label is already in the
9757        // role's read scope — this never widens beyond the role's declared labels.
9758        if !existed {
9759            if let Some(role) = self.pending_write_authz.as_ref().map(|a| a.role.clone()) {
9760                let new_mask = self.mask_for_role(&role)?;
9761                if let Some(a) = self.pending_write_authz.as_mut() {
9762                    a.mask = new_mask;
9763                }
9764            }
9765        }
9766
9767        // Optional RETURN clause: project the node (created or matched) as a read result.
9768        if let Some(returns) = stmt.returns {
9769            let var = stmt.var.as_deref().unwrap_or("_mn0");
9770            let q = Query {
9771                matches: vec![Pattern {
9772                    start: NodePat {
9773                        var: Some(var.to_string()),
9774                        label: Some(stmt.label.clone()),
9775                        props: vec![("id".to_string(), Operand::Lit(stmt.key_value.clone()))],
9776                    },
9777                    chain: vec![],
9778                    shortest: false,
9779                }],
9780                optional_clauses: vec![],
9781                where_expr: None,
9782                unwinds: vec![],
9783                post_unwind_where: None,
9784                stages: vec![],
9785                returns,
9786                distinct: false,
9787                order_by: vec![],
9788                skip: None,
9789                limit: None,
9790            };
9791            let ops = plan(&q).map_err(|e| GraphError::QueryError {
9792                detail: format!("plan: {e}"),
9793            })?;
9794            // Use view_masked when a role-scoped write is in flight so the
9795            // post-merge projection is consistent with the masked read phase.
9796            let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
9797            return (if let Some(ref mask) = mask_opt {
9798                execute(&self.view_masked(mask), &ops, &Params(params))
9799            } else {
9800                execute(&self.view(), &ops, &Params(params))
9801            })
9802            .map_err(|e| GraphError::QueryError {
9803                detail: format!("execute: {e}"),
9804            });
9805        }
9806
9807        let mut rs = write_result_set();
9808        rs.push_row(vec![
9809            Some(Value::Int(created)),
9810            Some(Value::Int(0)),
9811            Some(Value::Int(0)),
9812        ]);
9813        Ok(rs)
9814    }
9815
9816    /// Return all rule-owned edges between `key_a` and `key_b` (either direction),
9817    /// annotated with rule name, edge type, direction, and weight.
9818    /// Results are sorted by (rule, edge_type).
9819    /// Returns `Err(KeyNotFound)` if either key is unknown.
9820    pub fn explain(&self, key_a: &str, key_b: &str) -> Result<Vec<Explanation>> {
9821        self.ensure_v8_base_sections_loaded();
9822        let id_a = self
9823            .ids
9824            .get(key_a)
9825            .ok_or_else(|| GraphError::KeyNotFound { key: key_a.into() })?;
9826        let id_b = self
9827            .ids
9828            .get(key_b)
9829            .ok_or_else(|| GraphError::KeyNotFound { key: key_b.into() })?;
9830
9831        let mut results = Vec::new();
9832
9833        // Walk the smaller incident set so explain is O(min(deg(a), deg(b)))
9834        // rather than O(total provenance).
9835        let scan = if self.engine.provenance_touching_len(id_a)
9836            <= self.engine.provenance_touching_len(id_b)
9837        {
9838            id_a
9839        } else {
9840            id_b
9841        };
9842        for (rule_name, etype, src, dst) in self.engine.provenance_touching(scan) {
9843            if !((src == id_a && dst == id_b) || (src == id_b && dst == id_a)) {
9844                continue;
9845            }
9846            let Some(rule_def) = self.engine.rules().find(|r| r.name == rule_name) else {
9847                continue;
9848            };
9849            let edge_type = match self.syms.resolve(etype) {
9850                Some(s) => s.to_string(),
9851                None => continue,
9852            };
9853            // Provenance (src, dst) ids come from the archived PROVENANCE section
9854            // (large, no eager CRC).  A corrupt section can produce ids that are
9855            // out of range; return Corrupt rather than panic.
9856            let src_key = self
9857                .ids
9858                .key_of(src)
9859                .ok_or_else(|| GraphError::Corrupt {
9860                    detail: format!("v8: provenance src id {src} not in id table"),
9861                })?
9862                .to_string();
9863            let dst_key = self
9864                .ids
9865                .key_of(dst)
9866                .ok_or_else(|| GraphError::Corrupt {
9867                    detail: format!("v8: provenance dst id {dst} not in id table"),
9868                })?
9869                .to_string();
9870            let stored = rule_def.weight_prop.as_deref().and_then(|prop| {
9871                self.edge_props_view()
9872                    .get(etype, src, dst, prop)
9873                    .and_then(|v| {
9874                        if let Value::Float(f) = v {
9875                            Some(f)
9876                        } else {
9877                            None
9878                        }
9879                    })
9880            });
9881            // Rules that store no weight (KeyMatch/FieldEqual defaults, auto-FK)
9882            // still have a score: recompute it from the predicate so explain
9883            // never reports "no score" for an edge the engine scored.  Via-hop
9884            // rules score over their via set, not over (src, dst), so leave
9885            // those None rather than report a number the rule did not produce.
9886            let weight = stored.or_else(|| {
9887                if rule_def.via_edge.is_some() {
9888                    return None;
9889                }
9890                let props_view = build_props_view(&self.props, &self.base);
9891                let src_get = |field: &str| props_view.get(src, field).map(|vr| vr.into_value());
9892                let dst_get = |field: &str| props_view.get(dst, field).map(|vr| vr.into_value());
9893                let src_view = NodeView {
9894                    key: &src_key,
9895                    props: &src_get,
9896                };
9897                let dst_view = NodeView {
9898                    key: &dst_key,
9899                    props: &dst_get,
9900                };
9901                evaluate(&rule_def.predicate, &src_view, &dst_view)
9902            });
9903            results.push(Explanation {
9904                rule: rule_name.to_string(),
9905                edge_type,
9906                src_key,
9907                dst_key,
9908                weight,
9909                predicate: PredicateSummary {
9910                    approximate: rule_def.approximate,
9911                    ..PredicateSummary::from(&rule_def.predicate)
9912                },
9913                via_edge: rule_def.via_edge.clone(),
9914            });
9915        }
9916
9917        results.sort_by(|a, b| a.rule.cmp(&b.rule).then(a.edge_type.cmp(&b.edge_type)));
9918        Ok(results)
9919    }
9920
9921    pub fn neighbors(&self, key: &str, edge_type: &str, dir: Direction) -> Result<Vec<String>> {
9922        let id = self
9923            .ids
9924            .get(key)
9925            .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
9926        let Some(sym) = self.syms.get(edge_type) else {
9927            return Ok(Vec::new());
9928        };
9929        self.topo_view()
9930            .neighbors(sym, dir, id)
9931            .iter()
9932            .map(|&n| {
9933                self.ids
9934                    .key_of(n)
9935                    .map(|k| k.to_string())
9936                    .ok_or_else(|| GraphError::Corrupt {
9937                        detail: format!("topology id {n} has no key"),
9938                    })
9939            })
9940            .collect::<Result<Vec<_>>>()
9941    }
9942
9943    /// Unique directed degree of `key`. Unknown key → [`GraphError::KeyNotFound`].
9944    /// Unknown `edge_type` → 0. [`crate::algo::AlgoDir::Both`] is out + in (sum).
9945    pub fn degree(
9946        &self,
9947        key: &str,
9948        edge_type: Option<&str>,
9949        direction: crate::algo::AlgoDir,
9950    ) -> Result<u64> {
9951        let id = self
9952            .ids
9953            .get(key)
9954            .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
9955        let topo = self.topo_view();
9956        Ok(Self::unique_directed_degree(
9957            &topo, &self.syms, id, edge_type, direction,
9958        ))
9959    }
9960
9961    /// Unique directed degree for a subset or a label scan.
9962    ///
9963    /// Unknown keys in `keys` are omitted (mask-like). `keys = Some(&[])` →
9964    /// empty `Ok(vec![])`. `limit` is applied after sorting degree desc, key
9965    /// asc, and only when `Some`. Invalid `where_` → `QueryError`.
9966    #[allow(clippy::too_many_arguments)]
9967    pub fn degrees(
9968        &self,
9969        keys: Option<&[String]>,
9970        label: Option<&str>,
9971        where_: Option<&PropPredicate>,
9972        edge_type: Option<&str>,
9973        direction: crate::algo::AlgoDir,
9974        limit: Option<usize>,
9975    ) -> Result<Vec<(String, u64)>> {
9976        if let Some(pred) = where_ {
9977            pred.validate_named("where")
9978                .map_err(|detail| GraphError::QueryError { detail })?;
9979        }
9980        if matches!(keys, Some(ks) if ks.is_empty()) {
9981            return Ok(Vec::new());
9982        }
9983        let view = self.view();
9984        let ids: Vec<u32> = match keys {
9985            Some(ks) => {
9986                let mut seen = HashSet::new();
9987                let mut out = Vec::new();
9988                for k in ks {
9989                    let Some(id) = view.ids.get(k) else {
9990                        continue;
9991                    };
9992                    if !seen.insert(id) {
9993                        continue;
9994                    }
9995                    if let Some(pred) = where_ {
9996                        let holds = match view.prop(id, &pred.field) {
9997                            None => pred.holds(None),
9998                            Some(vr) => pred.holds(Some(vr.as_value())),
9999                        };
10000                        if !holds {
10001                            continue;
10002                        }
10003                    }
10004                    out.push(id);
10005                }
10006                out
10007            }
10008            None => Self::vector_candidates(&view, label, where_),
10009        };
10010        let mut out: Vec<(String, u64)> = ids
10011            .into_iter()
10012            .filter_map(|id| {
10013                let key = self.ids.key_of(id)?.to_string();
10014                let deg =
10015                    Self::unique_directed_degree(&view.topo, view.syms, id, edge_type, direction);
10016                Some((key, deg))
10017            })
10018            .collect();
10019        out.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
10020        if let Some(lim) = limit {
10021            out.truncate(lim);
10022        }
10023        Ok(out)
10024    }
10025
10026    /// Unique neighbour count for `id` across `edge_type` (or all types) and
10027    /// `direction`. Unknown `edge_type` → 0. `Both` sums out + in.
10028    fn unique_directed_degree(
10029        topo: &TopologyView<'_>,
10030        syms: &Interner,
10031        id: u32,
10032        edge_type: Option<&str>,
10033        direction: crate::algo::AlgoDir,
10034    ) -> u64 {
10035        let dirs: &[Direction] = match direction {
10036            crate::algo::AlgoDir::Out => &[Direction::Out],
10037            crate::algo::AlgoDir::In => &[Direction::In],
10038            crate::algo::AlgoDir::Both => &[Direction::Out, Direction::In],
10039        };
10040        match edge_type {
10041            Some(name) => {
10042                let Some(et) = syms.get(name) else {
10043                    return 0;
10044                };
10045                dirs.iter().map(|&d| topo.degree(et, d, id) as u64).sum()
10046            }
10047            None => topo
10048                .etypes()
10049                .map(|et| {
10050                    dirs.iter()
10051                        .map(|&d| topo.degree(et, d, id) as u64)
10052                        .sum::<u64>()
10053                })
10054                .sum(),
10055        }
10056    }
10057
10058    /// Return the last-change commit sequence for `key`, or `None` if the node
10059    /// does not exist or has never been mutated since the last V5-V7 snapshot
10060    /// (horizon-bounded for legacy stores).
10061    ///
10062    /// The returned sequence is a monotonically increasing counter that starts
10063    /// at 1 for the first commit after `open` and increments with every
10064    /// successful write.  WAL replay at open also assigns sequences (1..N for N
10065    /// replayed frames), so sequences are consistent across snapshot+WAL cycles.
10066    ///
10067    /// For V5-V7 stores opened without a V8 snapshot, nodes that were present
10068    /// in the snapshot but not touched by any WAL frame will return `None`
10069    /// (horizon-bounded: CAS against such nodes is only safe after the first
10070    /// V8 snapshot or after the node is next mutated).
10071    pub fn last_changed(&self, key: &str) -> Option<u64> {
10072        let id = self.ids.get(key)?;
10073        self.last_change.get(&id).copied()
10074    }
10075
10076    /// The current commit sequence (number of successful commits since open,
10077    /// including WAL replay frames).  Useful for recording a baseline before
10078    /// a read-modify-write cycle.
10079    pub fn commit_seq(&self) -> u64 {
10080        self.commit_seq
10081    }
10082
10083    /// Check that all `preconds` are satisfied against the current db state.
10084    /// Returns `Err(GraphError::CasConflict)` on the first failing precondition.
10085    pub(crate) fn check_preconditions(&self, preconds: &[Precondition]) -> Result<()> {
10086        for precond in preconds {
10087            match precond {
10088                Precondition::NodeUnchangedSince { key, expected } => {
10089                    // Missing entry means the node predates the WAL window or
10090                    // does not exist; treat as 0 (before any commit).
10091                    let actual = self.last_changed(key).unwrap_or_default();
10092                    if actual != *expected {
10093                        return Err(GraphError::CasConflict {
10094                            key: key.clone(),
10095                            expected: *expected,
10096                            actual,
10097                        });
10098                    }
10099                }
10100                Precondition::NodeAbsent { key } => {
10101                    // Node must not exist (not live).
10102                    if self.ids.get(key).is_some() {
10103                        let actual = self.last_changed(key).unwrap_or(0);
10104                        return Err(GraphError::CasConflict {
10105                            key: key.clone(),
10106                            expected: u64::MAX,
10107                            actual,
10108                        });
10109                    }
10110                }
10111            }
10112        }
10113        Ok(())
10114    }
10115
10116    /// Apply a batch of mutations with compare-and-set preconditions.
10117    ///
10118    /// All preconditions are checked atomically before any operation is applied.
10119    /// If any precondition fails, the entire batch is rejected with
10120    /// [`GraphError::CasConflict`] and no WAL frame is written.
10121    ///
10122    /// # Returns
10123    /// `(nodes_inserted, edges_inserted)` on success, same as [`write_batch`].
10124    ///
10125    /// # Errors
10126    /// - [`GraphError::CasConflict`] if any precondition is not satisfied.
10127    /// - Any error that [`write_batch`] would return for the ops themselves.
10128    pub fn write_batch_cas(
10129        &mut self,
10130        preconds: Vec<Precondition>,
10131        ops: Vec<BatchOp>,
10132    ) -> Result<(usize, usize)> {
10133        self.check_preconditions(&preconds)?;
10134        self.commit_logged_batch(ops, None, None)
10135    }
10136
10137    /// Update the per-node last-change map for a WAL record at commit `seq`.
10138    ///
10139    /// Called after a successful apply to record which nodes were touched.
10140    /// For replay, called with the WAL-frame's replayed seq.
10141    ///
10142    /// Touch definition (see [`Precondition`] doc):
10143    /// - InsertNode / InsertNodeId / SetProp / SetPropId / RemoveProp → the node.
10144    /// - InsertEdge / InsertEdgeId / DeleteEdge → both src and dst.
10145    /// - DeleteNode → node tombstoned; last_changed() returns None so no update needed.
10146    /// - DerivedEdge markers, Intern, rule/view records → no-ops.
10147    /// - Batch → recurse into inner records.
10148    fn update_last_change_from_rec(&mut self, rec: &WalRecord, seq: u64) {
10149        match rec {
10150            WalRecord::InsertNode { key, .. }
10151            | WalRecord::SetProp { key, .. }
10152            | WalRecord::RemoveProp { key, .. } => {
10153                if let Some(id) = self.ids.get(key) {
10154                    self.last_change.insert(id, seq);
10155                }
10156            }
10157            WalRecord::InsertNodeId { key, .. } => {
10158                if let Some(id) = self.ids.get(key) {
10159                    self.last_change.insert(id, seq);
10160                }
10161            }
10162            WalRecord::SetPropId { id, .. } => {
10163                self.last_change.insert(*id, seq);
10164            }
10165            WalRecord::InsertEdge {
10166                src_key, dst_key, ..
10167            }
10168            | WalRecord::DeleteEdge {
10169                src_key, dst_key, ..
10170            } => {
10171                if let Some(src_id) = self.ids.get(src_key) {
10172                    self.last_change.insert(src_id, seq);
10173                }
10174                if let Some(dst_id) = self.ids.get(dst_key) {
10175                    self.last_change.insert(dst_id, seq);
10176                }
10177            }
10178            WalRecord::InsertEdgeId { src, dst, .. } => {
10179                self.last_change.insert(*src, seq);
10180                self.last_change.insert(*dst, seq);
10181            }
10182            // DeleteNode: node is tombstoned; last_changed(key) returns None for
10183            // deleted keys (ids.get() returns None post-tombstone), so no update needed.
10184            // History markers: state no-ops; the underlying mutation already
10185            // touched the relevant nodes' last_change entries.
10186            WalRecord::DeleteNode { .. }
10187            | WalRecord::DerivedEdgeAdded { .. }
10188            | WalRecord::DerivedEdgeRetracted { .. }
10189            | WalRecord::Intern { .. }
10190            | WalRecord::CreateRule { .. }
10191            | WalRecord::DeleteRule { .. }
10192            | WalRecord::RebuildRule { .. }
10193            | WalRecord::CreateView { .. }
10194            | WalRecord::DeleteView { .. }
10195            | WalRecord::EnableFulltext { .. }
10196            | WalRecord::DisableFulltext { .. }
10197            | WalRecord::EnableIndex { .. }
10198            | WalRecord::DisableIndex { .. } => {}
10199            // RenameNode: node id is stable; update last_change via the new key.
10200            // Called after apply(), so ids already reflects new_key.
10201            WalRecord::RenameNode { new_key, .. } => {
10202                if let Some(id) = self.ids.get(new_key) {
10203                    self.last_change.insert(id, seq);
10204                }
10205            }
10206            WalRecord::Batch(inner) => {
10207                for inner_rec in inner {
10208                    self.update_last_change_from_rec(inner_rec, seq);
10209                }
10210            }
10211        }
10212    }
10213
10214    pub fn node_count(&self) -> usize {
10215        self.ids.len()
10216    }
10217
10218    /// Configure archive retention: keep the `N` newest WAL archives at each
10219    /// [`snapshot_with`] call when `archive_wal: true`.
10220    ///
10221    /// `Some(N)` where N > 0 → prune oldest archives keeping the newest N.
10222    /// `Some(0)` or `None` → unlimited (no pruning).
10223    ///
10224    /// Pruning only ever happens inside [`snapshot_with`]; this method only
10225    /// stores the policy.  Archives below the retention limit are deleted
10226    /// oldest-first.  The horizon floor is updated so that
10227    /// [`was_linked`] / history APIs return `CommitOutOfRange` for commits
10228    /// in pruned archives rather than silently returning wrong data.
10229    pub fn set_wal_archive_retention(&mut self, keep: Option<u32>) {
10230        self.wal_archive_retention = keep;
10231    }
10232
10233    /// Delete any WAL archives that are fully below the current horizon floor.
10234    ///
10235    /// Orphaned archives arise when the floor is written first during retention
10236    /// pruning and then a crash interrupts the archive-delete sequence.  The
10237    /// opening cleanup ensures no subsequent read path sees stale data.
10238    ///
10239    /// Under the monotonic naming scheme, the archive name N equals the
10240    /// cumulative end-frame index of the archive in global commit space (i.e.
10241    /// the archive covers global frames `[prev_n, N)`).  An archive is
10242    /// fully orphaned when `N <= wal_horizon_floor`: all of its frames fall
10243    /// below the floor and have already been counted in it.
10244    fn cleanup_orphaned_archives(&mut self) -> Result<()> {
10245        if self.wal_horizon_floor == 0 {
10246            // Floor at 0 means no pruning has ever occurred; nothing to clean.
10247            return Ok(());
10248        }
10249        let archive_ns = self.fs.list_archives()?;
10250        for n in archive_ns {
10251            if n <= self.wal_horizon_floor {
10252                // Archive N ends at global frame N; all its frames are below
10253                // the floor (floor already accounts for them) → orphaned.
10254                self.fs.delete_archive(n).map_err(GraphError::Io)?;
10255            } else {
10256                // Archives are sorted ascending; first one above floor stops scan.
10257                break;
10258            }
10259        }
10260        Ok(())
10261    }
10262
10263    /// Collect all WAL frames from surviving archives (oldest-first) then the
10264    /// live WAL into one flat list, and return the total along with the number
10265    /// of archive frames at the front of the list.
10266    ///
10267    /// Commit indices into the returned list are LOCAL (0 = first frame of
10268    /// oldest surviving archive).  To obtain the GLOBAL index add
10269    /// `self.wal_horizon_floor`.
10270    fn all_frames(&self) -> Result<(Vec<WalRecord>, u64)> {
10271        let archive_ns = self.fs.list_archives()?;
10272        let mut all: Vec<WalRecord> = Vec::new();
10273        for n in archive_ns {
10274            let bytes = self.fs.read_archive(n)?;
10275            let (frames, _) = decode_all(&bytes);
10276            all.extend(frames);
10277        }
10278        let archive_count = all.len() as u64;
10279        let live_bytes = self.fs.read(FileId::Wal)?;
10280        let (live_frames, _) = decode_all(&live_bytes);
10281        all.extend(live_frames);
10282        Ok((all, archive_count))
10283    }
10284
10285    /// Return the total number of committed WAL frames visible in the current
10286    /// horizon window, including frames in surviving WAL archives.
10287    ///
10288    /// This is the exclusive upper bound for valid `at_commit` indices in
10289    /// `was_linked`. Valid indices are `wal_horizon_floor()..wal_total_commits()`.
10290    ///
10291    /// Returns the horizon floor when all surviving history is empty.
10292    pub fn wal_total_commits(&self) -> Result<u64> {
10293        let (frames, _) = self.all_frames()?;
10294        Ok(self.wal_horizon_floor + frames.len() as u64)
10295    }
10296
10297    /// The global frame index of the first commit reachable through surviving
10298    /// archives (0 when no archives have been pruned).
10299    pub fn wal_horizon_floor(&self) -> u64 {
10300        self.wal_horizon_floor
10301    }
10302
10303    /// Return the per-node change history for `key` by scanning the on-disk WAL.
10304    ///
10305    /// ## Horizon
10306    ///
10307    /// History reaches back only to the last WAL-truncating snapshot, exactly like `open_at`.
10308    /// Snapshots written with `keep_wal: true` preserve deeper history. This is the honest,
10309    /// zero-cost contract; a durable history log is out of scope.
10310    ///
10311    /// ## Derived edges
10312    ///
10313    /// Rule-created (derived) edges are **not** in the WAL and therefore do not appear in
10314    /// history. Only edges written directly by the application are recorded.
10315    ///
10316    /// ## Deleted nodes
10317    ///
10318    /// For nodes that have been deleted, dense-id records (SetPropId, InsertEdgeId) that
10319    /// predate the deletion may not resolve (the id is tombstoned in the live map). The
10320    /// string-keyed `DeleteNode` record still matches and produces a `NodeDeleted` entry.
10321    /// Prop/edge history of a deleted node may therefore be partially unresolvable.
10322    ///
10323    /// ## Dense-id edge entries and tombstoned partners
10324    ///
10325    /// Edge entries from dense-id WAL records (`InsertEdgeId`) are omitted when the partner
10326    /// endpoint's dense id is tombstoned. As a result, a live node's history can contain an
10327    /// `EdgeRemoved` (string-keyed, always resolves) without a corresponding `EdgeAdded`.
10328    /// Build commit-bounded alias intervals for `queried_key`.
10329    ///
10330    /// Returns a list of `(key, valid_from_inclusive, valid_until_exclusive)` tuples.
10331    /// A record written under `key` at commit `c` matches the queried identity iff
10332    /// `c >= valid_from && (valid_until.is_none() || c < valid_until)`.
10333    ///
10334    /// Each alias entry carries both a lower and an upper bound so that key-reuse
10335    /// after a rename is handled correctly: if "a" is renamed to "b" at commit 5,
10336    /// then a NEW node is created as "a" at commit 7 and renamed to "c" at commit 10,
10337    /// querying "c" must NOT surface identity-1's events (commits 0–4 under "a");
10338    /// only identity-2's events (commits 7–9 under "a") are in scope.
10339    ///
10340    /// Only **forward aliasing**: querying the *new* key surfaces events written
10341    /// under the *old* key.  The reverse direction is not supported.
10342    fn build_key_alias_intervals(
10343        &self,
10344        frames: &[core_storage::wal::WalRecord],
10345        queried_key: &str,
10346    ) -> Vec<(String, u64, Option<u64>)> {
10347        use core_storage::wal::WalRecord;
10348
10349        // Pre-pass: build reverse_rename and key_starts maps.
10350        let mut reverse_rename: HashMap<String, (String, u64)> = HashMap::new();
10351        let mut key_starts: HashMap<String, Vec<u64>> = HashMap::new();
10352
10353        for (local_i, frame) in frames.iter().enumerate() {
10354            let commit = self.wal_horizon_floor + local_i as u64;
10355            let records: &[WalRecord] = match frame {
10356                WalRecord::Batch(inner) => inner.as_slice(),
10357                single => std::slice::from_ref(single),
10358            };
10359            for rec in records {
10360                match rec {
10361                    WalRecord::InsertNode { key, .. } | WalRecord::InsertNodeId { key, .. } => {
10362                        key_starts.entry(key.clone()).or_default().push(commit);
10363                    }
10364                    WalRecord::RenameNode { old_key, new_key } => {
10365                        // new_key came into existence at this commit.
10366                        key_starts.entry(new_key.clone()).or_default().push(commit);
10367                        // Record the reverse rename: new_key was introduced by renaming old_key.
10368                        reverse_rename.insert(new_key.clone(), (old_key.clone(), commit));
10369                    }
10370                    _ => {}
10371                }
10372            }
10373        }
10374
10375        // Build alias intervals by following the reverse rename chain.
10376        let mut result: Vec<(String, u64, Option<u64>)> = Vec::new();
10377        let mut current_key = queried_key.to_string();
10378        let mut current_valid_until: Option<u64> = None;
10379
10380        loop {
10381            // valid_from: the most recent commit where current_key was assigned to this
10382            // identity.  For aliases (valid_until = Some(vu)), find the last start event
10383            // for the key strictly before vu — this is where the alias's occupancy by
10384            // this identity began, correctly excluding prior identities that reused the key.
10385            let valid_from = if let Some(vu) = current_valid_until {
10386                key_starts
10387                    .get(&current_key)
10388                    .and_then(|starts| starts.iter().rev().find(|&&s| s < vu).copied())
10389                    .unwrap_or(self.wal_horizon_floor)
10390            } else {
10391                // Queried key — no upper bound; may have been introduced at any commit.
10392                self.wal_horizon_floor
10393            };
10394
10395            result.push((current_key.clone(), valid_from, current_valid_until));
10396
10397            match reverse_rename.get(&current_key) {
10398                Some((old_key, rename_commit)) => {
10399                    current_valid_until = Some(*rename_commit);
10400                    current_key = old_key.clone();
10401                }
10402                None => break,
10403            }
10404        }
10405
10406        result
10407    }
10408
10409    /// Returns true if `record_key` matches any alias interval that covers `commit`.
10410    fn aliases_match(
10411        intervals: &[(String, u64, Option<u64>)],
10412        record_key: &str,
10413        commit: u64,
10414    ) -> bool {
10415        intervals
10416            .iter()
10417            .any(|(k, vf, vu)| k == record_key && commit >= *vf && vu.is_none_or(|u| commit < u))
10418    }
10419
10420    /// Return the change history of node `key` by scanning the on-disk WAL.
10421    ///
10422    /// ## Horizon
10423    ///
10424    /// History reaches back only as far as the retained WAL. The returned
10425    /// [`HistoryResult`](crate::history::HistoryResult) carries `total_commits`
10426    /// (the exclusive upper bound for valid commit indices) and `horizon` (the
10427    /// oldest commit still reachable). When `horizon > 0`, older events were
10428    /// pruned and are not in `items`.
10429    pub fn node_history(
10430        &self,
10431        key: &str,
10432    ) -> Result<crate::history::HistoryResult<crate::history::HistoryEntry>> {
10433        use crate::history::{HistoryChange, HistoryEntry, HistoryResult};
10434        use core_storage::wal::WalRecord;
10435
10436        let (frames, _) = self.all_frames()?;
10437        let total_commits = self.wal_horizon_floor + frames.len() as u64;
10438
10439        // Resolve commit-bounded alias intervals for `key` (handles renames in the WAL).
10440        let alias_intervals = self.build_key_alias_intervals(&frames, key);
10441
10442        let mut out: Vec<HistoryEntry> = Vec::new();
10443
10444        for (local_i, frame) in frames.iter().enumerate() {
10445            let commit = self.wal_horizon_floor + local_i as u64;
10446            // Collect the inner records to process — Batch is one commit, single records are one commit.
10447            let records: &[WalRecord] = match frame {
10448                WalRecord::Batch(inner) => inner.as_slice(),
10449                single => std::slice::from_ref(single),
10450            };
10451
10452            for rec in records {
10453                let change = match rec {
10454                    WalRecord::InsertNode { label, key: k, .. }
10455                        if Self::aliases_match(&alias_intervals, k, commit) =>
10456                    {
10457                        Some(HistoryChange::NodeInserted {
10458                            label: label.clone(),
10459                        })
10460                    }
10461                    WalRecord::InsertNodeId { label, key: k, .. }
10462                        if Self::aliases_match(&alias_intervals, k, commit) =>
10463                    {
10464                        let label_str = match self.syms.resolve(*label) {
10465                            Some(s) => s.to_string(),
10466                            None => continue,
10467                        };
10468                        Some(HistoryChange::NodeInserted { label: label_str })
10469                    }
10470                    WalRecord::SetProp {
10471                        key: k,
10472                        field,
10473                        value,
10474                    } if Self::aliases_match(&alias_intervals, k, commit) => {
10475                        Some(HistoryChange::PropSet {
10476                            field: field.clone(),
10477                            value: value.clone(),
10478                        })
10479                    }
10480                    WalRecord::SetPropId { id, field, value } => {
10481                        // Use key_of_historical (not key_of) so a node's prop_set
10482                        // events remain visible after the node is later deleted:
10483                        // key_of returns None for a tombstoned id, which would
10484                        // silently drop every PropSet between insert and delete.
10485                        // Mirrors the InsertEdgeId arm below and edge_history's
10486                        // own id-keyed arms.
10487                        match self.ids.key_of_historical(*id) {
10488                            // key_of_historical returns the last-known (possibly
10489                            // post-rename, possibly post-delete) key; compare to queried key.
10490                            Some(resolved) if resolved == key => {
10491                                let field_str = match self.syms.resolve(*field) {
10492                                    Some(s) => s.to_string(),
10493                                    None => continue,
10494                                };
10495                                Some(HistoryChange::PropSet {
10496                                    field: field_str,
10497                                    value: value.clone(),
10498                                })
10499                            }
10500                            _ => None,
10501                        }
10502                    }
10503                    WalRecord::RemoveProp { key: k, field }
10504                        if Self::aliases_match(&alias_intervals, k, commit) =>
10505                    {
10506                        Some(HistoryChange::PropRemoved {
10507                            field: field.clone(),
10508                        })
10509                    }
10510                    WalRecord::InsertEdge {
10511                        edge_type,
10512                        src_key,
10513                        dst_key,
10514                    } => {
10515                        if Self::aliases_match(&alias_intervals, src_key, commit) {
10516                            Some(HistoryChange::EdgeAdded {
10517                                edge_type: edge_type.clone(),
10518                                other: dst_key.clone(),
10519                                outgoing: true,
10520                            })
10521                        } else if Self::aliases_match(&alias_intervals, dst_key, commit) {
10522                            Some(HistoryChange::EdgeAdded {
10523                                edge_type: edge_type.clone(),
10524                                other: src_key.clone(),
10525                                outgoing: false,
10526                            })
10527                        } else {
10528                            None
10529                        }
10530                    }
10531                    WalRecord::InsertEdgeId { etype, src, dst } => {
10532                        let etype_str = match self.syms.resolve(*etype) {
10533                            Some(s) => s.to_string(),
10534                            None => continue,
10535                        };
10536                        // key_of_historical (not key_of): an edge added before
10537                        // either endpoint was later deleted must still resolve —
10538                        // see the SetPropId arm above and edge_history's
10539                        // InsertEdgeId arm, which use the same lookup for the
10540                        // same reason.
10541                        let src_key = self.ids.key_of_historical(*src);
10542                        let dst_key = self.ids.key_of_historical(*dst);
10543                        if src_key == Some(key) {
10544                            let other = match dst_key {
10545                                Some(s) => s.to_string(),
10546                                None => continue,
10547                            };
10548                            Some(HistoryChange::EdgeAdded {
10549                                edge_type: etype_str,
10550                                other,
10551                                outgoing: true,
10552                            })
10553                        } else if dst_key == Some(key) {
10554                            let other = match src_key {
10555                                Some(s) => s.to_string(),
10556                                None => continue,
10557                            };
10558                            Some(HistoryChange::EdgeAdded {
10559                                edge_type: etype_str,
10560                                other,
10561                                outgoing: false,
10562                            })
10563                        } else {
10564                            None
10565                        }
10566                    }
10567                    WalRecord::DeleteEdge {
10568                        edge_type,
10569                        src_key,
10570                        dst_key,
10571                    } => {
10572                        if Self::aliases_match(&alias_intervals, src_key, commit) {
10573                            Some(HistoryChange::EdgeRemoved {
10574                                edge_type: edge_type.clone(),
10575                                other: dst_key.clone(),
10576                                outgoing: true,
10577                            })
10578                        } else if Self::aliases_match(&alias_intervals, dst_key, commit) {
10579                            Some(HistoryChange::EdgeRemoved {
10580                                edge_type: edge_type.clone(),
10581                                other: src_key.clone(),
10582                                outgoing: false,
10583                            })
10584                        } else {
10585                            None
10586                        }
10587                    }
10588                    WalRecord::DeleteNode { key: k }
10589                        if Self::aliases_match(&alias_intervals, k, commit) =>
10590                    {
10591                        Some(HistoryChange::NodeDeleted)
10592                    }
10593                    // Skip: rule/view/fulltext/intern metadata; Batch wrapper handled above.
10594                    _ => None,
10595                };
10596
10597                if let Some(change) = change {
10598                    out.push(HistoryEntry { commit, change });
10599                }
10600            }
10601        }
10602
10603        Ok(HistoryResult {
10604            items: out,
10605            total_commits,
10606            horizon: self.wal_horizon_floor,
10607        })
10608    }
10609
10610    /// Return the per-edge change history between nodes `a` and `b` by scanning
10611    /// the on-disk WAL.
10612    ///
10613    /// ## Horizon
10614    ///
10615    /// History reaches back only to the last WAL-truncating snapshot, exactly
10616    /// like `node_history` and `open_at`. The returned [`HistoryResult`] carries
10617    /// `total_commits` (= number of WAL frames), which is the exclusive upper
10618    /// bound for valid commit indices.
10619    ///
10620    /// ## Derived edges
10621    ///
10622    /// Rule-derived edges appear via `DerivedEdgeAdded` / `DerivedEdgeRetracted`
10623    /// WAL markers written by `log_then_apply_with` after each rule-firing
10624    /// mutation. The `rule` field of those events carries the rule name.
10625    ///
10626    /// ## DeleteNode
10627    ///
10628    /// When a node is deleted, its manual incident edges are swept inline without
10629    /// individual `DeleteEdge` WAL records. `edge_history` detects `DeleteNode`
10630    /// events for either endpoint and synthesises `Retracted(rule:None)` events
10631    /// for each manual edge that was active at that point. Derived edges active at
10632    /// the time of deletion are handled by the `DerivedEdgeRetracted` marker that
10633    /// the engine appends immediately after the `DeleteNode` record; those events
10634    /// carry correct rule attribution and are emitted by the marker arm, not the
10635    /// synthetic sweep.
10636    ///
10637    /// ## Masks
10638    ///
10639    /// Like `node_history`, this method has no mask parameter and returns WAL
10640    /// history regardless of any role mask. For masked history semantics, apply
10641    /// the mask at the caller level.
10642    pub fn edge_history(
10643        &self,
10644        a: &str,
10645        b: &str,
10646    ) -> Result<crate::history::HistoryResult<crate::history::EdgeHistoryEvent>> {
10647        use crate::history::{EdgeEvent, EdgeHistoryEvent, HistoryResult};
10648        use core_storage::wal::WalRecord;
10649
10650        let (frames, _) = self.all_frames()?;
10651        let total_commits = self.wal_horizon_floor + frames.len() as u64;
10652
10653        // Resolve all historical names for a and b (handles RenameNode in the WAL).
10654        // Intervals are commit-bounded so recycled keys don't contaminate histories.
10655        let alias_a = self.build_key_alias_intervals(&frames, a);
10656        let alias_b = self.build_key_alias_intervals(&frames, b);
10657
10658        // Active edges between a and b tracked as (edge_type, src_key, dst_key, is_derived).
10659        // The is_derived flag is used by the DeleteNode sweep: manual edges are
10660        // swept with a synthetic Retracted(rule:None); derived edges are skipped
10661        // because the engine writes a DerivedEdgeRetracted marker immediately after
10662        // the DeleteNode record, which carries the correct rule attribution.
10663        let mut active: Vec<(String, String, String, bool)> = Vec::new();
10664        let mut out: Vec<EdgeHistoryEvent> = Vec::new();
10665
10666        for (local_i, frame) in frames.iter().enumerate() {
10667            let commit = self.wal_horizon_floor + local_i as u64;
10668            let records: &[WalRecord] = match frame {
10669                WalRecord::Batch(inner) => inner.as_slice(),
10670                single => std::slice::from_ref(single),
10671            };
10672
10673            for rec in records {
10674                match rec {
10675                    WalRecord::InsertEdge {
10676                        edge_type,
10677                        src_key,
10678                        dst_key,
10679                    } => {
10680                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
10681                            && Self::aliases_match(&alias_b, dst_key, commit);
10682                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
10683                            && Self::aliases_match(&alias_a, dst_key, commit);
10684                        if is_ab || is_ba {
10685                            active.push((
10686                                edge_type.clone(),
10687                                src_key.clone(),
10688                                dst_key.clone(),
10689                                false,
10690                            ));
10691                            out.push(EdgeHistoryEvent {
10692                                edge_type: edge_type.clone(),
10693                                commit,
10694                                event: EdgeEvent::Added,
10695                                rule: None,
10696                            });
10697                        }
10698                    }
10699                    WalRecord::InsertEdgeId { etype, src, dst } => {
10700                        let etype_str = match self.syms.resolve(*etype) {
10701                            Some(s) => s.to_string(),
10702                            None => continue,
10703                        };
10704                        // Use key_of_historical so tombstoned nodes (deleted
10705                        // later in the WAL) still resolve during the scan.
10706                        let src_key = self.ids.key_of_historical(*src);
10707                        let dst_key = self.ids.key_of_historical(*dst);
10708                        let is_ab = src_key == Some(a) && dst_key == Some(b);
10709                        let is_ba = src_key == Some(b) && dst_key == Some(a);
10710                        if is_ab || is_ba {
10711                            let src_str = src_key.unwrap().to_string();
10712                            let dst_str = dst_key.unwrap().to_string();
10713                            active.push((etype_str.clone(), src_str, dst_str, false));
10714                            out.push(EdgeHistoryEvent {
10715                                edge_type: etype_str,
10716                                commit,
10717                                event: EdgeEvent::Added,
10718                                rule: None,
10719                            });
10720                        }
10721                    }
10722                    WalRecord::DeleteEdge {
10723                        edge_type,
10724                        src_key,
10725                        dst_key,
10726                    } => {
10727                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
10728                            && Self::aliases_match(&alias_b, dst_key, commit);
10729                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
10730                            && Self::aliases_match(&alias_a, dst_key, commit);
10731                        if is_ab || is_ba {
10732                            // Remove the first matching active entry (flag ignored).
10733                            if let Some(pos) = active.iter().position(|(et, s, d, _)| {
10734                                et == edge_type && s == src_key && d == dst_key
10735                            }) {
10736                                active.remove(pos);
10737                            }
10738                            out.push(EdgeHistoryEvent {
10739                                edge_type: edge_type.clone(),
10740                                commit,
10741                                event: EdgeEvent::Retracted,
10742                                rule: None,
10743                            });
10744                        }
10745                    }
10746                    WalRecord::DeleteNode { key: k }
10747                        if Self::aliases_match(&alias_a, k, commit)
10748                            || Self::aliases_match(&alias_b, k, commit) =>
10749                    {
10750                        // Sweep: implicitly retract only MANUAL active edges.
10751                        // Derived active edges are skipped here because the rule
10752                        // engine appends a DerivedEdgeRetracted marker immediately
10753                        // after this DeleteNode record; that marker produces the
10754                        // single correctly-attributed Retracted event.  Derived
10755                        // entries are dropped from `active` (the marker arm's
10756                        // idempotent retain finds nothing to remove).
10757                        for (et, _, _, is_derived) in active.drain(..) {
10758                            if !is_derived {
10759                                out.push(EdgeHistoryEvent {
10760                                    edge_type: et,
10761                                    commit,
10762                                    event: EdgeEvent::Retracted,
10763                                    rule: None,
10764                                });
10765                            }
10766                            // Derived: drop silently; marker carries the Retracted event.
10767                        }
10768                    }
10769                    WalRecord::DerivedEdgeAdded {
10770                        rule,
10771                        edge_type: et,
10772                        src_key,
10773                        dst_key,
10774                    } => {
10775                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
10776                            && Self::aliases_match(&alias_b, dst_key, commit);
10777                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
10778                            && Self::aliases_match(&alias_a, dst_key, commit);
10779                        if is_ab || is_ba {
10780                            active.push((et.clone(), src_key.clone(), dst_key.clone(), true));
10781                            out.push(EdgeHistoryEvent {
10782                                edge_type: et.clone(),
10783                                commit,
10784                                event: EdgeEvent::Added,
10785                                rule: Some(rule.clone()),
10786                            });
10787                        }
10788                    }
10789                    WalRecord::DerivedEdgeRetracted {
10790                        rule,
10791                        edge_type: et,
10792                        src_key,
10793                        dst_key,
10794                    } => {
10795                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
10796                            && Self::aliases_match(&alias_b, dst_key, commit);
10797                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
10798                            && Self::aliases_match(&alias_a, dst_key, commit);
10799                        if is_ab || is_ba {
10800                            // Push unconditionally: a derived edge whose Added marker
10801                            // predates the history horizon has no `active` entry, but
10802                            // the retraction is still a real in-window event.
10803                            // Remove from active idempotently if present.
10804                            active.retain(|(aet, s, d, _)| {
10805                                !(aet == et && s == src_key && d == dst_key)
10806                            });
10807                            out.push(EdgeHistoryEvent {
10808                                edge_type: et.clone(),
10809                                commit,
10810                                event: EdgeEvent::Retracted,
10811                                rule: Some(rule.clone()),
10812                            });
10813                        }
10814                    }
10815                    // All other records (InsertNode, SetProp, CreateRule, etc.)
10816                    // do not affect edges between a and b.
10817                    _ => {}
10818                }
10819            }
10820        }
10821
10822        Ok(HistoryResult {
10823            items: out,
10824            total_commits,
10825            horizon: self.wal_horizon_floor,
10826        })
10827    }
10828
10829    /// Return `true` iff an edge of `edge_type` existed between `a` and `b`
10830    /// (in either direction) at the WAL commit `at_commit`.
10831    ///
10832    /// ## Horizon
10833    ///
10834    /// Valid commit indices are `0..total_commits` where `total_commits` is the
10835    /// number of WAL frames. An `at_commit >= total_commits` is outside the
10836    /// visible horizon and returns [`GraphError::CommitOutOfRange`].
10837    ///
10838    /// ## Derived edges
10839    ///
10840    /// Rule-derived edges are tracked via `DerivedEdgeAdded` / `DerivedEdgeRetracted`
10841    /// WAL markers appended at firing time (Task 1). `was_linked` reads these markers
10842    /// and therefore includes derived edges in its point-in-time evaluation,
10843    /// matching `edge_history`'s fidelity.
10844    pub fn was_linked(&self, a: &str, b: &str, edge_type: &str, at_commit: u64) -> Result<bool> {
10845        use core_storage::wal::WalRecord;
10846
10847        let (frames, _) = self.all_frames()?;
10848        let total_commits = self.wal_horizon_floor + frames.len() as u64;
10849
10850        // Horizon floor: commits in pruned archives are unreachable.
10851        if at_commit < self.wal_horizon_floor {
10852            return Err(GraphError::CommitOutOfRange {
10853                commit: at_commit,
10854                total: total_commits,
10855                floor: self.wal_horizon_floor,
10856            });
10857        }
10858        if at_commit >= total_commits {
10859            return Err(GraphError::CommitOutOfRange {
10860                commit: at_commit,
10861                total: total_commits,
10862                floor: self.wal_horizon_floor,
10863            });
10864        }
10865
10866        // Resolve all historical names for a and b (handles RenameNode in the WAL).
10867        // Intervals are commit-bounded so recycled keys don't contaminate point-in-time reads.
10868        let alias_a = self.build_key_alias_intervals(&frames, a);
10869        let alias_b = self.build_key_alias_intervals(&frames, b);
10870
10871        // Local index into surviving frames (0 = first frame of oldest archive).
10872        let local_commit = at_commit - self.wal_horizon_floor;
10873
10874        // Replay local frames 0..=local_commit, tracking active edges.
10875        let mut active: BTreeSet<(String, String, String)> = BTreeSet::new();
10876
10877        for (local_i, frame) in frames.iter().enumerate().take((local_commit + 1) as usize) {
10878            let commit = self.wal_horizon_floor + local_i as u64;
10879            let records: &[WalRecord] = match frame {
10880                WalRecord::Batch(inner) => inner.as_slice(),
10881                single => std::slice::from_ref(single),
10882            };
10883
10884            for rec in records {
10885                match rec {
10886                    WalRecord::InsertEdge {
10887                        edge_type: et,
10888                        src_key,
10889                        dst_key,
10890                    } => {
10891                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
10892                            && Self::aliases_match(&alias_b, dst_key, commit);
10893                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
10894                            && Self::aliases_match(&alias_a, dst_key, commit);
10895                        if is_ab || is_ba {
10896                            active.insert((et.clone(), src_key.clone(), dst_key.clone()));
10897                        }
10898                    }
10899                    WalRecord::InsertEdgeId { etype, src, dst } => {
10900                        let etype_str = match self.syms.resolve(*etype) {
10901                            Some(s) => s.to_string(),
10902                            None => continue,
10903                        };
10904                        // Use key_of_historical so tombstoned nodes resolve.
10905                        let src_key = self.ids.key_of_historical(*src);
10906                        let dst_key = self.ids.key_of_historical(*dst);
10907                        let is_ab = src_key == Some(a) && dst_key == Some(b);
10908                        let is_ba = src_key == Some(b) && dst_key == Some(a);
10909                        if is_ab || is_ba {
10910                            active.insert((
10911                                etype_str,
10912                                src_key.unwrap().to_string(),
10913                                dst_key.unwrap().to_string(),
10914                            ));
10915                        }
10916                    }
10917                    WalRecord::DeleteEdge {
10918                        edge_type: et,
10919                        src_key,
10920                        dst_key,
10921                    } => {
10922                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
10923                            && Self::aliases_match(&alias_b, dst_key, commit);
10924                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
10925                            && Self::aliases_match(&alias_a, dst_key, commit);
10926                        if is_ab || is_ba {
10927                            active.remove(&(et.clone(), src_key.clone(), dst_key.clone()));
10928                        }
10929                    }
10930                    WalRecord::DeleteNode { key: k }
10931                        if Self::aliases_match(&alias_a, k, commit)
10932                            || Self::aliases_match(&alias_b, k, commit) =>
10933                    {
10934                        // All edges touching the deleted node are gone.
10935                        active.retain(|(_, s, d)| s != k && d != k);
10936                    }
10937                    WalRecord::DerivedEdgeAdded {
10938                        edge_type: et,
10939                        src_key,
10940                        dst_key,
10941                        ..
10942                    } => {
10943                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
10944                            && Self::aliases_match(&alias_b, dst_key, commit);
10945                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
10946                            && Self::aliases_match(&alias_a, dst_key, commit);
10947                        if is_ab || is_ba {
10948                            active.insert((et.clone(), src_key.clone(), dst_key.clone()));
10949                        }
10950                    }
10951                    WalRecord::DerivedEdgeRetracted {
10952                        edge_type: et,
10953                        src_key,
10954                        dst_key,
10955                        ..
10956                    } => {
10957                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
10958                            && Self::aliases_match(&alias_b, dst_key, commit);
10959                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
10960                            && Self::aliases_match(&alias_a, dst_key, commit);
10961                        if is_ab || is_ba {
10962                            active.remove(&(et.clone(), src_key.clone(), dst_key.clone()));
10963                        }
10964                    }
10965                    _ => {}
10966                }
10967            }
10968        }
10969
10970        Ok(active.iter().any(|(et, _, _)| et == edge_type))
10971    }
10972
10973    /// Every edge incident to `key` — either endpoint — that existed at WAL
10974    /// commit `commit`, from ONE scan of the WAL.
10975    ///
10976    /// This is the bulk form of [`was_linked`](GraphDb::was_linked): answering
10977    /// "what did K's relationships look like at commit C" with one call instead
10978    /// of one [`edge_history`](GraphDb::edge_history) per candidate partner.
10979    /// The two agree edge for edge.
10980    ///
10981    /// Results are sorted by `(edge_type, src_key, dst_key)`.
10982    ///
10983    /// ## Horizon
10984    ///
10985    /// Valid commit indices are `wal_horizon_floor()..wal_total_commits()`;
10986    /// anything outside is [`GraphError::CommitOutOfRange`], exactly like
10987    /// `was_linked`. An unknown key is not an error — it simply had no edges.
10988    ///
10989    /// ## Derived edges
10990    ///
10991    /// `DerivedEdgeAdded` / `DerivedEdgeRetracted` markers carry rule
10992    /// attribution, so a rule-owned edge comes back with `derived: true` and
10993    /// `rule: Some(name)`.
10994    ///
10995    /// ## Renames
10996    ///
10997    /// `key` is matched through the same commit-bounded alias intervals
10998    /// `edge_history` uses, so querying a node's *current* key surfaces edges
10999    /// written under an earlier name. Endpoint keys in the result are reported
11000    /// under the name the node carries today, so they can be fed straight back
11001    /// into `node_info`, `explain` or another `edges_at`.
11002    ///
11003    /// ## Masks
11004    ///
11005    /// Like `edge_history` and `node_history`, this reads the WAL regardless of
11006    /// any role mask. Apply masking at the caller level.
11007    pub fn edges_at(&self, key: &str, commit: u64) -> Result<Vec<EdgeAt>> {
11008        use core_storage::wal::WalRecord;
11009
11010        let (frames, _) = self.all_frames()?;
11011        let total_commits = self.wal_horizon_floor + frames.len() as u64;
11012
11013        // Horizon floor: commits in pruned archives are unreachable.
11014        if commit < self.wal_horizon_floor || commit >= total_commits {
11015            return Err(GraphError::CommitOutOfRange {
11016                commit,
11017                total: total_commits,
11018                floor: self.wal_horizon_floor,
11019            });
11020        }
11021
11022        // Commit-bounded historical names of `key` (handles RenameNode).
11023        let alias = self.build_key_alias_intervals(&frames, key);
11024
11025        // Forward rename chain, for reporting endpoints under their current
11026        // names: old key → [(commit, new key)] in ascending commit order.
11027        // Built over the whole WAL, not just the prefix up to `commit`, because
11028        // a rename after `commit` still changes what the node is called today.
11029        let mut renames: HashMap<String, Vec<(u64, String)>> = HashMap::new();
11030        for (local_i, frame) in frames.iter().enumerate() {
11031            let c = self.wal_horizon_floor + local_i as u64;
11032            let records: &[WalRecord] = match frame {
11033                WalRecord::Batch(inner) => inner.as_slice(),
11034                single => std::slice::from_ref(single),
11035            };
11036            for rec in records {
11037                if let WalRecord::RenameNode { old_key, new_key } = rec {
11038                    renames
11039                        .entry(old_key.clone())
11040                        .or_default()
11041                        .push((c, new_key.clone()));
11042                }
11043            }
11044        }
11045
11046        // The name a node written as `k` at commit `from` carries today.
11047        // Follows the first rename at or after `from`, then keeps going. The
11048        // iteration cap bounds a rename cycle inside a single batch.
11049        let canon = |k: &str, from: u64| -> String {
11050            if renames.is_empty() {
11051                return k.to_string();
11052            }
11053            let mut cur = k.to_string();
11054            let mut at = from;
11055            for _ in 0..64 {
11056                match renames
11057                    .get(&cur)
11058                    .and_then(|v| v.iter().find(|(c, _)| *c >= at))
11059                {
11060                    Some((c, new)) => {
11061                        at = *c;
11062                        cur = new.clone();
11063                    }
11064                    None => break,
11065                }
11066            }
11067            cur
11068        };
11069
11070        let local_commit = commit - self.wal_horizon_floor;
11071        // (edge_type, src_key, dst_key) → (derived, rule)
11072        let mut active: BTreeMap<(String, String, String), (bool, Option<String>)> =
11073            BTreeMap::new();
11074
11075        for (local_i, frame) in frames.iter().enumerate().take((local_commit + 1) as usize) {
11076            let c = self.wal_horizon_floor + local_i as u64;
11077            let records: &[WalRecord] = match frame {
11078                WalRecord::Batch(inner) => inner.as_slice(),
11079                single => std::slice::from_ref(single),
11080            };
11081
11082            for rec in records {
11083                match rec {
11084                    WalRecord::InsertEdge {
11085                        edge_type,
11086                        src_key,
11087                        dst_key,
11088                    } => {
11089                        if Self::aliases_match(&alias, src_key, c)
11090                            || Self::aliases_match(&alias, dst_key, c)
11091                        {
11092                            active.insert(
11093                                (edge_type.clone(), canon(src_key, c), canon(dst_key, c)),
11094                                (false, None),
11095                            );
11096                        }
11097                    }
11098                    WalRecord::InsertEdgeId { etype, src, dst } => {
11099                        let Some(etype_str) = self.syms.resolve(*etype) else {
11100                            continue;
11101                        };
11102                        // `key_of_historical` resolves tombstoned ids too, and
11103                        // already returns the node's current key — no rename
11104                        // canonicalisation needed on this arm.
11105                        let (Some(src_key), Some(dst_key)) = (
11106                            self.ids.key_of_historical(*src),
11107                            self.ids.key_of_historical(*dst),
11108                        ) else {
11109                            continue;
11110                        };
11111                        if src_key == key || dst_key == key {
11112                            active.insert(
11113                                (
11114                                    etype_str.to_string(),
11115                                    src_key.to_string(),
11116                                    dst_key.to_string(),
11117                                ),
11118                                (false, None),
11119                            );
11120                        }
11121                    }
11122                    WalRecord::DeleteEdge {
11123                        edge_type,
11124                        src_key,
11125                        dst_key,
11126                    } => {
11127                        if Self::aliases_match(&alias, src_key, c)
11128                            || Self::aliases_match(&alias, dst_key, c)
11129                        {
11130                            active.remove(&(
11131                                edge_type.clone(),
11132                                canon(src_key, c),
11133                                canon(dst_key, c),
11134                            ));
11135                        }
11136                    }
11137                    WalRecord::DeleteNode { key: k } => {
11138                        if active.is_empty() {
11139                            continue;
11140                        }
11141                        if Self::aliases_match(&alias, k, c) {
11142                            // Our node is gone; every incident edge goes with it.
11143                            active.clear();
11144                        } else {
11145                            // A partner is gone; its edges to us go with it.
11146                            let ck = canon(k, c);
11147                            active.retain(|(_, s, d), _| *s != ck && *d != ck);
11148                        }
11149                    }
11150                    WalRecord::DerivedEdgeAdded {
11151                        rule,
11152                        edge_type,
11153                        src_key,
11154                        dst_key,
11155                    } => {
11156                        if Self::aliases_match(&alias, src_key, c)
11157                            || Self::aliases_match(&alias, dst_key, c)
11158                        {
11159                            active.insert(
11160                                (edge_type.clone(), canon(src_key, c), canon(dst_key, c)),
11161                                (true, Some(rule.clone())),
11162                            );
11163                        }
11164                    }
11165                    WalRecord::DerivedEdgeRetracted {
11166                        edge_type,
11167                        src_key,
11168                        dst_key,
11169                        ..
11170                    } => {
11171                        if Self::aliases_match(&alias, src_key, c)
11172                            || Self::aliases_match(&alias, dst_key, c)
11173                        {
11174                            active.remove(&(
11175                                edge_type.clone(),
11176                                canon(src_key, c),
11177                                canon(dst_key, c),
11178                            ));
11179                        }
11180                    }
11181                    // InsertNode, SetProp, CreateRule, … do not move edges.
11182                    _ => {}
11183                }
11184            }
11185        }
11186
11187        // BTreeMap iteration is already (edge_type, src, dst) order.
11188        Ok(active
11189            .into_iter()
11190            .map(|((edge_type, src_key, dst_key), (derived, rule))| EdgeAt {
11191                edge_type,
11192                src_key,
11193                dst_key,
11194                derived,
11195                rule,
11196            })
11197            .collect())
11198    }
11199
11200    /// The derived edges that would be retracted and derived if `key.field`
11201    /// were set to `value` — computed WITHOUT writing anything.
11202    ///
11203    /// Nothing is committed and nothing on `self` is mutated: the rule engine's
11204    /// provenance, its candidate indexes, the topology and the property columns
11205    /// are all cloned first, the change is applied to the clone, and the real
11206    /// per-node re-derivation (`RuleEngine::on_node_changed` — the same call
11207    /// `set_prop` makes during apply) runs against it. The derived-edge deltas
11208    /// it emits are the answer, so rule semantics — predicates, top-k,
11209    /// via-hops, chaining, weights — are the engine's, not a re-implementation.
11210    ///
11211    /// Works on a read-only handle.
11212    ///
11213    /// **While a rule's vector index is still building** (`RuleStats::building`)
11214    /// the clone carries no pending-build state, so this reports the edges that
11215    /// rule would derive — which the live store will not derive until its
11216    /// backfill runs. Right about the end state, early about the timing.
11217    ///
11218    /// Returns `Err(KeyNotFound)` for an unknown or tombstoned key and
11219    /// `Err(ViewPropReadOnly)` for a field a view owns — matching
11220    /// [`set_prop`](GraphDb::set_prop)'s validation. A change with no effect
11221    /// (the node already holds `value`, or no rule watches `field`) returns
11222    /// empty lists.
11223    ///
11224    /// ## Cost
11225    ///
11226    /// One clone of the property columns, the topology overlay, the symbol
11227    /// interner, the edge properties and the provenance map, plus one candidate
11228    /// re-index (O(nodes × rules)). That is much cheaper than copying the store
11229    /// directory, but it is not free — this is an interactive "what if", not a
11230    /// hot path.
11231    pub fn what_if_set_prop(&self, key: &str, field: &str, value: Value) -> Result<WhatIf> {
11232        // The engine's provenance, HNSW and IVF state live in the mmap'd base
11233        // until something asks for them. On a store opened cold from a snapshot
11234        // this is the first ask, and without it the clone below starts from an
11235        // empty provenance map: nothing to retract, so `lost` comes back empty.
11236        self.ensure_v8_base_sections_loaded();
11237
11238        let empty = WhatIf {
11239            lost: Vec::new(),
11240            gained: Vec::new(),
11241        };
11242
11243        if let Some(view_name) = self.view_store.view_for_prop(field) {
11244            return Err(GraphError::ViewPropReadOnly {
11245                view_name: view_name.to_string(),
11246            });
11247        }
11248        MutPreview::new(self).check_live_key(key)?;
11249        let id = self
11250            .ids
11251            .get(key)
11252            .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
11253
11254        let rules: Vec<RuleDef> = self.engine.rules().cloned().collect();
11255        if rules.is_empty() {
11256            return Ok(empty);
11257        }
11258
11259        // No rule watches this field → no derivation can change.
11260        if !rules.iter().any(|r| r.watched_fields().contains(field)) {
11261            return Ok(empty);
11262        }
11263
11264        let old_value = build_props_view(&self.props, &self.base)
11265            .get(id, field)
11266            .map(|vr| vr.into_value());
11267        if old_value.as_ref() == Some(&value) {
11268            return Ok(empty);
11269        }
11270
11271        // --- Clone every piece of state the re-derivation writes to. ---
11272        let mut props = self.props.clone();
11273        let mut topo = self.topo.clone();
11274        let mut syms = self.syms.clone();
11275        let mut edge_props = self.edge_props.clone();
11276
11277        let mut tripped: BTreeMap<String, bool> = BTreeMap::new();
11278        let mut fires: BTreeMap<String, u64> = BTreeMap::new();
11279        for r in &rules {
11280            tripped.insert(r.name.clone(), self.engine.is_tripped(&r.name));
11281            fires.insert(r.name.clone(), self.engine.fire_count(&r.name));
11282        }
11283        // `provenance()` decodes retained snapshot bytes on first use; the
11284        // engine clone needs the real map, not an empty one.
11285        let provenance = self.engine.provenance().clone();
11286        let mut engine = core_rules::RuleEngine::from_persist(rules, provenance, tripped, fires);
11287
11288        // Build the candidate indexes from the state BEFORE the change, exactly
11289        // as apply() sees them: `on_node_changed` withdraws the node under its
11290        // old value and refiles it under the new one, so the index must not
11291        // already reflect the change.
11292        engine.reindex_all_load_state(
11293            &self.ids,
11294            &syms,
11295            &self.labels,
11296            build_props_view(&self.props, &self.base),
11297            self.engine.export_ivf_state(),
11298            self.engine.export_hnsw_state_passthrough(),
11299        );
11300        engine.set_emit_deltas(true);
11301
11302        // --- Apply the hypothetical change and re-derive. ---
11303        props.set(id, field, value);
11304        {
11305            let mut gm = make_graph_mut(
11306                &self.ids,
11307                &mut syms,
11308                &self.labels,
11309                build_props_view(&props, &self.base),
11310                &mut topo,
11311                &self.base,
11312                &mut edge_props,
11313            );
11314            engine.on_node_changed(id, Some((field, old_value)), &mut gm);
11315        }
11316
11317        let mut lost: BTreeSet<EdgeAt> = BTreeSet::new();
11318        let mut gained: BTreeSet<EdgeAt> = BTreeSet::new();
11319        for d in engine.drain_deltas() {
11320            let edge = EdgeAt {
11321                edge_type: d.edge_type,
11322                src_key: d.src_key,
11323                dst_key: d.dst_key,
11324                derived: true,
11325                rule: Some(d.rule),
11326            };
11327            if d.fired {
11328                gained.insert(edge);
11329            } else {
11330                lost.insert(edge);
11331            }
11332        }
11333        // An edge retracted and re-derived within the same re-derivation (top-k
11334        // churn) is not a change the caller would see.
11335        let churn: Vec<EdgeAt> = lost.intersection(&gained).cloned().collect();
11336        for e in churn {
11337            lost.remove(&e);
11338            gained.remove(&e);
11339        }
11340
11341        Ok(WhatIf {
11342            lost: lost.into_iter().collect(),
11343            gained: gained.into_iter().collect(),
11344        })
11345    }
11346
11347    pub fn edge_count(&self) -> u64 {
11348        self.topo_view().edge_count()
11349    }
11350
11351    /// Live/tombstone/edge counts plus per-rule provenance size, trip latch,
11352    /// and fire counter (includes rebuild evaluations). Rules are sorted by name.
11353    pub fn stats(&self) -> Stats {
11354        self.ensure_v8_base_sections_loaded();
11355        let building = self.engine.builds_in_progress();
11356        let rules: Vec<RuleStats> = self
11357            .engine
11358            .rules()
11359            .map(|r| RuleStats {
11360                name: r.name.clone(),
11361                edges: self
11362                    .engine
11363                    .provenance()
11364                    .get(&r.name)
11365                    .map(|s| s.len() as u64)
11366                    .unwrap_or(0),
11367                tripped: self.engine.is_tripped(&r.name),
11368                fires: self.engine.fire_count(&r.name),
11369                approximate: r.approximate,
11370                building: building.iter().find(|b| b.rule == r.name).cloned(),
11371            })
11372            .collect();
11373        Stats {
11374            nodes_live: self.ids.live_len(),
11375            nodes_tombstoned: self.ids.len() - self.ids.live_len(),
11376            edges: self.topo_view().edge_count(),
11377            rules,
11378            chain_truncations: self.engine.chain_truncations(),
11379            history_floor: self.wal_horizon_floor,
11380            namespaces: self.namespace_stats(),
11381        }
11382    }
11383
11384    /// On-disk size of the WAL file in bytes.
11385    ///
11386    /// Reads file metadata without loading WAL contents.  Returns `Err` for
11387    /// in-memory (`SimFs`) databases where no WAL file exists on disk.
11388    pub fn wal_size_bytes(&self) -> std::io::Result<u64> {
11389        let path = self.fs.wal_path().ok_or_else(|| {
11390            std::io::Error::new(
11391                std::io::ErrorKind::Unsupported,
11392                "wal_path not available for this Fs implementation",
11393            )
11394        })?;
11395        Ok(std::fs::metadata(path)?.len())
11396    }
11397
11398    /// Set the slow-query threshold.  Queries whose execution time equals or
11399    /// exceeds `ms` milliseconds are logged.  Pass `0` to disable.
11400    ///
11401    /// Use this setter in tests — the environment variable
11402    /// `MUSHROOMDB_SLOW_QUERY_MS` is process-global and races parallel test
11403    /// threads.
11404    pub fn set_slow_query_threshold_ms(&mut self, ms: u64) {
11405        self.slow_query_threshold_ms = ms;
11406    }
11407
11408    /// Snapshot of the slow-query ring buffer and lifetime counter.
11409    pub fn slow_query_snapshot(&self) -> SlowQuerySnapshot {
11410        let log = self.slow_queries.lock().unwrap_or_else(|e| e.into_inner());
11411        SlowQuerySnapshot {
11412            threshold_ms: self.slow_query_threshold_ms,
11413            count: log.total,
11414            last: log.entries.iter().cloned().collect(),
11415        }
11416    }
11417
11418    /// Instant the database was opened.  Used by consumers (e.g. `/metrics`)
11419    /// to compute uptime.
11420    pub fn started_at(&self) -> std::time::Instant {
11421        self.started_at
11422    }
11423
11424    /// On-disk snapshot format version this binary writes and reads.
11425    pub fn format_version() -> u16 {
11426        core_storage::snapshot::VERSION
11427    }
11428
11429    /// Test-support: total bytes appended (SimFs only usage).
11430    pub fn fs_total_appended(&self) -> usize
11431    where
11432        F: FsIntrospect,
11433    {
11434        self.fs.total_appended()
11435    }
11436
11437    /// Test-support: successful `Fs::sync` calls (SimFs / counting fs).
11438    pub fn fs_sync_count(&self) -> usize
11439    where
11440        F: FsIntrospect,
11441    {
11442        self.fs.sync_count()
11443    }
11444
11445    /// Consume the db, returning its fs (for crash simulation).
11446    pub fn into_fs(self) -> F {
11447        self.fs
11448    }
11449
11450    pub fn snapshot(&mut self) -> Result<()> {
11451        self.snapshot_with(SnapshotOptions::default())
11452    }
11453
11454    /// Snapshot with explicit options.
11455    ///
11456    /// # `keep_wal`
11457    ///
11458    /// When `keep_wal` is `false` (the default, same as [`snapshot`]):
11459    ///   - The WAL is replaced with a minimal baseline containing one
11460    ///     `EnableFulltext` record per active declaration.  All pre-snapshot
11461    ///     history is discarded; `open_at` can only reach post-snapshot commits.
11462    ///
11463    /// When `keep_wal` is `true`:
11464    ///   - The WAL is left intact.  All pre-snapshot commits remain reachable
11465    ///     via `open_at`.  The existing WAL already contains the original
11466    ///     `EnableFulltext` records, so no baseline re-write is needed; the
11467    ///     recovery guards in `apply()` silently skip any duplicate records on
11468    ///     replay.
11469    ///   - Crash window: a crash after the snapshot write but before the next
11470    ///     WAL write leaves the full pre-snapshot WAL intact.  On reopen the
11471    ///     snapshot is loaded and the WAL replayed idempotently over it — safe
11472    ///     because every `apply()` arm is idempotent when replayed over an
11473    ///     already-current snapshot.
11474    pub fn snapshot_with(&mut self, opts: SnapshotOptions) -> Result<()> {
11475        if self.read_only {
11476            return Err(GraphError::ReadOnly);
11477        }
11478        // A snapshot rewrites `wal.bin` through a tmp+rename, so a peer that is
11479        // appending ends up holding a descriptor on an unlinked inode and loses
11480        // commits it believes durable. Snapshotting therefore requires the
11481        // cross-process write lock, exactly as appending does. Unlike the WAL
11482        // append path this does not go through `log_then_apply_with`, so both
11483        // guards are repeated here.
11484        if self.degraded {
11485            return Err(GraphError::Io(std::io::Error::other(
11486                "database degraded after group-commit fsync failure; reopen required",
11487            )));
11488        }
11489        if self.lock_denied {
11490            return Err(GraphError::Busy { holder: None });
11491        }
11492        // Capture whether snapshot.bin already existed BEFORE this snapshot write.
11493        // Used by the archive path's conservative genesis-chain check: if a prior
11494        // snapshot exists but wal.truncated does not, we cannot distinguish a
11495        // legacy store (may have been truncated in an older code version) from a
11496        // new store that only used keep_wal=true.  Conservative: refuse genesis in
11497        // both cases.  Must be sampled here, before the snapshot write below.
11498        let had_prior_snapshot = self.fs.snapshot_path().map(|p| p.exists()).unwrap_or(false);
11499        self.ensure_v8_base_sections_loaded();
11500        // Ensure provenance is decoded before to_persist() clones it.
11501        self.engine.ensure_provenance_loaded_mut();
11502        let (rule_defs_typed, provenance, rule_tripped, rule_fires) = self.engine.to_persist();
11503        let rule_defs = rule_defs_typed
11504            .iter()
11505            .map(|r| bincode::serialize(r).expect("RuleDef serialize cannot fail"))
11506            .collect();
11507        // Collect HNSW state and IVF state.  When indexes are not yet
11508        // populated (clean open, no mutation since open), pass the retained
11509        // raw bytes through directly so that migrate/snapshot does not
11510        // silently discard fitted approximate-rule indexes.
11511        let hnsw_state = self.engine.export_hnsw_state_passthrough();
11512        let ivf_bytes = if !self.engine.indexes_populated() {
11513            // Pass retained IVF bytes through unchanged (no re-encode).
11514            self.engine.retained_ivf_bytes_clone().unwrap_or_default()
11515        } else {
11516            // Indexes live: encode from current state.
11517            let raw_ivf = self.engine.export_ivf_state();
11518            let ivf_state_map: BTreeMap<String, core_storage::snapshot::PerRuleIvfState> = raw_ivf
11519                .into_iter()
11520                .map(|(name, ((sc, sa, sd), (dc, da, dd)))| {
11521                    (
11522                        name,
11523                        core_storage::snapshot::PerRuleIvfState {
11524                            src: core_storage::snapshot::SideIvfState {
11525                                centroids: sc,
11526                                clusters: sa,
11527                                drift: sd,
11528                            },
11529                            dst: core_storage::snapshot::SideIvfState {
11530                                centroids: dc,
11531                                clusters: da,
11532                                drift: dd,
11533                            },
11534                        },
11535                    )
11536                })
11537                .collect();
11538            if ivf_state_map.is_empty() {
11539                Vec::new()
11540            } else {
11541                bincode::serialize(&ivf_state_map).expect("IVF state serialize cannot fail")
11542            }
11543        };
11544        let view_defs: Vec<Vec<u8>> = self
11545            .view_store
11546            .views()
11547            .map(|v| bincode::serialize(v).expect("ViewDef serialize cannot fail"))
11548            .collect();
11549        if self.base.is_some() {
11550            // V8 merge-snapshot path: encode base+overlay into a new V8 snapshot,
11551            // write it atomically, remap it as the new base, then clear the overlay.
11552            let meta = V8Meta {
11553                labels: self.labels.clone(),
11554                edge_props: self.edge_props.clone(),
11555                rule_defs,
11556                provenance,
11557                rule_tripped,
11558                rule_fires,
11559                ivf_bytes,
11560                view_defs,
11561                wal_truncated: !opts.keep_wal,
11562                hnsw: hnsw_state,
11563                last_change: self.last_change.clone(),
11564            };
11565            let mut buf: Vec<u8> = Vec::new();
11566            {
11567                // Clone the Arc so the old base stays alive while we encode.
11568                // The borrow of archived_csr (into old_base's mmap) is released
11569                // at the end of this block, before we replace self.base.
11570                let old_base = self.base.clone().expect("is_some checked above");
11571                let archived_csr = old_base.topology().map_err(|e| GraphError::Corrupt {
11572                    detail: format!("v8 snapshot: topology section: {e:?}"),
11573                })?;
11574                let archived_cols = old_base.columns().map_err(|e| GraphError::Corrupt {
11575                    detail: format!("v8 snapshot: columns section: {e:?}"),
11576                })?;
11577                // `None` when the base predates V9 — the migration path: its
11578                // string columns still carry their own tables and this snapshot
11579                // is the rewrite that collapses them into section 12.
11580                let archived_strings =
11581                    old_base
11582                        .string_table()
11583                        .transpose()
11584                        .map_err(|e| GraphError::Corrupt {
11585                            detail: format!("v8 snapshot: strings section: {e:?}"),
11586                        })?;
11587                let archived_edge_props =
11588                    old_base
11589                        .edge_props_section()
11590                        .map_err(|e| GraphError::Corrupt {
11591                            detail: format!("v8 snapshot: edge_props section: {e:?}"),
11592                        })?;
11593                let edge_props_raw =
11594                    old_base
11595                        .edge_props_raw_bytes()
11596                        .map_err(|e| GraphError::Corrupt {
11597                            detail: format!("v8 snapshot: edge_props raw bytes: {e:?}"),
11598                        })?;
11599                let prov_raw =
11600                    old_base
11601                        .provenance_raw_bytes()
11602                        .map_err(|e| GraphError::Corrupt {
11603                            detail: format!("v8 snapshot: provenance raw bytes: {e:?}"),
11604                        })?;
11605                encode_v8(
11606                    Some(archived_csr),
11607                    Some(archived_cols),
11608                    archived_strings,
11609                    Some((archived_edge_props, edge_props_raw)),
11610                    Some(prov_raw),
11611                    &self.topo,
11612                    &self.props,
11613                    &self.ids,
11614                    &self.syms,
11615                    &meta,
11616                    &mut buf,
11617                )?;
11618            }
11619            self.fs.write_atomic(FileId::Snapshot, &buf)?;
11620            // Remap the freshly-written snapshot as the new base.
11621            // C2: use file mmap on RealFs; fall back to from_bytes on SimFs.
11622            let new_base = if let Some(snap_path) = self.fs.snapshot_path() {
11623                core_storage::v8::MappedBase::map(&snap_path)
11624            } else {
11625                core_storage::v8::MappedBase::from_bytes(buf)
11626            }
11627            .map_err(|e| GraphError::Corrupt {
11628                detail: format!("v8 snapshot: remap new base: {e:?}"),
11629            })?;
11630            self.base = Some(Arc::new(new_base));
11631            // Clear the overlay and prop tombstones — all data is now in the new base.
11632            self.topo = Topology::new();
11633            self.props = core_storage::columns::ColumnStore::new();
11634        } else {
11635            // Legacy path (V5–V7 stores without a V8 base).
11636            //
11637            // Memory-diet path: build V8Meta directly from &self — no SnapshotState
11638            // clone and no encode_v8_from_state intermediate clones.  The big
11639            // structures (self.topo, self.props) are borrowed, not cloned.
11640            // self.edge_props is moved (not cloned) because we immediately clear it
11641            // when we remap the new V8 snapshot as self.base (see below).
11642            //
11643            // Eliminates from peak RSS vs. the old SnapshotState path:
11644            //   • self.topo.clone()      (~topology HashMap footprint)
11645            //   • self.props.clone()     (~column-store footprint)
11646            //   • encode_v8_from_state V8Meta secondary clones (labels, edge_props, …)
11647            let meta = V8Meta {
11648                labels: self.labels.clone(),
11649                wal_truncated: !opts.keep_wal,
11650                // Move edge_props out so the large overlay is freed when meta
11651                // drops at end of this block (self.edge_props is now empty; reads
11652                // after base assignment go through the mmap'd base section).
11653                edge_props: std::mem::take(&mut self.edge_props),
11654                rule_defs,
11655                provenance,
11656                rule_tripped,
11657                rule_fires,
11658                ivf_bytes,
11659                view_defs,
11660                hnsw: hnsw_state,
11661                last_change: self.last_change.clone(),
11662            };
11663            let mut buf = Vec::new();
11664            encode_v8(
11665                None,
11666                None,
11667                None,
11668                None,
11669                None,
11670                &self.topo,
11671                &self.props,
11672                &self.ids,
11673                &self.syms,
11674                &meta,
11675                &mut buf,
11676            )?;
11677            // meta (and the moved edge_props inside it) is no longer needed;
11678            // drop it before the write to keep the peak window narrow.
11679            drop(meta);
11680            self.fs.write_atomic(FileId::Snapshot, &buf)?;
11681            // Remap the freshly-written V8 snapshot as self.base.
11682            // On RealFs: drop the encode buffer before mmap to recover ~1.9 GiB.
11683            // On SimFs (tests): pass buf to from_bytes.
11684            let new_base = if let Some(snap_path) = self.fs.snapshot_path() {
11685                drop(buf);
11686                core_storage::v8::MappedBase::map(&snap_path)
11687            } else {
11688                core_storage::v8::MappedBase::from_bytes(buf)
11689            }
11690            .map_err(|e| GraphError::Corrupt {
11691                detail: format!("v8 snapshot: remap new base (legacy path): {e:?}"),
11692            })?;
11693            self.base = Some(Arc::new(new_base));
11694            // Free the large heap-allocated decoded state — all data is now in the
11695            // mmap'd base.  Mirrors the V8 merge-snapshot path (see above).
11696            // self.edge_props was already moved into meta and is effectively empty.
11697            self.topo = Topology::new();
11698            self.props = core_storage::columns::ColumnStore::new();
11699        }
11700
11701        if opts.archive_wal {
11702            // History-preserving snapshot (Task 4):
11703            //   1. Snapshot already written above (write_atomic → fsynced).
11704            //   2. Rename WAL → wal.<commit_seq>.archive  (atomic, same fs).
11705            //      Crash window B: crash here leaves archive present, WAL
11706            //      absent.  Reopen: snapshot loaded (full state), no WAL
11707            //      replay.  Archive is NOT replayed into live state — it is
11708            //      pre-snapshot by construction.  Safe.
11709            //   3. Optionally write genesis marker (first archive only, no
11710            //      prior WAL truncation).
11711            //   4. Prune old archives (retention), update horizon floor.
11712            //      Pruning invalidates the genesis chain; delete marker.
11713            //   5. Write new minimal baseline WAL (write_atomic).
11714            //      Crash window C: crash here leaves new archive plus no live
11715            //      WAL.  Same as window B — handled above.
11716            //
11717            // Sample existing archives BEFORE the rename so we can detect
11718            // whether this is the first archive.
11719            let existing_archives = self.fs.list_archives()?;
11720            let is_first_archive = existing_archives.is_empty();
11721
11722            // Compute a globally-monotonic archive name: the name equals the
11723            // cumulative end-frame index of the archive in global commit space.
11724            //
11725            // Using `commit_seq` directly is UNSOUND across sessions: on reopen
11726            // commit_seq is seeded from max(last_change), which underestimates
11727            // the WAL depth when trailing commits (e.g. insert_edge) do not
11728            // update last_change.  A session-2 archive could then receive a name
11729            // ≤ the session-1 archive, causing incorrect sort order or collision.
11730            //
11731            // Instead: read and decode the live WAL here (before the rename) to
11732            // get its exact frame count, then add it to the last known global
11733            // end-frame index (the name of the most recent existing archive, or
11734            // wal_horizon_floor if no archives exist).  This is O(WAL size) but
11735            // snapshot is already serialising the full graph state, so the cost
11736            // is dominated.
11737            let live_wal_bytes_for_name = self.fs.read(FileId::Wal)?;
11738            let (live_frames_for_name, _) = decode_all(&live_wal_bytes_for_name);
11739            let archive_n = existing_archives
11740                .last()
11741                .copied()
11742                .unwrap_or(self.wal_horizon_floor)
11743                + live_frames_for_name.len() as u64;
11744            self.fs.archive_wal(archive_n)?;
11745
11746            // Genesis marker: written once when the first archive is taken
11747            // from a store that has never undergone a WAL-truncating snapshot.
11748            // When present, `open_at` may replay archive-resident commits from
11749            // empty state (the archive chain covers from global index 0).
11750            //
11751            // Two conditions must ALL hold:
11752            //   1. This is the first archive (existing_archives was empty).
11753            //   2. No snapshot.bin existed before this operation (had_prior_snapshot=false).
11754            //      A WAL-truncating snapshot (keep_wal=false) always writes snapshot.bin
11755            //      before truncating the WAL, so if any prior truncating snapshot was taken
11756            //      — even in a previous session — snapshot.bin is present and this condition
11757            //      is false.  This subsumes the cross-session truncation case without
11758            //      requiring a separate wal.truncated sidecar file.
11759            //      For legacy stores (snapshot.bin written by an older code version that
11760            //      may have truncated the WAL), the same conservative refusal applies:
11761            //      we cannot prove the chain is complete, so we refuse genesis (cost =
11762            //      no as-of-through-archives; never silent wrong data).
11763            //      On SimFs (snapshot_path() == None) had_prior_snapshot is always false,
11764            //      so SimFs always passes this check.
11765            if is_first_archive && !had_prior_snapshot {
11766                self.fs.write_genesis_marker()?;
11767                self.archive_genesis_chain = true;
11768            }
11769
11770            // Retention pruning: keep newest `keep` archives; delete oldest.
11771            // Pruning is the ONLY deletion site for archives.
11772            //
11773            // Crash-safety ordering (C1 fix):
11774            //   1. Count frames in surplus archives (reads only — no mutation).
11775            //   2. Advance and PERSIST the horizon floor FIRST via write-then-
11776            //      rename (atomic).  A crash after this point leaves orphaned
11777            //      archives on disk, but the floor is correct.  The opening
11778            //      cleanup sweep (`cleanup_orphaned_archives`) removes them on
11779            //      the next open, so the store is always safe to reopen.
11780            //   3. Delete the genesis marker (floor > 0 already blocks open_at
11781            //      via the conjunctive gate; marker cleanup is belt-and-suspenders).
11782            //   4. Delete surplus archives.  A crash between any two deletes
11783            //      leaves the floor committed and orphaned archives cleaned at
11784            //      next open — never a stale floor with a missing archive prefix.
11785            if let Some(keep) = self.wal_archive_retention {
11786                if keep > 0 {
11787                    let archives = self.fs.list_archives()?;
11788                    // archives is sorted ascending (oldest first)
11789                    if archives.len() as u32 > keep {
11790                        let surplus = archives.len() - keep as usize;
11791                        // Step 1: count pruned frames (reads, no mutation).
11792                        let mut pruned_frames = 0u64;
11793                        for &n in &archives[..surplus] {
11794                            let bytes = self.fs.read_archive(n)?;
11795                            let (frames, _) = decode_all(&bytes);
11796                            pruned_frames += frames.len() as u64;
11797                        }
11798                        // Step 2: advance and persist floor FIRST.
11799                        self.wal_horizon_floor += pruned_frames;
11800                        self.fs.write_horizon_floor(self.wal_horizon_floor)?;
11801                        // Step 3: delete genesis marker (floor > 0 already
11802                        // blocks open_at; this is belt-and-suspenders cleanup).
11803                        if pruned_frames > 0 && self.archive_genesis_chain {
11804                            self.fs.delete_genesis_marker()?;
11805                            self.archive_genesis_chain = false;
11806                        }
11807                        // Step 4: delete surplus archives.  Crash here →
11808                        // orphaned archives; cleaned at next open.
11809                        for &n in &archives[..surplus] {
11810                            self.fs.delete_archive(n)?;
11811                        }
11812                    }
11813                }
11814            }
11815
11816            // Write new minimal baseline WAL (mirrors the keep_wal=false path).
11817            let mut baseline_wal: Vec<u8> = Vec::new();
11818            for (label, field) in self.fulltext.enabled_pairs() {
11819                let rec = WalRecord::EnableFulltext {
11820                    label: label.clone(),
11821                    field: field.clone(),
11822                };
11823                baseline_wal.extend_from_slice(&encode_record(&rec));
11824            }
11825            for (label, field) in self.prop_index.enabled_pairs() {
11826                let rec = WalRecord::EnableIndex {
11827                    label: label.clone(),
11828                    field: field.clone(),
11829                };
11830                baseline_wal.extend_from_slice(&encode_record(&rec));
11831            }
11832            self.fs.write_atomic(FileId::Wal, &baseline_wal)?;
11833        } else if opts.keep_wal {
11834            // keep_wal=true: WAL is left untouched.  The existing WAL already
11835            // contains the EnableFulltext records from the original enable calls;
11836            // replay is idempotent (guards in apply() skip already-live entries).
11837            // No baseline re-write is needed or safe here — the full WAL history
11838            // must remain intact for open_at to reach pre-snapshot commits.
11839        } else {
11840            // keep_wal=false (default): truncate by replacing the WAL with a
11841            // minimal baseline of one EnableFulltext record per active pair.
11842            //
11843            // Crash-ordering: write_atomic is atomic.
11844            //   • Crash before snapshot write  → WAL unchanged.  Safe.
11845            //   • Crash after snapshot write but before this WAL write → full
11846            //     pre-snapshot WAL still present; open_with replays idempotently.
11847            //   • Crash after both writes → normal post-snapshot state.
11848            //
11849            // Genesis chain: a WAL-truncating snapshot breaks the archive chain
11850            // for any archives taken AFTER this point (their WAL slices would
11851            // not start at genesis).  Delete any existing genesis marker so that
11852            // open_at refuses archive-resident commits.  Future sessions are
11853            // covered by had_prior_snapshot: snapshot.bin written here persists
11854            // across sessions and prevents a later archiving session from
11855            // incorrectly claiming a complete genesis chain.
11856            if self.archive_genesis_chain {
11857                self.fs.delete_genesis_marker()?;
11858                self.archive_genesis_chain = false;
11859            }
11860            let mut baseline_wal: Vec<u8> = Vec::new();
11861            for (label, field) in self.fulltext.enabled_pairs() {
11862                let rec = WalRecord::EnableFulltext {
11863                    label: label.clone(),
11864                    field: field.clone(),
11865                };
11866                baseline_wal.extend_from_slice(&encode_record(&rec));
11867            }
11868            for (label, field) in self.prop_index.enabled_pairs() {
11869                let rec = WalRecord::EnableIndex {
11870                    label: label.clone(),
11871                    field: field.clone(),
11872                };
11873                baseline_wal.extend_from_slice(&encode_record(&rec));
11874            }
11875            self.fs.write_atomic(FileId::Wal, &baseline_wal)?;
11876        }
11877        // After snapshot the overlay may have changed (V8 merge path clears
11878        // self.topo and self.props). Refresh the MVCC fold so future readers
11879        // see the post-snapshot state rather than stale overlay data.
11880        self.fold_now();
11881        // We wrote the snapshot and (unless keep_wal) replaced the WAL, so both
11882        // markers this handle uses to detect other processes' work must be
11883        // re-taken from disk. Skipping this would make our own snapshot look
11884        // like a peer's on the next staleness check and force a needless
11885        // reload.
11886        self.wal_consumed = self.fs.wal_len().map_err(GraphError::Io)?;
11887        self.snapshot_ident = self.fs.snapshot_ident().map_err(GraphError::Io)?;
11888        Ok(())
11889    }
11890}
11891
11892/// Queued mutation for a [`BatchBuilder`] or [`GraphDb::commit_group`].
11893///
11894/// The `submit_batch` / `commit_group` APIs accept `Vec<BatchOp>` so that
11895/// callers can build a set of mutations without holding `&mut GraphDb` and
11896/// hand them off to the group-committing writer for durable, batched I/O.
11897pub enum BatchOp {
11898    InsertNode {
11899        label: String,
11900        key: String,
11901        props: Vec<(String, Value)>,
11902    },
11903    InsertEdge {
11904        edge_type: String,
11905        src_key: String,
11906        dst_key: String,
11907    },
11908    SetProp {
11909        key: String,
11910        field: String,
11911        value: Value,
11912    },
11913    RemoveProp {
11914        key: String,
11915        field: String,
11916    },
11917    DeleteEdge {
11918        edge_type: String,
11919        src_key: String,
11920        dst_key: String,
11921    },
11922    DeleteNode {
11923        key: String,
11924    },
11925    CreateRule(RuleDef),
11926    DeleteRule {
11927        name: String,
11928    },
11929    /// Rename a node's key. Validated: old must exist, new must not.
11930    RenameNode {
11931        old_key: String,
11932        new_key: String,
11933    },
11934    /// Insert an edge, auto-creating any missing endpoint as a plain node with
11935    /// `placeholder_label` and no props. Rules fire and last-change is updated
11936    /// for each created endpoint (normal InsertNode semantics in the batch frame).
11937    InsertEdgeUpsert {
11938        edge_type: String,
11939        src_key: String,
11940        dst_key: String,
11941        placeholder_label: String,
11942    },
11943}
11944
11945/// Three-way node visibility status used by `check_single_op_authz`.
11946enum NodeAuthzStatus {
11947    /// Node exists in the store and is in the role's read mask.
11948    Visible(String), // carries the node's label
11949    /// Node exists in the store but is NOT in the role's read mask.
11950    Hidden,
11951    /// Node does not exist in the store.
11952    Absent,
11953}
11954
11955/// Overlay of ops already accepted earlier in the same batch. Never written
11956/// back to the database — validation only.
11957#[derive(Default)]
11958struct Overlay {
11959    extra_keys: BTreeSet<String>,
11960    deleted_keys: BTreeSet<String>,
11961    extra_props: BTreeMap<(String, String), Value>,
11962    removed_props: BTreeSet<(String, String)>,
11963    extra_edges: BTreeSet<(String, String, String)>,
11964    deleted_edges: BTreeSet<(String, String, String)>,
11965    extra_rules: BTreeSet<String>,
11966    deleted_rules: BTreeSet<String>,
11967    /// `rule name → (via_edge, edge_type)` for every via-hop rule accepted
11968    /// earlier in this batch. Feeds the rule-chain cycle check, which otherwise
11969    /// sees only the rules already committed to the engine. Keyed by name so a
11970    /// later `DeleteRule` in the same batch drops the arc with the rule.
11971    extra_rule_arcs: BTreeMap<String, (String, String)>,
11972}
11973
11974/// Read-only view of live db state plus a batch overlay. Shared by single-op
11975/// public methods (empty overlay) and `commit_batch`.
11976struct MutPreview<'a, F: Fs> {
11977    db: &'a GraphDb<F>,
11978    overlay: Overlay,
11979}
11980
11981/// Shortest path from `start` to `target` following `arcs` (`from → to`), or
11982/// `None` if `target` is unreachable.
11983///
11984/// Used for rule-chain cycle detection, where an arc is "a rule hops over
11985/// `from` and writes `to`". Breadth-first over BTree-ordered adjacency, so the
11986/// reported path is stable for a given rule set, and iterative so a pathological
11987/// rule graph cannot overflow the stack.
11988fn find_cycle_through(arcs: &[(String, String)], start: &str, target: &str) -> Option<Vec<String>> {
11989    let mut adj: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
11990    for (from, to) in arcs {
11991        adj.entry(from.as_str()).or_default().insert(to.as_str());
11992    }
11993    let mut parent: BTreeMap<&str, &str> = BTreeMap::new();
11994    let mut visited: BTreeSet<&str> = BTreeSet::new();
11995    let mut queue: std::collections::VecDeque<&str> = std::collections::VecDeque::new();
11996    visited.insert(start);
11997    queue.push_back(start);
11998    while let Some(node) = queue.pop_front() {
11999        if node == target {
12000            let mut path = vec![node.to_string()];
12001            let mut cur = node;
12002            while let Some(&p) = parent.get(cur) {
12003                path.push(p.to_string());
12004                cur = p;
12005            }
12006            path.reverse();
12007            return Some(path);
12008        }
12009        for &next in adj.get(node).into_iter().flatten() {
12010            if visited.insert(next) {
12011                parent.insert(next, node);
12012                queue.push_back(next);
12013            }
12014        }
12015    }
12016    None
12017}
12018
12019impl<'a, F: Fs> MutPreview<'a, F> {
12020    fn new(db: &'a GraphDb<F>) -> Self {
12021        Self {
12022            db,
12023            overlay: Overlay::default(),
12024        }
12025    }
12026
12027    fn has_key(&self, key: &str) -> bool {
12028        if self.overlay.extra_keys.contains(key) {
12029            return true;
12030        }
12031        if self.overlay.deleted_keys.contains(key) {
12032            return false;
12033        }
12034        self.db.ids.get(key).is_some()
12035    }
12036
12037    fn has_prop(&self, key: &str, field: &str) -> bool {
12038        if !self.has_key(key) {
12039            return false;
12040        }
12041        let k = (key.to_string(), field.to_string());
12042        if self.overlay.removed_props.contains(&k) {
12043            return false;
12044        }
12045        if self.overlay.extra_props.contains_key(&k) {
12046            return true;
12047        }
12048        // Fresh identity (first insert in this batch, or delete+reinsert):
12049        // ignore props still sitting on the soon-to-be-tombstoned slot.
12050        if self.overlay.extra_keys.contains(key) {
12051            return false;
12052        }
12053        self.db.get_prop(key, field).is_some()
12054    }
12055
12056    fn has_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
12057        let k = (
12058            edge_type.to_string(),
12059            src_key.to_string(),
12060            dst_key.to_string(),
12061        );
12062        if self.overlay.deleted_edges.contains(&k) {
12063            return false;
12064        }
12065        if self.overlay.extra_edges.contains(&k) {
12066            return true;
12067        }
12068        // A key created in this batch (including reinsert) has no db edges.
12069        if self.overlay.extra_keys.contains(src_key) || self.overlay.extra_keys.contains(dst_key) {
12070            return false;
12071        }
12072        if self.overlay.deleted_keys.contains(src_key)
12073            || self.overlay.deleted_keys.contains(dst_key)
12074        {
12075            return false;
12076        }
12077        let Some(src) = self.db.ids.get(src_key) else {
12078            return false;
12079        };
12080        let Some(dst) = self.db.ids.get(dst_key) else {
12081            return false;
12082        };
12083        let Some(sym) = self.db.syms.get(edge_type) else {
12084            return false;
12085        };
12086        self.db
12087            .topo_view()
12088            .neighbors(sym, Direction::Out, src)
12089            .binary_search(&dst)
12090            .is_ok()
12091    }
12092
12093    fn has_rule(&self, name: &str) -> bool {
12094        if self.overlay.extra_rules.contains(name) {
12095            return true;
12096        }
12097        if self.overlay.deleted_rules.contains(name) {
12098            return false;
12099        }
12100        self.db.engine.rules().any(|r| r.name == name)
12101    }
12102
12103    fn is_rule_owned(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
12104        if self.overlay.extra_keys.contains(src_key) || self.overlay.extra_keys.contains(dst_key) {
12105            return false;
12106        }
12107        if self.overlay.deleted_keys.contains(src_key)
12108            || self.overlay.deleted_keys.contains(dst_key)
12109        {
12110            return false;
12111        }
12112        let Some(src) = self.db.ids.get(src_key) else {
12113            return false;
12114        };
12115        let Some(dst) = self.db.ids.get(dst_key) else {
12116            return false;
12117        };
12118        let Some(et) = self.db.syms.get(edge_type) else {
12119            return false;
12120        };
12121        // extra_rules is deliberately not consulted: a CreateRule earlier in
12122        // this batch has not fired, so it contributes no provenance. That is
12123        // the documented rule-window gap (see GraphDb::batch).
12124        if self.overlay.deleted_rules.is_empty() {
12125            return self.db.engine.is_owned(et, src, dst);
12126        }
12127        for (rule, triples) in self.db.engine.provenance() {
12128            if self.overlay.deleted_rules.contains(rule) {
12129                continue;
12130            }
12131            if triples.contains(&(et, src, dst)) {
12132                return true;
12133            }
12134        }
12135        false
12136    }
12137
12138    fn check_insert_node(&self, key: &str) -> Result<()> {
12139        if self.has_key(key) {
12140            Err(GraphError::DuplicateKey { key: key.into() })
12141        } else {
12142            Ok(())
12143        }
12144    }
12145
12146    fn check_live_key(&self, key: &str) -> Result<()> {
12147        if self.has_key(key) {
12148            Ok(())
12149        } else {
12150            Err(GraphError::KeyNotFound { key: key.into() })
12151        }
12152    }
12153
12154    fn prepare_insert_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
12155        for k in [src_key, dst_key] {
12156            if !self.has_key(k) {
12157                return Err(GraphError::KeyNotFound { key: k.into() });
12158            }
12159        }
12160        if self.is_rule_owned(edge_type, src_key, dst_key) {
12161            return Err(GraphError::RuleOwned {
12162                detail: format!("edge {edge_type} {src_key}→{dst_key} is rule-owned"),
12163            });
12164        }
12165        // A user-written edge stays inside one namespace. Derived edges do not
12166        // come through here — the engine adds them directly — and the rule
12167        // scoping check is what keeps those pure.
12168        let src_ns = self.namespace_in_batch(src_key);
12169        let dst_ns = self.namespace_in_batch(dst_key);
12170        if src_ns != dst_ns {
12171            return Err(GraphError::CrossNamespace {
12172                src: src_key.to_string(),
12173                src_ns,
12174                dst: dst_key.to_string(),
12175                dst_ns,
12176            });
12177        }
12178        Ok(!self.has_edge(edge_type, src_key, dst_key))
12179    }
12180
12181    fn prepare_remove_prop(&self, key: &str, field: &str) -> Result<bool> {
12182        self.check_live_key(key)?;
12183        // Removing `ns` is changing the namespace — to `default`, the namespace
12184        // an absent property names. It goes through this one choke-point and NOT
12185        // through `rewrite_wal_dense` (a `RemoveProp` needs no dense rewrite), so
12186        // the immutability rule has to be stated here as well. Without it the
12187        // node silently lands in `default` on the next open: the cross-namespace
12188        // edge guard is defeated and a default-bound role reads a tenant's node.
12189        if field == NS_PROP {
12190            let from = self.namespace_in_batch(key);
12191            if from != NS_DEFAULT {
12192                return Err(GraphError::NamespaceImmutable {
12193                    key: key.to_string(),
12194                    from,
12195                    to: NS_DEFAULT.to_string(),
12196                });
12197            }
12198            // Already in `default`: the removal changes no namespace. It is the
12199            // no-op `set_prop` to the current namespace is, not an error.
12200            return Ok(false);
12201        }
12202        Ok(self.has_prop(key, field))
12203    }
12204
12205    fn prepare_delete_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
12206        for k in [src_key, dst_key] {
12207            if !self.has_key(k) {
12208                return Err(GraphError::KeyNotFound { key: k.into() });
12209            }
12210        }
12211        // Provenance-owned OR a live rule would derive this pair. User-first
12212        // edges that a later rule matches are not in `owned`, but deleting
12213        // them would leave a hole `rebuild_rule` immediately fills.
12214        if self.is_rule_owned(edge_type, src_key, dst_key) {
12215            return Err(GraphError::RuleOwned {
12216                detail: format!(
12217                    "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
12218                     delete or change the owning rule"
12219                ),
12220            });
12221        }
12222        if self.would_derive(edge_type, src_key, dst_key) {
12223            return Err(GraphError::RuleOwned {
12224                detail: format!(
12225                    "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
12226                     delete or change the owning rule, or a live rule would re-derive it"
12227                ),
12228            });
12229        }
12230        Ok(self.has_edge(edge_type, src_key, dst_key))
12231    }
12232
12233    /// True if any live rule (minus overlay-deleted names) would derive
12234    /// `(edge_type, src, dst)` from current overlay-visible props/labels.
12235    /// CreateRule names in `extra_rules` are ignored — same documented
12236    /// same-batch rule-window as [`Self::is_rule_owned`].
12237    fn would_derive(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
12238        if src_key == dst_key {
12239            return false;
12240        }
12241        let Some(src_label) = self.label_of(src_key) else {
12242            return false;
12243        };
12244        let Some(dst_label) = self.label_of(dst_key) else {
12245            return false;
12246        };
12247        for rule in self.db.engine.rules() {
12248            if self.overlay.deleted_rules.contains(&rule.name) {
12249                continue;
12250            }
12251            if rule.edge_type != edge_type {
12252                continue;
12253            }
12254            if rule.src_label != src_label || rule.dst_label != dst_label {
12255                continue;
12256            }
12257            let src_props = |f: &str| self.prop_value(src_key, f);
12258            let dst_props = |f: &str| self.prop_value(dst_key, f);
12259            let src_view = NodeView {
12260                key: src_key,
12261                props: &src_props,
12262            };
12263            let dst_view = NodeView {
12264                key: dst_key,
12265                props: &dst_props,
12266            };
12267            if evaluate(&rule.predicate, &src_view, &dst_view).is_some() {
12268                return true;
12269            }
12270        }
12271        false
12272    }
12273
12274    fn label_of(&self, key: &str) -> Option<String> {
12275        if self.overlay.deleted_keys.contains(key) {
12276            return None;
12277        }
12278        // Fresh identities created in this batch have no stored label in the
12279        // overlay; they cannot be provenance-owned yet either.
12280        let id = self.db.ids.get(key)?;
12281        let sym = self.db.labels.get(id as usize).copied()?;
12282        if sym == u32::MAX {
12283            return None;
12284        }
12285        self.db.syms.resolve(sym).map(str::to_string)
12286    }
12287
12288    /// The namespace `key` is in as this batch sees it — including a node
12289    /// inserted earlier in the same batch, which the store does not have yet.
12290    fn namespace_in_batch(&self, key: &str) -> String {
12291        namespace_of_value(self.prop_value(key, NS_PROP).as_ref()).to_string()
12292    }
12293
12294    fn prop_value(&self, key: &str, field: &str) -> Option<Value> {
12295        if !self.has_key(key) {
12296            return None;
12297        }
12298        let k = (key.to_string(), field.to_string());
12299        if self.overlay.removed_props.contains(&k) {
12300            return None;
12301        }
12302        if let Some(v) = self.overlay.extra_props.get(&k) {
12303            return Some(v.clone());
12304        }
12305        if self.overlay.extra_keys.contains(key) {
12306            return None;
12307        }
12308        self.db.get_prop(key, field)
12309    }
12310
12311    fn check_create_rule(&self, def: &RuleDef) -> Result<()> {
12312        def.validate()
12313            .map_err(|e| GraphError::RuleInvalid { detail: e })?;
12314        if self.has_rule(&def.name) {
12315            return Err(GraphError::RuleInvalid {
12316                detail: format!("rule {:?} already exists", def.name),
12317            });
12318        }
12319        // Rule-chain cycle rejection. Derived edges feed via-hop rules, so a
12320        // rule set forms a graph whose arcs are "hops over `via_edge`, writes
12321        // `edge_type`". A cycle in that graph is a rule set that would re-fire
12322        // itself forever; the engine's depth cap would silently truncate it
12323        // instead, leaving an arbitrary partial result. Reject it here, the one
12324        // place that sees the whole rule set.
12325        //
12326        // Rules accepted earlier in the same batch count too: the overlay
12327        // carries their arcs, so a cycle cannot be assembled one op at a time.
12328        if let Some(via) = def.via_edge.as_deref() {
12329            if via == def.edge_type {
12330                return Err(GraphError::RuleInvalid {
12331                    detail: format!("rule chain cycle: {} -> {}", via, def.edge_type),
12332                });
12333            }
12334            let mut arcs: Vec<(String, String)> = self
12335                .db
12336                .engine
12337                .rules()
12338                .filter(|r| !self.overlay.deleted_rules.contains(&r.name))
12339                .filter_map(|r| r.via_edge.clone().map(|v| (v, r.edge_type.clone())))
12340                .collect();
12341            arcs.extend(self.overlay.extra_rule_arcs.values().cloned());
12342            arcs.push((via.to_string(), def.edge_type.clone()));
12343            if let Some(path) = find_cycle_through(&arcs, &def.edge_type, via) {
12344                return Err(GraphError::RuleInvalid {
12345                    detail: format!("rule chain cycle: {} -> {}", via, path.join(" -> ")),
12346                });
12347            }
12348        }
12349        Ok(())
12350    }
12351
12352    fn check_delete_rule(&self, name: &str) -> Result<()> {
12353        if self.has_rule(name) {
12354            Ok(())
12355        } else {
12356            Err(GraphError::RuleNotFound { name: name.into() })
12357        }
12358    }
12359
12360    fn note_insert_node(&mut self, key: &str, props: &[(String, Value)]) {
12361        self.overlay.deleted_keys.remove(key);
12362        self.overlay.extra_keys.insert(key.to_string());
12363        self.overlay.extra_props.retain(|(k, _), _| k != key);
12364        self.overlay.removed_props.retain(|(k, _)| k != key);
12365        for (field, value) in props {
12366            self.overlay
12367                .extra_props
12368                .insert((key.to_string(), field.clone()), value.clone());
12369        }
12370    }
12371
12372    fn note_insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) {
12373        let k = (
12374            edge_type.to_string(),
12375            src_key.to_string(),
12376            dst_key.to_string(),
12377        );
12378        self.overlay.deleted_edges.remove(&k);
12379        self.overlay.extra_edges.insert(k);
12380    }
12381
12382    fn note_set_prop(&mut self, key: &str, field: &str, value: &Value) {
12383        let k = (key.to_string(), field.to_string());
12384        self.overlay.removed_props.remove(&k);
12385        self.overlay.extra_props.insert(k, value.clone());
12386    }
12387
12388    fn note_remove_prop(&mut self, key: &str, field: &str) {
12389        let k = (key.to_string(), field.to_string());
12390        self.overlay.extra_props.remove(&k);
12391        self.overlay.removed_props.insert(k);
12392    }
12393
12394    fn note_delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) {
12395        let k = (
12396            edge_type.to_string(),
12397            src_key.to_string(),
12398            dst_key.to_string(),
12399        );
12400        self.overlay.extra_edges.remove(&k);
12401        self.overlay.deleted_edges.insert(k);
12402    }
12403
12404    fn note_delete_node(&mut self, key: &str) {
12405        self.overlay.extra_keys.remove(key);
12406        self.overlay.deleted_keys.insert(key.to_string());
12407        self.overlay.extra_props.retain(|(k, _), _| k != key);
12408        self.overlay.removed_props.retain(|(k, _)| k != key);
12409        self.overlay
12410            .extra_edges
12411            .retain(|(_, s, d)| s != key && d != key);
12412        self.overlay
12413            .deleted_edges
12414            .retain(|(_, s, d)| s != key && d != key);
12415    }
12416
12417    fn note_create_rule(&mut self, def: &RuleDef) {
12418        self.overlay.deleted_rules.remove(&def.name);
12419        self.overlay.extra_rules.insert(def.name.clone());
12420        // Rules accepted earlier in this batch are not in the engine yet, so
12421        // the cycle check would not see their arcs. Keep the arc, not just the
12422        // name, so a batch cannot smuggle in a cycle one op at a time.
12423        if let Some(via) = def.via_edge.clone() {
12424            self.overlay
12425                .extra_rule_arcs
12426                .insert(def.name.clone(), (via, def.edge_type.clone()));
12427        }
12428    }
12429
12430    fn check_rename_node(&self, old: &str, new: &str) -> Result<()> {
12431        if !self.has_key(old) {
12432            return Err(GraphError::KeyNotFound { key: old.into() });
12433        }
12434        if self.has_key(new) {
12435            return Err(GraphError::DuplicateKey { key: new.into() });
12436        }
12437        Ok(())
12438    }
12439
12440    fn note_rename_node(&mut self, old: &str, new: &str) {
12441        // Mark old as deleted so subsequent batch ops cannot reference it.
12442        self.overlay.extra_keys.remove(old);
12443        self.overlay.deleted_keys.insert(old.to_string());
12444        // Mark new as extra so subsequent batch ops can reference it.
12445        self.overlay.deleted_keys.remove(new);
12446        self.overlay.extra_keys.insert(new.to_string());
12447        // Migrate any overlay props from old key to new key.
12448        let new_str = new.to_string();
12449        let transferred: Vec<((String, String), Value)> = self
12450            .overlay
12451            .extra_props
12452            .iter()
12453            .filter(|((k, _), _)| k.as_str() == old)
12454            .map(|((_, f), v)| ((new_str.clone(), f.clone()), v.clone()))
12455            .collect();
12456        self.overlay
12457            .extra_props
12458            .retain(|(k, _), _| k.as_str() != old);
12459        for (k, v) in transferred {
12460            self.overlay.extra_props.insert(k, v);
12461        }
12462        // Migrate removed_props.
12463        let transferred_removed: Vec<(String, String)> = self
12464            .overlay
12465            .removed_props
12466            .iter()
12467            .filter(|(k, _)| k.as_str() == old)
12468            .map(|(_, f)| (new_str.clone(), f.clone()))
12469            .collect();
12470        self.overlay
12471            .removed_props
12472            .retain(|(k, _)| k.as_str() != old);
12473        for k in transferred_removed {
12474            self.overlay.removed_props.insert(k);
12475        }
12476    }
12477
12478    fn note_delete_rule(&mut self, name: &str) {
12479        self.overlay.extra_rules.remove(name);
12480        // Drop its chain arc too: a rule created and then deleted in the same
12481        // batch must not make a later, legal rule look like a cycle.
12482        self.overlay.extra_rule_arcs.remove(name);
12483        self.overlay.deleted_rules.insert(name.to_string());
12484        // Treat the deleted rule's current provenance as gone so a later
12485        // delete_edge of those triples is a no-op (matches sequential).
12486        if let Some(triples) = self.db.engine.provenance().get(name) {
12487            for &(et, s, d) in triples {
12488                let Some(etype) = self.db.syms.resolve(et) else {
12489                    continue;
12490                };
12491                let Some(src) = self.db.ids.key_of(s) else {
12492                    continue;
12493                };
12494                let Some(dst) = self.db.ids.key_of(d) else {
12495                    continue;
12496                };
12497                let k = (etype.to_string(), src.to_string(), dst.to_string());
12498                self.overlay.extra_edges.remove(&k);
12499                self.overlay.deleted_edges.insert(k);
12500            }
12501        }
12502    }
12503}
12504
12505/// Collects mutations and commits them as one WAL `Batch` frame.
12506///
12507/// Holds `&mut GraphDb` for its lifetime. Queue with the same method names
12508/// as [`GraphDb`]; call [`commit`](Self::commit) to validate, log, and apply.
12509/// See [`GraphDb::batch`] for validation and atomicity rules.
12510pub struct BatchBuilder<'a, F: Fs> {
12511    db: &'a mut GraphDb<F>,
12512    ops: Vec<BatchOp>,
12513}
12514
12515impl<'a, F: Fs> BatchBuilder<'a, F> {
12516    pub fn insert_node(
12517        &mut self,
12518        label: &str,
12519        key: &str,
12520        props: Vec<(String, Value)>,
12521    ) -> &mut Self {
12522        self.ops.push(BatchOp::InsertNode {
12523            label: label.into(),
12524            key: key.into(),
12525            props,
12526        });
12527        self
12528    }
12529
12530    pub fn insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> &mut Self {
12531        self.ops.push(BatchOp::InsertEdge {
12532            edge_type: edge_type.into(),
12533            src_key: src_key.into(),
12534            dst_key: dst_key.into(),
12535        });
12536        self
12537    }
12538
12539    pub fn set_prop(&mut self, key: &str, field: &str, value: Value) -> &mut Self {
12540        self.ops.push(BatchOp::SetProp {
12541            key: key.into(),
12542            field: field.into(),
12543            value,
12544        });
12545        self
12546    }
12547
12548    pub fn remove_prop(&mut self, key: &str, field: &str) -> &mut Self {
12549        self.ops.push(BatchOp::RemoveProp {
12550            key: key.into(),
12551            field: field.into(),
12552        });
12553        self
12554    }
12555
12556    pub fn delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> &mut Self {
12557        self.ops.push(BatchOp::DeleteEdge {
12558            edge_type: edge_type.into(),
12559            src_key: src_key.into(),
12560            dst_key: dst_key.into(),
12561        });
12562        self
12563    }
12564
12565    pub fn delete_node(&mut self, key: &str) -> &mut Self {
12566        self.ops.push(BatchOp::DeleteNode { key: key.into() });
12567        self
12568    }
12569
12570    pub fn create_rule(&mut self, def: RuleDef) -> &mut Self {
12571        self.ops.push(BatchOp::CreateRule(def));
12572        self
12573    }
12574
12575    pub fn delete_rule(&mut self, name: &str) -> &mut Self {
12576        self.ops.push(BatchOp::DeleteRule { name: name.into() });
12577        self
12578    }
12579
12580    /// Queue a node-rename in this batch.
12581    ///
12582    /// Validation (old exists, new not taken) runs at commit time.
12583    pub fn rename_node(&mut self, old_key: &str, new_key: &str) -> &mut Self {
12584        self.ops.push(BatchOp::RenameNode {
12585            old_key: old_key.into(),
12586            new_key: new_key.into(),
12587        });
12588        self
12589    }
12590
12591    /// Queue an edge insert with endpoint auto-creation.
12592    ///
12593    /// Any missing endpoint is created as a plain node `{key, label:
12594    /// placeholder_label, no props}` inside this batch frame. Rules fire and
12595    /// last-change is updated for each auto-created node.
12596    pub fn insert_edge_upsert(
12597        &mut self,
12598        edge_type: &str,
12599        src_key: &str,
12600        dst_key: &str,
12601        placeholder_label: &str,
12602    ) -> &mut Self {
12603        self.ops.push(BatchOp::InsertEdgeUpsert {
12604            edge_type: edge_type.into(),
12605            src_key: src_key.into(),
12606            dst_key: dst_key.into(),
12607            placeholder_label: placeholder_label.into(),
12608        });
12609        self
12610    }
12611
12612    /// Validate every queued op, then log one `Batch` frame and apply.
12613    /// Empty / all-noop batches return `Ok(())` without writing the WAL.
12614    /// A second `commit()` after a successful one is an empty-batch no-op
12615    /// (queued ops were taken).
12616    /// Takes `&mut self` so it chains after the queue methods (`b.insert_node(..).commit()`)
12617    /// and also works as `let mut b = db.batch(); b.insert_node(..); b.commit()`.
12618    ///
12619    /// **Rule-window limitation:** batch validation cannot see edges that a
12620    /// rule created earlier in the *same* batch will derive at apply time, so
12621    /// a `delete_edge` / `insert_edge` in that window is silently no-oped
12622    /// where sequential calls would return `Err(RuleOwned)`. State integrity
12623    /// is unaffected (idempotent apply, provenance intact). Create rules in
12624    /// their own batch, or sequentially, when later ops may touch derived
12625    /// edges.
12626    /// Validate every queued op and commit atomically.
12627    ///
12628    /// Returns `(nodes_inserted, edges_inserted)` — the counts of node and edge
12629    /// WAL records actually written (duplicate edges are silent no-ops and are
12630    /// NOT counted). Both are 0 when the batch is empty or all-noop.
12631    pub fn commit(&mut self) -> Result<(usize, usize)> {
12632        let ops = std::mem::take(&mut self.ops);
12633        self.db.commit_batch(ops)
12634    }
12635
12636    /// Same as [`commit`](Self::commit) but tail the inner events with
12637    /// [`MutationEvent::Ingested`] instead of [`MutationEvent::BatchApplied`].
12638    pub(crate) fn commit_ingest(&mut self, label: &str, inserted: usize) -> Result<(usize, usize)> {
12639        let ops = std::mem::take(&mut self.ops);
12640        self.db
12641            .commit_logged_batch(ops, Some((label.to_string(), inserted)), None)
12642    }
12643}
12644
12645pub struct NodeRef<'a, F: Fs> {
12646    db: &'a GraphDb<F>,
12647    id: u32,
12648}
12649
12650impl<'a, F: Fs> NodeRef<'a, F> {
12651    pub fn key(&self) -> &str {
12652        self.db.ids.key_of(self.id).expect("dense ids")
12653    }
12654
12655    pub fn label(&self) -> &str {
12656        let sym = self
12657            .db
12658            .labels
12659            .get(self.id as usize)
12660            .copied()
12661            .filter(|&s| s != u32::MAX)
12662            .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
12663        self.db.syms.resolve(sym).expect("interned label symbol")
12664    }
12665
12666    pub fn prop(&self, field: &str) -> Option<Value> {
12667        self.db
12668            .props_view()
12669            .get(self.id, field)
12670            .map(|vr| vr.into_value())
12671    }
12672
12673    /// All stored fields for this node, sorted by field name.
12674    ///
12675    /// Reads from the full base+overlay view so that props stored only in the
12676    /// V8 snapshot base (i.e. before any post-snapshot WAL writes) are visible.
12677    pub fn props(&self) -> BTreeMap<String, Value> {
12678        let mut out = BTreeMap::new();
12679        let pv = self.db.props_view();
12680        for field in pv.field_names() {
12681            if let Some(vr) = pv.get(self.id, &field) {
12682                out.insert(field, vr.into_value());
12683            }
12684        }
12685        out
12686    }
12687
12688    /// depth-N BFS as a ResultSet: columns ["key","label","depth"], BFS order.
12689    pub fn neighborhood(&self, depth: u32, edge_types: Option<&[&str]>, dir: Dir) -> ResultSet {
12690        let view = self.db.view();
12691        let resolved: Option<Vec<u32>> = edge_types.map(|names| {
12692            names
12693                .iter()
12694                .filter_map(|name| view.syms.get(name))
12695                .collect()
12696        });
12697        let nb = neighborhood(&view, self.id, depth, resolved.as_deref(), dir);
12698        let mut rs = ResultSet::new(vec!["key".into(), "label".into(), "depth".into()]);
12699        for (nid, d) in nb.nodes {
12700            let key = view.key_of(nid);
12701            let label = view
12702                .label_of(nid)
12703                .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
12704            rs.push_row(vec![
12705                Some(Value::Str(key.to_string())),
12706                Some(Value::Str(label.to_string())),
12707                Some(Value::Int(d as i64)),
12708            ]);
12709        }
12710        rs
12711    }
12712
12713    /// 1-hop, Both directions: edge-type name → sorted unique neighbor keys.
12714    pub fn grouped_by_edge_type(&self) -> BTreeMap<String, Vec<String>> {
12715        let view = self.db.view();
12716        let mut groups: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
12717        for e in expand(&view, self.id, None, Dir::Both) {
12718            // Skip edges with unknown etypes (only possible from corrupt large
12719            // TOPOLOGY section; function returns BTreeMap not Result).
12720            let Some(etype) = view.syms.resolve(e.etype) else {
12721                continue;
12722            };
12723            let etype = etype.to_string();
12724            let nbr = if e.src == self.id { e.dst } else { e.src };
12725            groups
12726                .entry(etype)
12727                .or_default()
12728                .insert(view.key_of(nbr).to_string());
12729        }
12730        groups
12731            .into_iter()
12732            .map(|(k, v)| (k, v.into_iter().collect()))
12733            .collect()
12734    }
12735}
12736
12737#[cfg(test)]
12738mod tests {
12739    use super::*;
12740    use core_rules::Predicate;
12741
12742    fn tmp_dir(name: &str) -> std::path::PathBuf {
12743        let d =
12744            std::env::temp_dir().join(format!("graphdb-db-unit-{}-{}", name, std::process::id()));
12745        let _ = std::fs::remove_dir_all(&d);
12746        d
12747    }
12748
12749    fn fk_rule() -> RuleDef {
12750        RuleDef {
12751            name: "works_at".into(),
12752            src_label: "Person".into(),
12753            dst_label: "Org".into(),
12754            predicate: Predicate::KeyMatch {
12755                field: "org_id".into(),
12756            },
12757            edge_type: "WORKS_AT".into(),
12758            weight_prop: None,
12759            max_edges: None,
12760            approximate: false,
12761            via_label: None,
12762            via_edge: None,
12763            via_dir: None,
12764            namespace: None,
12765        }
12766    }
12767
12768    /// Regression guard for the no-views delta-copy fast path.
12769    ///
12770    /// When no views are defined, `pending_deltas_since().to_vec()` must never
12771    /// be called — even during a large CreateRule backfill. The DELTA_COPY_COUNT
12772    /// thread-local is incremented inside every `if !view_store.is_empty()` block;
12773    /// a count of 0 after the entire sequence proves the guard fires correctly.
12774    #[test]
12775    fn no_delta_copy_when_no_views() {
12776        DELTA_COPY_COUNT.with(|c| c.set(0));
12777        let dir = tmp_dir("no-delta-copy");
12778        {
12779            let mut db = GraphDb::open(&dir).unwrap();
12780            // Insert 50 Org + 50 Person nodes with FK links.
12781            for i in 0..50u32 {
12782                db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
12783            }
12784            for i in 0..50u32 {
12785                db.insert_node(
12786                    "Person",
12787                    &format!("p{i}"),
12788                    vec![("org_id".into(), Value::Str(format!("o{i}")))],
12789                )
12790                .unwrap();
12791            }
12792            // CreateRule backfill should NOT invoke to_vec() when no views are defined.
12793            db.create_rule(fk_rule()).unwrap();
12794
12795            // Counter must stay 0 — no views, no copies.
12796            let copies = DELTA_COPY_COUNT.with(|c| c.get());
12797            assert_eq!(
12798                copies, 0,
12799                "pending_deltas_since().to_vec() called despite no views"
12800            );
12801
12802            // Derived edges must still be correct (the guard skips only the
12803            // empty delta propagation loop, not the rule application itself).
12804            let nbrs = db.neighbors("p0", "WORKS_AT", Direction::Out).unwrap();
12805            assert_eq!(
12806                nbrs,
12807                vec!["o0"],
12808                "rule must derive edges even with no views"
12809            );
12810        }
12811        let _ = std::fs::remove_dir_all(&dir);
12812    }
12813
12814    /// Gating regression: subscribe AFTER a backfill must see no stale events.
12815    /// subscribe BEFORE a backfill must see every edge-fire event.
12816    #[test]
12817    fn subscribe_after_backfill_no_stale_events() {
12818        let dir = tmp_dir("sub-after-backfill");
12819        {
12820            let mut db = GraphDb::open(&dir).unwrap();
12821            for i in 0..10u32 {
12822                db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
12823                db.insert_node(
12824                    "Person",
12825                    &format!("p{i}"),
12826                    vec![("org_id".into(), Value::Str(format!("o{i}")))],
12827                )
12828                .unwrap();
12829            }
12830            // Create rule BEFORE subscribing — emit_deltas is false during backfill.
12831            db.create_rule(fk_rule()).unwrap();
12832
12833            // Subscribe AFTER the backfill — queue must be empty (no stale events).
12834            let sub = db.subscribe_all_rules().unwrap();
12835            // No events should have queued for the prior backfill.
12836            assert!(
12837                sub.try_recv().is_none(),
12838                "subscribe after backfill must see no stale events"
12839            );
12840
12841            // Inserting a new node now should fire an event (emit_deltas is now true).
12842            db.insert_node("Org", "o_new", vec![]).unwrap();
12843            db.insert_node(
12844                "Person",
12845                "p_new",
12846                vec![("org_id".into(), Value::Str("o_new".into()))],
12847            )
12848            .unwrap();
12849            let ev = sub.recv_timeout(std::time::Duration::from_millis(200));
12850            assert!(
12851                ev.is_some(),
12852                "edge-fire event must arrive after subscribe (emit_deltas=true)"
12853            );
12854        }
12855        let _ = std::fs::remove_dir_all(&dir);
12856    }
12857
12858    /// Gating regression: subscribe BEFORE a backfill → events flow.
12859    #[test]
12860    fn subscribe_before_backfill_events_flow() {
12861        let dir = tmp_dir("sub-before-backfill");
12862        {
12863            let mut db = GraphDb::open(&dir).unwrap();
12864            // Subscribe FIRST — emit_deltas becomes true.
12865            let sub = db.subscribe_all_rules().unwrap();
12866
12867            for i in 0..5u32 {
12868                db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
12869                db.insert_node(
12870                    "Person",
12871                    &format!("p{i}"),
12872                    vec![("org_id".into(), Value::Str(format!("o{i}")))],
12873                )
12874                .unwrap();
12875            }
12876            // Backfill fires with emit_deltas=true → events queued.
12877            db.create_rule(fk_rule()).unwrap();
12878
12879            // Should receive at least one edge-fired event from the backfill.
12880            let mut received = 0usize;
12881            while sub.try_recv().is_some() {
12882                received += 1;
12883            }
12884            assert!(
12885                received > 0,
12886                "subscribe before backfill must receive edge-fire events (got 0)"
12887            );
12888        }
12889        let _ = std::fs::remove_dir_all(&dir);
12890    }
12891
12892    /// Companion: when a view IS defined, the delta path fires and view values update.
12893    #[test]
12894    fn delta_copy_fires_when_view_exists() {
12895        use core_rules::ViewSource;
12896        DELTA_COPY_COUNT.with(|c| c.set(0));
12897        let dir = tmp_dir("delta-copy-with-view");
12898        {
12899            let mut db = GraphDb::open(&dir).unwrap();
12900            db.insert_node("Org", "o1", vec![]).unwrap();
12901            db.insert_node(
12902                "Person",
12903                "p1",
12904                vec![("org_id".into(), Value::Str("o1".into()))],
12905            )
12906            .unwrap();
12907            // Declare a Degree view so is_empty() returns false.
12908            db.create_view(ViewDef {
12909                name: "degree_out".into(),
12910                label: "Person".into(),
12911                view_prop: "degree_out".into(),
12912                source: ViewSource::Degree {
12913                    edge_type: "WORKS_AT".into(),
12914                    direction: Direction::Out,
12915                },
12916            })
12917            .unwrap();
12918            db.create_rule(fk_rule()).unwrap();
12919
12920            // At least one delta copy should have happened (CreateRule backfill).
12921            let copies = DELTA_COPY_COUNT.with(|c| c.get());
12922            assert!(
12923                copies > 0,
12924                "expected delta copy to fire when a view is defined"
12925            );
12926
12927            // View value should be computed: p1 has one WORKS_AT out-edge.
12928            let info = db.node_info("p1").unwrap();
12929            let degree = info.props.get("degree_out");
12930            assert!(
12931                degree.is_some(),
12932                "view prop should be written to node props"
12933            );
12934        }
12935        let _ = std::fs::remove_dir_all(&dir);
12936    }
12937
12938    /// Regression: `open_at_with` must call `rebuild_all` after WAL replay so
12939    /// derived-edge-driven view values reflect the as-of state rather than just
12940    /// the initial backfill written at `CreateView` time.
12941    ///
12942    /// Base WAL frames (indices 0..=5 before history markers):
12943    ///   0: insert Org "o1"
12944    ///   1: create_view "employee_count" (Degree / WORKS_AT / In) on Org
12945    ///   2: create_rule fk_rule (WORKS_AT, Person→Org via org_id)
12946    ///   3: insert Person "p1" → rule fires WORKS_AT p1→o1 (degree = 1)  ← mid
12947    ///   4: insert Person "p2" → rule fires WORKS_AT p2→o1 (degree = 2)
12948    ///   5: insert Person "p3" → rule fires WORKS_AT p3→o1 (degree = 3)  ← latest
12949    ///
12950    /// Each rule-fire also appends a DerivedEdgeAdded history-marker frame (state
12951    /// no-op), so the total commit count is higher than the base frame count.
12952    /// The "latest" open_at commit is computed dynamically via `wal_commit_count_at`.
12953    ///
12954    /// Without `rebuild_all`, the as-of instance's "emp" view stays at the
12955    /// initial backfill value (0) instead of reflecting the replayed derived edges.
12956    #[test]
12957    fn open_at_derived_edge_view_values_correct() {
12958        use core_rules::ViewSource;
12959        let dir = tmp_dir("open-at-view-rebuild");
12960        {
12961            let mut db = GraphDb::open(&dir).unwrap();
12962            // frame 0
12963            db.insert_node("Org", "o1", vec![]).unwrap();
12964            // frame 1: create view — initial backfill sees 0 derived edges (none fired yet)
12965            db.create_view(ViewDef {
12966                name: "employee_count".into(),
12967                label: "Org".into(),
12968                view_prop: "emp".into(),
12969                source: ViewSource::Degree {
12970                    edge_type: "WORKS_AT".into(),
12971                    direction: Direction::In,
12972                },
12973            })
12974            .unwrap();
12975            // frame 2: create rule — no Persons yet; backfill is a no-op
12976            db.create_rule(fk_rule()).unwrap();
12977            // frame 3: p1 — rule fires WORKS_AT p1→o1; degree = 1
12978            db.insert_node(
12979                "Person",
12980                "p1",
12981                vec![("org_id".into(), Value::Str("o1".into()))],
12982            )
12983            .unwrap();
12984            // frame 4: p2 — degree = 2
12985            db.insert_node(
12986                "Person",
12987                "p2",
12988                vec![("org_id".into(), Value::Str("o1".into()))],
12989            )
12990            .unwrap();
12991            // frame 5: p3 — degree = 3
12992            db.insert_node(
12993                "Person",
12994                "p3",
12995                vec![("org_id".into(), Value::Str("o1".into()))],
12996            )
12997            .unwrap();
12998            // Sanity: normal open sees degree = 3.
12999            assert_eq!(
13000                db.get_view_prop("o1", "emp"),
13001                Some(Value::Int(3)),
13002                "normal db must show degree 3 after 3 derived edges"
13003            );
13004        } // WAL flushed
13005
13006        // Re-open normally to get the authoritative reference value.
13007        let normal_db = GraphDb::open(&dir).unwrap();
13008        let normal_emp = normal_db.get_view_prop("o1", "emp");
13009        assert_eq!(
13010            normal_emp,
13011            Some(Value::Int(3)),
13012            "re-opened normal db must show degree 3"
13013        );
13014
13015        // Latest as-of (last WAL commit): must match the normal open.
13016        // History-marker frames are appended after each rule-fire, so the total
13017        // commit count is computed dynamically rather than hardcoded.
13018        let total = crate::wal_commit_count_at(&dir).unwrap();
13019        let aof_latest = GraphDb::open_at(&dir, total - 1).unwrap();
13020        assert_eq!(
13021            aof_latest.get_view_prop("o1", "emp"),
13022            normal_emp,
13023            "open_at latest: derived-edge view must equal normal open (rebuild_all required)"
13024        );
13025
13026        // Mid-history as-of (commit 3 = p1 insert Batch frame): only p1; degree = 1.
13027        // The DerivedEdgeAdded marker for p1 is at frame 4 (state no-op on replay),
13028        // so replaying 0..=3 correctly re-derives only the p1→o1 edge.
13029        let aof_mid = GraphDb::open_at(&dir, 3).unwrap();
13030        assert_eq!(
13031            aof_mid.get_view_prop("o1", "emp"),
13032            Some(Value::Int(1)),
13033            "open_at mid-history: only p1 exists at frame 3, degree must be 1"
13034        );
13035
13036        let _ = std::fs::remove_dir_all(&dir);
13037    }
13038
13039    /// Pin: subscribe_* on an as-of instance must return Err(ReadOnly) —
13040    /// as-of instances never commit, so distribute_events never runs and any
13041    /// subscription would wait forever.
13042    #[test]
13043    fn subscribe_on_as_of_returns_read_only_error() {
13044        let dir = tmp_dir("sub-as-of-read-only");
13045        {
13046            let mut db = GraphDb::open(&dir).unwrap();
13047            db.insert_node("Org", "o1", vec![]).unwrap();
13048            db.create_rule(fk_rule()).unwrap();
13049        }
13050        let mut aof = GraphDb::open_at(&dir, 0).unwrap();
13051
13052        assert!(
13053            matches!(
13054                aof.subscribe_all_rules(),
13055                Err(core_storage::GraphError::ReadOnly)
13056            ),
13057            "subscribe_all_rules on as-of must return ReadOnly"
13058        );
13059        assert!(
13060            matches!(
13061                aof.subscribe_writes(),
13062                Err(core_storage::GraphError::ReadOnly)
13063            ),
13064            "subscribe_writes on as-of must return ReadOnly"
13065        );
13066        assert!(
13067            matches!(
13068                aof.subscribe_rule("works_at"),
13069                Err(core_storage::GraphError::ReadOnly)
13070            ),
13071            "subscribe_rule on as-of must return ReadOnly"
13072        );
13073        let _ = std::fs::remove_dir_all(&dir);
13074    }
13075
13076    /// Regression: a failed dense WAL rewrite must not leave speculative
13077    /// interns in `syms`. If it does, the next successful mutation logs an
13078    /// `Intern` record with an inflated id; replay (which never saw the
13079    /// orphans) assigns a smaller id and the WAL becomes unreplayable.
13080    #[test]
13081    fn dense_rewrite_error_rolls_back_speculative_interns() {
13082        let dir = tmp_dir("dense-rewrite-rollback");
13083        {
13084            let mut db = GraphDb::open(&dir).unwrap();
13085            db.insert_node("Person", "a", vec![]).unwrap();
13086
13087            // Bypass MutPreview validation to hit the rewrite's own error path
13088            // (same shape as an id-exhaustion failure mid-rewrite). The
13089            // InsertEdge arm interns the edge type before it resolves keys.
13090            let err = db.rewrite_wal_dense(vec![WalRecord::InsertEdge {
13091                edge_type: "ORPHAN_TYPE".into(),
13092                src_key: "missing".into(),
13093                dst_key: "a".into(),
13094            }]);
13095            assert!(err.is_err(), "rewrite of a missing src key must fail");
13096            assert_eq!(
13097                db.syms.get("ORPHAN_TYPE"),
13098                None,
13099                "failed rewrite must roll back speculative interns"
13100            );
13101
13102            // A later successful mutation must produce a replayable WAL.
13103            db.set_prop("a", "later_field", Value::Int(2)).unwrap();
13104        }
13105        let db = GraphDb::open(&dir).expect("WAL must replay after failed rewrite");
13106        assert_eq!(db.get_prop("a", "later_field"), Some(Value::Int(2)));
13107        let _ = std::fs::remove_dir_all(&dir);
13108    }
13109}