core_api/db.rs
1use crate::ingest::{IngestOptions, IngestReport};
2use crate::roles::{RoleDef, RolesFile, WriteScope};
3use crate::subscription::{
4 event_matches, DbEvent, SubEntry, SubFilter, SubInner, Subscription, DEFAULT_SUB_CAPACITY,
5};
6use core_query::cypher::ast::ArithOp;
7use core_query::cypher::{
8 execute, execute_union, is_subscribable, is_write_tokens, lex, parse, parse_read, parse_write,
9 plan, MatchDeleteNodeStmt, NodePat, Operand, Params, Pattern, PlanOp, Query, RetItem, RetVal,
10 WriteStatement,
11};
12use core_query::{eval_filter, expand, neighborhood, Dir, Filter, GraphView, ResultSet};
13use core_rules::{
14 decode_rule_def, evaluate, EngineEdgeDelta, GraphMut, NodeView, Predicate, RuleDef, RuleEngine,
15 ViewDef, ViewStore,
16};
17use core_storage::fs::{FileId, Fs, FsIntrospect, RealFs};
18use core_storage::fulltext::FulltextIndex;
19use core_storage::property_index::PropertyIndex;
20use core_storage::v8::encode::{
21 archived_hnsw_to_owned, archived_rules_meta_to_owned, archived_to_idmap, archived_to_interner,
22 archived_views_to_owned, decode_last_change_bytes, decode_meta, encode_v8, V8Meta,
23};
24use core_storage::v8::seam::TopologyView;
25use core_storage::wal::{decode_all, encode_record, WalRecord};
26use core_storage::EdgePropsView;
27use core_storage::{
28 ColumnStore, Direction, EdgeProps, GraphError, IdMap, Interner, Result, Topology, Value,
29};
30use serde::{Deserialize, Serialize};
31use std::collections::{BTreeMap, BTreeSet, HashMap};
32use std::sync::Arc;
33
34/// Print a timing checkpoint when MUSHROOMDB_TRACE_OPEN is set.
35/// Zero-cost when the env var is absent (the var check is O(1) after first call).
36macro_rules! trace_open {
37 ($phase:literal, $t:expr) => {
38 if std::env::var("MUSHROOMDB_TRACE_OPEN").is_ok() {
39 eprintln!(
40 "[MUSHROOMDB_TRACE_OPEN] {:40} {:>9.3?}",
41 $phase,
42 $t.elapsed()
43 );
44 }
45 };
46}
47
48/// Print a migration phase checkpoint when MUSHROOMDB_TRACE_MIGRATE is set.
49/// Zero-cost when the env var is absent (the var check is O(1) after first call).
50macro_rules! trace_migrate {
51 ($phase:literal, $t:expr) => {
52 if std::env::var("MUSHROOMDB_TRACE_MIGRATE").is_ok() {
53 eprintln!(
54 "[MUSHROOMDB_TRACE_MIGRATE] {:40} {:>9.3?}",
55 $phase,
56 $t.elapsed()
57 );
58 }
59 };
60}
61
62// Test-only: counts how many times `pending_deltas_since().to_vec()` actually
63// executes (i.e., at least one view is defined). Used to verify the fast-path
64// guard skips the allocation when `view_store.is_empty()`.
65#[cfg(test)]
66thread_local! {
67 static DELTA_COPY_COUNT: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
68}
69
70// Per-thread count of query-subscription `execute` calls in `distribute_events`.
71//
72// Incremented each time a query subscription actually runs its plan (i.e.,
73// the label-skip fast-path did not fire). Because `distribute_events` is
74// called synchronously on the writer thread, this thread-local correctly
75// isolates each test thread's count even when integration tests run in
76// parallel. Read via [`query_sub_exec_count`].
77thread_local! {
78 static QUERY_SUB_EXECS_TL: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
79}
80
81/// Return the number of query-subscription re-executions logged on this
82/// thread since the process started (or since last reset via
83/// [`reset_query_sub_exec_count`]).
84///
85/// Primarily for integration tests that verify the label-skip fast-path.
86#[doc(hidden)]
87pub fn query_sub_exec_count() -> usize {
88 QUERY_SUB_EXECS_TL.with(|c| c.get())
89}
90
91/// Reset the per-thread query-subscription execution counter to zero.
92#[doc(hidden)]
93pub fn reset_query_sub_exec_count() {
94 QUERY_SUB_EXECS_TL.with(|c| c.set(0));
95}
96
97/// Internal state for a single `subscribe_query` subscription.
98///
99/// On every commit, `distribute_events` re-executes `ops` against the current
100/// graph state, diffs the result against `prev_rows`, and pushes
101/// `DbEvent::QueryRowAdded` / `QueryRowRemoved` events to `inner`.
102///
103/// **Full re-run per commit; use LIMIT to bound execution cost.**
104/// (Differential evaluation is roadmap / Phase 5.)
105pub(crate) struct QuerySubEntry {
106 /// Compiled plan for the subscribed Cypher query.
107 ops: Vec<PlanOp>,
108 /// Column names from the first execution (fixed for the subscription lifetime).
109 columns: Vec<String>,
110 /// Serialized (JSON) row key → row data, representing the result set at
111 /// the end of the last commit. Used to diff against the new result.
112 prev_row_map: std::collections::HashMap<String, Vec<Option<Value>>>,
113 /// Weak pointer to the subscriber queue; dead Weak → subscription dropped.
114 inner: std::sync::Weak<SubInner>,
115 /// Interned label sym captured at subscribe time from the plan's leading scan
116 /// (`ScanLabel`, `IndexScan`, or `IndexIntersect` with a concrete label).
117 ///
118 /// `None` means the plan has an `Expand` op (or no recognizable leading scan
119 /// with a concrete label), and this subscription must re-execute on every
120 /// commit without skipping. This is the conservative v0.4.3 boundary: Expand
121 /// queries are never skipped because edges can alter join results regardless
122 /// of which node labels were written.
123 scan_label: Option<u32>,
124}
125
126/// A post-commit mutation notification.
127///
128/// Emitted from `log_then_apply` after the WAL append, fsync, and
129/// in-memory `apply` all succeed. Never emitted for rejected operations
130/// (validation errors, [`GraphError::RuleOwned`], duplicate keys, no-op
131/// deletes/removes). Event payloads carry user keys and rule names, never
132/// internal ids.
133///
134/// **Replay:** [`GraphDb::open`] / [`GraphDb::open_with`] replay the WAL via
135/// `apply` only. Emission lives exclusively in `log_then_apply`, so
136/// recovery is silent even if a sink were installed (it cannot be: the
137/// sink is in-memory and set after open).
138///
139/// **Ordering:** a `Batch` WAL frame emits one event per inner record, then
140/// [`MutationEvent::BatchApplied`]. An ingest commit emits those same inner
141/// events, then [`MutationEvent::Ingested`] (not `BatchApplied`). An empty
142/// or all-noop batch writes no WAL and emits nothing (including no summary).
143///
144/// **Derived edges:** rule-created or retracted edges are not individually
145/// evented — they are recoverable from the triggering mutation plus the live
146/// rule set. Only the triggering record is emitted.
147///
148/// **Wire form:** externally tagged snake_case JSON
149/// (`{"node_inserted":{"label":"A","key":"k"}}`).
150#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
151#[serde(rename_all = "snake_case")]
152pub enum MutationEvent {
153 NodeInserted {
154 label: String,
155 key: String,
156 },
157 PropSet {
158 key: String,
159 field: String,
160 },
161 PropRemoved {
162 key: String,
163 field: String,
164 },
165 EdgeInserted {
166 edge_type: String,
167 src: String,
168 dst: String,
169 },
170 EdgeDeleted {
171 edge_type: String,
172 src: String,
173 dst: String,
174 },
175 NodeDeleted {
176 key: String,
177 },
178 RuleCreated {
179 name: String,
180 },
181 RuleDeleted {
182 name: String,
183 },
184 RuleRebuilt {
185 name: String,
186 },
187 BatchApplied {
188 ops: usize,
189 },
190 Ingested {
191 label: String,
192 inserted: usize,
193 },
194}
195
196fn event_from_record(rec: &WalRecord, intern: &Interner, ids: &IdMap) -> Option<MutationEvent> {
197 match rec {
198 WalRecord::InsertNode { label, key, .. } => Some(MutationEvent::NodeInserted {
199 label: label.clone(),
200 key: key.clone(),
201 }),
202 WalRecord::InsertNodeId { label, key, .. } => Some(MutationEvent::NodeInserted {
203 label: intern.resolve(*label)?.to_string(),
204 key: key.clone(),
205 }),
206 WalRecord::SetProp { key, field, .. } => Some(MutationEvent::PropSet {
207 key: key.clone(),
208 field: field.clone(),
209 }),
210 WalRecord::SetPropId { id, field, .. } => Some(MutationEvent::PropSet {
211 key: ids.key_of(*id)?.to_string(),
212 field: intern.resolve(*field)?.to_string(),
213 }),
214 WalRecord::RemoveProp { key, field } => Some(MutationEvent::PropRemoved {
215 key: key.clone(),
216 field: field.clone(),
217 }),
218 WalRecord::InsertEdge {
219 edge_type,
220 src_key,
221 dst_key,
222 } => Some(MutationEvent::EdgeInserted {
223 edge_type: edge_type.clone(),
224 src: src_key.clone(),
225 dst: dst_key.clone(),
226 }),
227 WalRecord::InsertEdgeId { etype, src, dst } => Some(MutationEvent::EdgeInserted {
228 edge_type: intern.resolve(*etype)?.to_string(),
229 src: ids.key_of(*src)?.to_string(),
230 dst: ids.key_of(*dst)?.to_string(),
231 }),
232 WalRecord::DeleteEdge {
233 edge_type,
234 src_key,
235 dst_key,
236 } => Some(MutationEvent::EdgeDeleted {
237 edge_type: edge_type.clone(),
238 src: src_key.clone(),
239 dst: dst_key.clone(),
240 }),
241 WalRecord::DeleteNode { key } => Some(MutationEvent::NodeDeleted { key: key.clone() }),
242 WalRecord::CreateRule { def_bytes } => {
243 let def: RuleDef = decode_rule_def(def_bytes).ok()?;
244 Some(MutationEvent::RuleCreated { name: def.name })
245 }
246 WalRecord::DeleteRule { name } => Some(MutationEvent::RuleDeleted { name: name.clone() }),
247 WalRecord::RebuildRule { name } => Some(MutationEvent::RuleRebuilt { name: name.clone() }),
248 WalRecord::Batch(_)
249 | WalRecord::CreateView { .. }
250 | WalRecord::DeleteView { .. }
251 | WalRecord::EnableFulltext { .. }
252 | WalRecord::DisableFulltext { .. }
253 | WalRecord::EnableIndex { .. }
254 | WalRecord::DisableIndex { .. }
255 | WalRecord::Intern { .. }
256 // History markers are no-ops for mutation events — they carry no new
257 // state and rules re-derive deterministically on replay.
258 | WalRecord::DerivedEdgeAdded { .. }
259 | WalRecord::DerivedEdgeRetracted { .. }
260 // RenameNode carries no node/edge count change; no special event.
261 | WalRecord::RenameNode { .. } => None,
262 }
263}
264
265/// Database-wide counters plus per-rule budget/fire stats.
266#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
267pub struct Stats {
268 pub nodes_live: usize,
269 pub nodes_tombstoned: usize,
270 pub edges: u64,
271 pub rules: Vec<RuleStats>,
272}
273
274/// One rule's provenance size, trip latch, and fire counter.
275///
276/// `tripped` is a one-way latch: once set, the engine adds no new edges for
277/// that rule until [`GraphDb::rebuild_rule`] (and only if the full desired
278/// set then fits). `fires` counts `on_node_changed` evaluations plus
279/// backfill/rebuild participant ticks (rebuild counts even when it is a
280/// provenance no-op).
281#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
282pub struct RuleStats {
283 pub name: String,
284 pub edges: u64,
285 pub tripped: bool,
286 pub fires: u64,
287 /// Whether this rule uses the approximate IVF-Flat candidate path.
288 pub approximate: bool,
289}
290
291/// One entry in the slow-query ring buffer.
292#[derive(Debug, Clone, Serialize)]
293pub struct SlowQueryEntry {
294 /// Execution time in whole milliseconds.
295 pub ms: u64,
296 /// The Cypher query string that was slow.
297 pub query: String,
298 /// The commit sequence number at the time the query ran.
299 pub at_commit: u64,
300}
301
302/// Snapshot of the slow-query log returned by [`GraphDb::slow_query_snapshot`].
303#[derive(Debug, Clone, Serialize)]
304pub struct SlowQuerySnapshot {
305 /// Current threshold in milliseconds (0 = disabled).
306 pub threshold_ms: u64,
307 /// Total number of slow queries ever recorded (not capped by ring size).
308 pub count: u64,
309 /// Most-recent slow queries (up to 16), oldest first.
310 pub last: Vec<SlowQueryEntry>,
311}
312
313/// Internal ring-buffer state protected by a `Mutex` so `query(&self)` can
314/// write to it without a mutable borrow.
315struct SlowQueryLog {
316 entries: std::collections::VecDeque<SlowQueryEntry>,
317 total: u64,
318}
319
320/// Maximum number of entries kept in the slow-query ring buffer.
321const SLOW_QUERY_RING_CAP: usize = 16;
322
323/// Wire summary of a [`Predicate`]. JSON only — `Explanation` is never
324/// bincode-persisted (WAL/snapshots store `RuleDef` bytes, not this type).
325#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
326pub struct PredicateSummary {
327 pub kind: String,
328 pub fields: Vec<String>,
329 pub min: Option<f64>,
330 pub tolerance: Option<f64>,
331 pub km: Option<f64>,
332 pub parts: Option<Vec<PredicateSummary>>,
333 /// True when the owning rule has `approximate=true` (IVF-Flat candidate path).
334 /// Always false for predicates reported without rule context (sub-predicates in `parts`).
335 #[serde(default)]
336 pub approximate: bool,
337}
338
339impl From<&Predicate> for PredicateSummary {
340 fn from(p: &Predicate) -> Self {
341 match p {
342 Predicate::KeyMatch { field } => PredicateSummary {
343 kind: "key_match".into(),
344 fields: vec![field.clone()],
345 min: None,
346 tolerance: None,
347 km: None,
348 parts: None,
349 approximate: false,
350 },
351 Predicate::FieldEqual { field } => PredicateSummary {
352 kind: "field_equal".into(),
353 fields: vec![field.clone()],
354 min: None,
355 tolerance: None,
356 km: None,
357 parts: None,
358 approximate: false,
359 },
360 Predicate::Overlap { field, min } => PredicateSummary {
361 kind: "overlap".into(),
362 fields: vec![field.clone()],
363 min: Some(*min),
364 tolerance: None,
365 km: None,
366 parts: None,
367 approximate: false,
368 },
369 Predicate::NumericWithin { field, tolerance } => PredicateSummary {
370 kind: "numeric_within".into(),
371 fields: vec![field.clone()],
372 min: None,
373 tolerance: Some(*tolerance),
374 km: None,
375 parts: None,
376 approximate: false,
377 },
378 Predicate::GeoRadius { field, km } => PredicateSummary {
379 kind: "geo_radius".into(),
380 fields: vec![field.clone()],
381 min: None,
382 tolerance: None,
383 km: Some(*km),
384 parts: None,
385 approximate: false,
386 },
387 Predicate::VectorSimilar { field, min } => PredicateSummary {
388 kind: "vector_similar".into(),
389 fields: vec![field.clone()],
390 min: Some(*min),
391 tolerance: None,
392 km: None,
393 parts: None,
394 approximate: false,
395 },
396 Predicate::All(inner) => {
397 let parts: Vec<PredicateSummary> = inner.iter().map(Self::from).collect();
398 let mut fields = Vec::new();
399 for part in &parts {
400 for f in &part.fields {
401 if !fields.contains(f) {
402 fields.push(f.clone());
403 }
404 }
405 }
406 PredicateSummary {
407 kind: "all".into(),
408 fields,
409 min: None,
410 tolerance: None,
411 km: None,
412 parts: Some(parts),
413 approximate: false,
414 }
415 }
416 Predicate::Any(inner) => {
417 let parts: Vec<PredicateSummary> = inner.iter().map(Self::from).collect();
418 let mut fields = Vec::new();
419 for part in &parts {
420 for f in &part.fields {
421 if !fields.contains(f) {
422 fields.push(f.clone());
423 }
424 }
425 }
426 PredicateSummary {
427 kind: "any".into(),
428 fields,
429 min: None,
430 tolerance: None,
431 km: None,
432 parts: Some(parts),
433 approximate: false,
434 }
435 }
436 }
437 }
438}
439
440/// Snapshot of a live node's key, label, and columnar properties.
441///
442/// `props` is a [`BTreeMap`] so field order is deterministic (sorted by name)
443/// regardless of insert order or the columnar store's `HashMap` iteration.
444///
445/// Deliberately does not derive `Serialize`: `Value`'s serde form is
446/// internally tagged. Wire JSON is built by `value_to_json` in the server.
447#[derive(Debug, Clone, PartialEq)]
448pub struct NodeInfo {
449 pub key: String,
450 pub label: String,
451 pub props: BTreeMap<String, Value>,
452}
453
454/// Counts returned by [`GraphDb::delete_node`].
455#[derive(Debug, Clone, PartialEq, Eq, Default)]
456pub struct DeleteReport {
457 /// Number of manual (user-inserted) edges removed.
458 pub manual_edges: u64,
459 /// Number of derived (rule-owned) edges retracted.
460 pub derived_edges: u64,
461}
462
463/// One directed edge incident on a node, with provenance membership.
464///
465/// `derived` is true iff `(edge_type, src, dst)` is in the rule engine's
466/// Plan-8 `by_node` provenance index.
467#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
468pub struct EdgeInfo {
469 pub edge_type: String,
470 pub src_key: String,
471 pub dst_key: String,
472 pub derived: bool,
473}
474
475/// An edge with mask-aware endpoint visibility.
476///
477/// Returned by [`GraphDb::node_edges_masked`] in [`crate::mask::MaskMode::Stub`]
478/// mode — hidden endpoints carry `*_restricted: true`.
479#[derive(Debug, Clone, PartialEq, Eq)]
480pub struct MaskedEdge {
481 pub edge_type: String,
482 pub src_key: String,
483 /// `true` when `src_key` is in the DB but hidden from the mask.
484 pub src_restricted: bool,
485 pub dst_key: String,
486 /// `true` when `dst_key` is in the DB but hidden from the mask.
487 pub dst_restricted: bool,
488 pub derived: bool,
489}
490
491/// Result of a mask-aware node lookup via [`GraphDb::node_info_masked`].
492///
493/// `None` from that method means the key does not exist (→ 404).
494/// `Some(Restricted)` is only produced when `mask.mode() == MaskMode::Stub`.
495#[derive(Debug, PartialEq)]
496pub enum MaskedNodeResult {
497 Visible(NodeInfo),
498 /// Node exists in the DB but is hidden from this mask.
499 Restricted,
500}
501
502/// One rule-owned edge between two nodes, with the rule name, edge type,
503/// direction (src_key → dst_key), and weight if the rule stores one.
504#[derive(Debug, Clone, PartialEq, Serialize)]
505pub struct Explanation {
506 pub rule: String,
507 pub edge_type: String,
508 pub src_key: String,
509 pub dst_key: String,
510 pub weight: Option<f64>,
511 pub predicate: PredicateSummary,
512}
513
514/// Report returned by [`GraphDb::backup_to`].
515#[derive(Debug, Clone)]
516pub struct BackupReport {
517 /// Filenames copied into the destination directory (sorted ascending).
518 pub files: Vec<String>,
519 /// Total bytes written across all copied files.
520 pub bytes: u64,
521 /// `true` when the destination opened cleanly and passed post-copy checks.
522 ///
523 /// For stores that have a `snapshot.bin` this means: all V8 section CRCs
524 /// matched **and** the destination opened without error.
525 ///
526 /// For WAL-only stores (no `snapshot.bin`) there is no snapshot to
527 /// CRC-check; `verified` is `true` when the destination opened and
528 /// replayed the WAL without error (record-level checksums in the WAL
529 /// provide the integrity signal, not section CRCs).
530 pub verified: bool,
531}
532
533/// One directed edge in export form, with optional rule attribution for derived edges.
534///
535/// Returned by [`GraphDb::all_edges_for_export`].
536#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
537pub struct ExportEdge {
538 pub edge_type: String,
539 pub src: String,
540 pub dst: String,
541 pub derived: bool,
542 /// Rule name that created this edge, if derived. `None` for manual edges.
543 pub rule: Option<String>,
544}
545
546/// Construct the standard write-query result set (columns: created, properties_set, deleted).
547fn write_result_set() -> ResultSet {
548 ResultSet::new(vec![
549 "created".into(),
550 "properties_set".into(),
551 "deleted".into(),
552 ])
553}
554
555fn resolve_merge_set_value(op: &Operand, params: &BTreeMap<String, Value>) -> Result<Value> {
556 match op {
557 Operand::Lit(v) => Ok(v.clone()),
558 Operand::Param(name) => params
559 .get(name)
560 .cloned()
561 .ok_or_else(|| GraphError::QueryError {
562 detail: format!("missing parameter `{name}`"),
563 }),
564 _ => Err(GraphError::QueryError {
565 detail: "ON CREATE/ON MATCH SET value must be a literal or $parameter".into(),
566 }),
567 }
568}
569
570fn operand_node_vars(op: &Operand, out: &mut Vec<String>) {
571 match op {
572 Operand::Prop { var, .. } | Operand::Var(var) => {
573 if !out.contains(var) {
574 out.push(var.clone());
575 }
576 }
577 Operand::FuncCall { args, .. } => {
578 for arg in args {
579 operand_node_vars(arg, out);
580 }
581 }
582 Operand::BinArith { left, right, .. } => {
583 operand_node_vars(left, out);
584 operand_node_vars(right, out);
585 }
586 Operand::Case { branches, default } => {
587 // Branch conditions reference vars already bound (and mask-filtered)
588 // by the MATCH phase, so collecting from the value operands + ELSE
589 // is sufficient for RETURN-projection var discovery.
590 for (_, value) in branches {
591 operand_node_vars(value, out);
592 }
593 if let Some(d) = default {
594 operand_node_vars(d, out);
595 }
596 }
597 Operand::Lit(_) | Operand::Param(_) => {}
598 }
599}
600
601fn ret_node_vars(items: &[RetItem]) -> Vec<String> {
602 let mut out = Vec::new();
603 for item in items {
604 match &item.value {
605 RetVal::Var(v) | RetVal::Prop { var: v, .. } => {
606 if !out.contains(v) {
607 out.push(v.clone());
608 }
609 }
610 RetVal::FuncCall { args, .. } => {
611 for arg in args {
612 operand_node_vars(arg, &mut out);
613 }
614 }
615 RetVal::ScalarExpr(op) => operand_node_vars(op, &mut out),
616 RetVal::Agg { .. } => {}
617 }
618 }
619 out
620}
621
622fn add_var(out: &mut Vec<String>, v: &str) {
623 if !out.iter().any(|x| x == v) {
624 out.push(v.to_string());
625 }
626}
627
628fn pattern_node_vars(pats: &[Pattern]) -> Vec<String> {
629 let mut out = Vec::new();
630 for p in pats {
631 if let Some(v) = &p.start.var {
632 add_var(&mut out, v);
633 }
634 for (_, dest) in &p.chain {
635 if let Some(v) = &dest.var {
636 add_var(&mut out, v);
637 }
638 }
639 }
640 out
641}
642
643fn pattern_rel_vars(pats: &[Pattern]) -> Vec<String> {
644 let mut out = Vec::new();
645 for p in pats {
646 for (rel, _) in &p.chain {
647 if rel.hops.is_none() {
648 if let Some(v) = &rel.var {
649 add_var(&mut out, v);
650 }
651 }
652 }
653 }
654 out
655}
656
657fn rel_type_alias(var: &str) -> String {
658 format!("__rt_{var}")
659}
660
661fn ret_column_name(item: &RetItem) -> String {
662 if let Some(alias) = &item.alias {
663 return alias.clone();
664 }
665 match &item.value {
666 RetVal::Var(v) => v.clone(),
667 RetVal::Prop { var, field } => format!("{var}.{field}"),
668 RetVal::FuncCall { name, args } => {
669 let arg_strs: Vec<String> = args
670 .iter()
671 .map(|a| match a {
672 Operand::Var(v) => v.clone(),
673 Operand::Prop { var, field } => format!("{var}.{field}"),
674 Operand::Lit(_) => "<lit>".to_string(),
675 Operand::Param(p) => format!("${p}"),
676 Operand::FuncCall { name: n, .. } => format!("{n}(...)"),
677 Operand::BinArith { .. } => "<arith>".to_string(),
678 Operand::Case { .. } => "<case>".to_string(),
679 })
680 .collect();
681 format!("{name}({})", arg_strs.join(", "))
682 }
683 RetVal::ScalarExpr(_) => "<expr>".to_string(),
684 RetVal::Agg { .. } => "<agg>".to_string(),
685 }
686}
687
688fn eval_set_return_operand<F: Fs>(
689 db: &GraphDb<F>,
690 match_rs: &ResultSet,
691 row: usize,
692 rel_vars: &[String],
693 op: &Operand,
694 params: &BTreeMap<String, Value>,
695) -> Result<Option<Value>> {
696 match op {
697 Operand::Lit(v) => Ok(Some(v.clone())),
698 Operand::Param(name) => params.get(name).cloned().ok_or_else(|| GraphError::QueryError {
699 detail: format!("missing parameter `{name}`"),
700 }).map(Some),
701 Operand::Var(name) if rel_vars.iter().any(|r| r == name) => Err(GraphError::QueryError {
702 detail: format!(
703 "cannot return relationship variable '{name}' bare; return its properties ({name}.field) instead"
704 ),
705 }),
706 Operand::Var(name) => Ok(match_rs.get(row, name).cloned()),
707 Operand::Prop { var, field } => {
708 if rel_vars.iter().any(|r| r == var) {
709 return Ok(None);
710 }
711 let Some(Value::Str(key)) = match_rs.get(row, var) else {
712 return Ok(None);
713 };
714 Ok(db.get_prop(key, field))
715 }
716 Operand::FuncCall { name, args } => {
717 eval_set_return_func(db, match_rs, row, rel_vars, name, args, params)
718 }
719 Operand::BinArith { op, left, right } => {
720 let lv = eval_set_return_operand(db, match_rs, row, rel_vars, left, params)?;
721 let rv = eval_set_return_operand(db, match_rs, row, rel_vars, right, params)?;
722 eval_set_return_arith(op, lv, rv)
723 }
724 // CASE is supported in read-query RETURN; in a write-statement RETURN
725 // projection (CREATE/MERGE/SET … RETURN) it is not yet wired.
726 Operand::Case { .. } => Err(GraphError::QueryError {
727 detail: "CASE is not supported in a write-statement RETURN projection; \
728 use a read query"
729 .into(),
730 }),
731 }
732}
733
734fn eval_set_return_arith(
735 op: &ArithOp,
736 lv: Option<Value>,
737 rv: Option<Value>,
738) -> Result<Option<Value>> {
739 match (lv, rv) {
740 (None, _) | (_, None) => Ok(None),
741 (Some(Value::Int(a)), Some(Value::Int(b))) => {
742 let result = match op {
743 ArithOp::Sub => a.saturating_sub(b),
744 ArithOp::Mul => a.saturating_mul(b),
745 ArithOp::Add => a.saturating_add(b),
746 ArithOp::Div => {
747 if b == 0 {
748 return Err(GraphError::QueryError {
749 detail: "division by zero".into(),
750 });
751 }
752 a.checked_div(b).unwrap_or(i64::MAX)
753 }
754 };
755 Ok(Some(Value::Int(result)))
756 }
757 (Some(lv), Some(rv)) => {
758 let a = match &lv {
759 Value::Float(f) => *f,
760 Value::Int(i) => *i as f64,
761 _ => {
762 return Err(GraphError::QueryError {
763 detail: format!("arithmetic operand must be numeric, got {lv:?}"),
764 })
765 }
766 };
767 let b = match &rv {
768 Value::Float(f) => *f,
769 Value::Int(i) => *i as f64,
770 _ => {
771 return Err(GraphError::QueryError {
772 detail: format!("arithmetic operand must be numeric, got {rv:?}"),
773 })
774 }
775 };
776 let result = match op {
777 ArithOp::Sub => a - b,
778 ArithOp::Mul => a * b,
779 ArithOp::Add => a + b,
780 ArithOp::Div => {
781 if b == 0.0 {
782 return Err(GraphError::QueryError {
783 detail: "division by zero".into(),
784 });
785 }
786 a / b
787 }
788 };
789 Ok(Some(Value::Float(result)))
790 }
791 }
792}
793
794fn eval_set_return_func<F: Fs>(
795 db: &GraphDb<F>,
796 match_rs: &ResultSet,
797 row: usize,
798 rel_vars: &[String],
799 name: &str,
800 args: &[Operand],
801 params: &BTreeMap<String, Value>,
802) -> Result<Option<Value>> {
803 let norm = name.to_ascii_lowercase();
804 if norm == "type" {
805 if args.len() != 1 {
806 return Err(GraphError::QueryError {
807 detail: format!("type() requires exactly 1 argument, got {}", args.len()),
808 });
809 }
810 let Operand::Var(rel) = &args[0] else {
811 return Err(GraphError::QueryError {
812 detail: "type() argument must be a relationship variable (e.g. type(r))".into(),
813 });
814 };
815 return Ok(match_rs.get(row, &rel_type_alias(rel)).cloned());
816 }
817 let mut vals = Vec::with_capacity(args.len());
818 for arg in args {
819 vals.push(eval_set_return_operand(
820 db, match_rs, row, rel_vars, arg, params,
821 )?);
822 }
823 match norm.as_str() {
824 "tolower" => {
825 if vals.len() != 1 {
826 return Err(GraphError::QueryError {
827 detail: format!("toLower() requires exactly 1 argument, got {}", vals.len()),
828 });
829 }
830 Ok(vals[0].clone().map(|val| match val {
831 Value::Str(s) => Value::Str(s.to_ascii_lowercase()),
832 other => other,
833 }))
834 }
835 "toupper" => {
836 if vals.len() != 1 {
837 return Err(GraphError::QueryError {
838 detail: format!("toUpper() requires exactly 1 argument, got {}", vals.len()),
839 });
840 }
841 Ok(vals[0].clone().map(|val| match val {
842 Value::Str(s) => Value::Str(s.to_ascii_uppercase()),
843 other => other,
844 }))
845 }
846 "size" => match vals.first().cloned().flatten() {
847 None => Ok(None),
848 Some(Value::Str(s)) => Ok(Some(Value::Int(s.len() as i64))),
849 Some(Value::List(items)) => Ok(Some(Value::Int(items.len() as i64))),
850 Some(_) => Ok(None),
851 },
852 "coalesce" => Ok(vals.into_iter().flatten().next()),
853 "abs" => match vals.first().cloned().flatten() {
854 None => Ok(None),
855 Some(Value::Int(n)) => Ok(Some(Value::Int(n.saturating_abs()))),
856 Some(Value::Float(f)) => Ok(Some(Value::Float(f.abs()))),
857 Some(_) => Ok(None),
858 },
859 "round" => match vals.first().cloned().flatten() {
860 None => Ok(None),
861 Some(Value::Float(f)) => Ok(Some(Value::Float(f.round()))),
862 Some(Value::Int(n)) => Ok(Some(Value::Int(n))),
863 Some(_) => Ok(None),
864 },
865 _ => Err(GraphError::QueryError {
866 detail: format!(
867 "unknown function `{name}`; supported: toLower, toUpper, size, coalesce, type, abs, round, textMatches"
868 ),
869 }),
870 }
871}
872
873fn eval_set_return_item<F: Fs>(
874 db: &GraphDb<F>,
875 match_rs: &ResultSet,
876 row: usize,
877 rel_vars: &[String],
878 item: &RetItem,
879 params: &BTreeMap<String, Value>,
880) -> Result<Option<Value>> {
881 match &item.value {
882 RetVal::Var(v) => eval_set_return_operand(
883 db,
884 match_rs,
885 row,
886 rel_vars,
887 &Operand::Var(v.clone()),
888 params,
889 ),
890 RetVal::Prop { var, field } => eval_set_return_operand(
891 db,
892 match_rs,
893 row,
894 rel_vars,
895 &Operand::Prop {
896 var: var.clone(),
897 field: field.clone(),
898 },
899 params,
900 ),
901 RetVal::FuncCall { name, args } => {
902 eval_set_return_func(db, match_rs, row, rel_vars, name, args, params)
903 }
904 RetVal::ScalarExpr(op) => eval_set_return_operand(db, match_rs, row, rel_vars, op, params),
905 RetVal::Agg { .. } => Err(GraphError::QueryError {
906 detail: "aggregates are not supported in MATCH … SET … RETURN".into(),
907 }),
908 }
909}
910
911/// Project user RETURN from original MATCH rows after SET. No rematch.
912fn project_set_return_rows<F: Fs>(
913 db: &GraphDb<F>,
914 rel_vars: &[String],
915 match_rs: &ResultSet,
916 returns: &[RetItem],
917 params: &BTreeMap<String, Value>,
918) -> Result<ResultSet> {
919 let columns: Vec<String> = returns.iter().map(ret_column_name).collect();
920 let mut out = ResultSet::new(columns);
921 for row in 0..match_rs.len() {
922 let mut cells = Vec::with_capacity(returns.len());
923 for item in returns {
924 cells.push(eval_set_return_item(
925 db, match_rs, row, rel_vars, item, params,
926 )?);
927 }
928 out.push_row(cells);
929 }
930 Ok(out)
931}
932
933/// Single construction point for a `GraphMut` view over the split-borrowed graph fields.
934/// Callers use `std::mem::take` on the engine before calling this, then restore it after.
935/// Extract a `Vec<f64>` from a `Value::List` whose items are all numeric.
936/// Returns `None` for non-list values or lists with non-numeric elements.
937fn value_as_float_list(v: &Value) -> Option<Vec<f64>> {
938 match v {
939 Value::List(items) => items
940 .iter()
941 .map(|item| match item {
942 Value::Float(f) => Some(*f),
943 Value::Int(i) => Some(*i as f64),
944 _ => None,
945 })
946 .collect(),
947 _ => None,
948 }
949}
950
951fn make_graph_mut<'a>(
952 ids: &'a IdMap,
953 syms: &'a mut Interner,
954 labels: &'a [u32],
955 props: core_storage::v8::seam::ColumnsView<'a>,
956 topo: &'a mut Topology,
957 edge_props: &'a mut EdgeProps,
958) -> GraphMut<'a> {
959 GraphMut {
960 ids,
961 syms,
962 labels,
963 props,
964 topo,
965 edge_props,
966 }
967}
968
969/// Build a `ColumnsView` from the disjoint `props` overlay and optional V8 base.
970///
971/// Takes explicit field references rather than `&self` so the caller can hold
972/// simultaneous mutable borrows of other fields (e.g. `syms`, `topo`).
973fn build_props_view<'a>(
974 props: &'a ColumnStore,
975 base: &'a Option<std::sync::Arc<core_storage::v8::MappedBase>>,
976) -> core_storage::v8::seam::ColumnsView<'a> {
977 match base {
978 None => core_storage::v8::seam::ColumnsView::owned(props),
979 Some(b) => {
980 let archived = b
981 .columns()
982 .expect("base columns section bounds validated at open");
983 core_storage::v8::seam::ColumnsView::with_base(props, archived)
984 }
985 }
986}
987
988fn build_topo_view<'a>(
989 overlay: &'a Topology,
990 base: &'a Option<std::sync::Arc<core_storage::v8::MappedBase>>,
991) -> core_storage::v8::seam::TopologyView<'a> {
992 match base {
993 None => core_storage::v8::seam::TopologyView::owned(overlay),
994 Some(b) => {
995 let archived_csr = b
996 .topology()
997 .expect("base topology section bounds validated at open");
998 core_storage::v8::seam::TopologyView::with_base(overlay, archived_csr)
999 }
1000 }
1001}
1002
1003/// When [`GraphDb`] calls `Fs::sync` after a WAL append.
1004///
1005/// Default is [`Strict`](FsyncPolicy::Strict): every `log_then_apply_with`
1006/// fsyncs (single `insert_node` / `set_prop`). Ingest and `write_batch`
1007/// emit one `WalRecord::Batch` and fsync once at that frame (Batched).
1008/// [`Relaxed`](FsyncPolicy::Relaxed) skips WAL sync; [`GraphDb::snapshot`]
1009/// is still durable via `write_atomic`. Crash-recovery DST stays Strict.
1010#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
1011pub enum FsyncPolicy {
1012 /// Every WAL commit calls `fs.sync` (today's behavior).
1013 #[default]
1014 Strict,
1015 /// Sync only at a `Batch` frame end. Single-op path stays Strict unless
1016 /// this policy is set on the database.
1017 Batched,
1018 /// Never call `fs.sync`. [`GraphDb::snapshot`] still syncs via `write_atomic`.
1019 Relaxed,
1020}
1021
1022/// A precondition for a compare-and-set batch write.
1023///
1024/// All preconditions in a [`GraphDb::write_batch_cas`] or
1025/// [`crate::SharedDb::submit_batch_cas`] call are checked atomically before
1026/// any operation in the batch is applied. If any precondition fails, the
1027/// entire batch is rejected with [`GraphError::CasConflict`] and no WAL frame
1028/// is written.
1029///
1030/// # Touch definition
1031///
1032/// A node's last-change commit (`last_changed`) is updated when any of the
1033/// following state-changing WAL records touch it:
1034///
1035/// - `InsertNode` / `InsertNodeId` — the newly-inserted node.
1036/// - `SetProp` / `SetPropId` / `RemoveProp` — the property-bearing node.
1037/// - `InsertEdge` / `InsertEdgeId` / `DeleteEdge` — **both** src and dst
1038/// endpoints (an edge change touches both sides).
1039/// - `DeleteNode` — the node is tombstoned; `last_changed` returns `None`
1040/// for deleted keys so the pre-deletion entry is never observed.
1041///
1042/// History markers (`DerivedEdgeAdded` / `DerivedEdgeRetracted`) are
1043/// state no-ops. The underlying mutation that triggered rule firing already
1044/// updated the relevant nodes' last-change entries. Rule-management records
1045/// (`CreateRule`, `DeleteRule`, `RebuildRule`) and view/full-text declarations
1046/// do not touch any node's last-change.
1047#[derive(Debug, Clone, PartialEq, Eq)]
1048pub enum Precondition {
1049 /// The node's last-change commit must equal `expected`.
1050 ///
1051 /// Fails with [`GraphError::CasConflict`] when:
1052 /// - The node does not exist (`last_changed` returns `None`), or
1053 /// - The recorded commit seq does not match `expected`.
1054 NodeUnchangedSince { key: String, expected: u64 },
1055 /// The node must not exist (not inserted, or already deleted).
1056 ///
1057 /// Fails with [`GraphError::CasConflict`] (expected=`u64::MAX`,
1058 /// actual=`last_changed(key).unwrap_or(0)`) when the node is live.
1059 NodeAbsent { key: String },
1060}
1061
1062pub struct GraphDb<F: Fs> {
1063 fs: F,
1064 ids: IdMap,
1065 syms: Interner,
1066 topo: Topology,
1067 props: ColumnStore,
1068 labels: Vec<u32>, // node id -> label symbol
1069 edge_props: EdgeProps,
1070 engine: RuleEngine,
1071 view_store: ViewStore,
1072 /// Incremental inverted index for full-text-lite search.
1073 /// Rebuild-on-open: populated from WAL replay + rebuild_all at open end.
1074 fulltext: FulltextIndex,
1075 /// Opt-in equality index over scalar node properties.
1076 /// Rebuild-on-open: declarations replay from the WAL, postings rebuild at
1077 /// open end (mirrors `fulltext`).
1078 prop_index: PropertyIndex,
1079 event_sink: Option<Box<dyn Fn(MutationEvent) + Send + Sync>>,
1080 /// WAL fsync cadence. Default [`FsyncPolicy::Strict`].
1081 fsync: FsyncPolicy,
1082 /// Monotonically increasing per-commit counter. A single `log_then_apply_with`
1083 /// call increments this once; all events emitted from that call share the same
1084 /// `commit_seq` value.
1085 commit_seq: u64,
1086 /// RBAC role definitions loaded from `roles.json` at open.
1087 ///
1088 /// `Some(roles)` — loaded successfully (may be empty when no roles are defined).
1089 /// `None` — `roles.json` was present but corrupt; `mask_for_role` returns
1090 /// `Err` for any request (fail-loud, never silently grant empty visibility).
1091 roles: Option<Vec<RoleDef>>,
1092 /// Live subscriptions. Entries with a dead `Weak` are pruned on the next
1093 /// distribute_events call.
1094 subscriptions: Vec<SubEntry>,
1095 /// Live query subscriptions. Re-executed on every commit when non-empty.
1096 /// Dead `Weak` entries are pruned inside `distribute_events`.
1097 query_subscriptions: Vec<QuerySubEntry>,
1098 /// Queue capacity for new subscriptions created by this db. Default is
1099 /// [`DEFAULT_SUB_CAPACITY`]; can be overridden via [`set_sub_capacity`]
1100 /// to test Lagged behaviour with small queues.
1101 sub_capacity: usize,
1102 /// True for as-of instances opened via [`GraphDb::open_at`].
1103 /// Every mutation method and `snapshot()` returns [`GraphError::ReadOnly`]
1104 /// when this flag is set.
1105 read_only: bool,
1106 /// Total WAL commit count at the time [`open_at`] was called.
1107 /// 0 for normal (non-as-of) instances.
1108 total_wal_commits: u64,
1109 /// Immutable mmap-backed base snapshot (V8). When `Some`, `self.topo` is
1110 /// the WAL-replay overlay (empty at open time, populated by apply()) and
1111 /// reads go through a merged `TopologyView`. `self.props` is always
1112 /// fully materialized (base + WAL replay) for HNSW/IVF and view compat.
1113 base: Option<Arc<core_storage::v8::MappedBase>>,
1114 // ── MVCC epoch reader state ───────────────────────────────────────────────
1115 /// Most-recent full overlay clone. Initialized at end of `open_with` /
1116 /// `open_at_with`; refreshed every `FOLD_EVERY_K` commits.
1117 /// `None` only between struct creation and the first fold.
1118 fold_overlay: Option<Arc<crate::reader::FrozenOverlay>>,
1119 /// Per-commit deltas accumulated since the last fold.
1120 delta_tail: Vec<Arc<crate::reader::CommitDelta>>,
1121 /// How many commits have occurred since the last fold.
1122 commits_since_fold: usize,
1123 /// When true, `log_then_apply_with` buffers event notifications instead of
1124 /// firing them immediately. Used by the group-commit drain thread to defer
1125 /// events until after the group fsync (R2: durability before notification).
1126 /// Cleared to false once the drain thread flushes or discards the buffer.
1127 defer_events: bool,
1128 /// Buffered events accumulated while `defer_events` is true.
1129 deferred_events: Vec<DeferredEvent>,
1130 /// Set to true by the group-commit drain thread when a group fsync fails
1131 /// after WAL truncation. All subsequent mutation attempts return an IO
1132 /// error until the database is reopened.
1133 degraded: bool,
1134 /// Set to `true` after `ensure_v8_base_sections_loaded` has read provenance,
1135 /// HNSW, and IVF sections from the mmap base into the engine's retained
1136 /// fields. `false` on all opens until first use; always `true` for non-V8
1137 /// opens (base is None, fast-path sets flag immediately).
1138 v8_sections_loaded: std::sync::atomic::AtomicBool,
1139 /// Serializes the one-time section population in `ensure_v8_base_sections_loaded`.
1140 v8_sections_mutex: std::sync::Mutex<()>,
1141 /// Per-node last-change commit sequence. `last_change[node_id] = seq` means
1142 /// the node was last modified by commit `seq`.
1143 ///
1144 /// Loaded from V8 section 11 at open; updated on every state-changing commit
1145 /// and WAL replay frame. V5-V7 stores start with an empty map; pre-WAL-horizon
1146 /// nodes return `None` from `last_changed` until they are next mutated.
1147 ///
1148 /// See [`Precondition`] for the full touch definition.
1149 last_change: HashMap<u32, u64>,
1150 /// WAL archive retention policy set by [`set_wal_archive_retention`].
1151 /// `None` = unlimited (keep all archives); `Some(N)` = keep N newest archives,
1152 /// pruning older ones at snapshot time. 0 is treated as unlimited.
1153 wal_archive_retention: Option<u32>,
1154 /// Global frame index of the first commit that is still reachable through
1155 /// surviving archives. Persisted to `wal.floor` sidecar when pruning occurs.
1156 /// Default 0 = all history reachable.
1157 wal_horizon_floor: u64,
1158 /// True when the surviving archive chain forms a continuous WAL history
1159 /// starting from the store's first commit (the genesis chain).
1160 ///
1161 /// `open_at` may replay archive-resident commits from empty state only when
1162 /// this flag is true AND `wal_horizon_floor == 0`. Cleared whenever:
1163 /// - a WAL-truncating snapshot (`keep_wal=false`) is taken after archives
1164 /// already exist (breaks the chain for subsequent archives), or
1165 /// - any archive is pruned (floor advances past zero).
1166 ///
1167 /// Persisted via the `wal.genesis` marker file; loaded from it at open.
1168 archive_genesis_chain: bool,
1169 /// Transient write-authz context set by `write_batch_authz` /
1170 /// `query_write_authz` for the duration of ONE mutation call.
1171 /// Always `None` at rest. Never serialized, never WAL-replayed.
1172 pending_write_authz: Option<WriteAuthz>,
1173 /// Slow-query threshold in milliseconds. 0 = disabled.
1174 /// Seeded from `MUSHROOMDB_SLOW_QUERY_MS` at open; override via
1175 /// [`GraphDb::set_slow_query_threshold_ms`] (tests must use the setter
1176 /// — env vars are process-global and race parallel test threads).
1177 slow_query_threshold_ms: u64,
1178 /// Ring buffer of recent slow queries (interior-mutable so `query(&self)`
1179 /// can record entries without requiring `&mut self`).
1180 slow_queries: std::sync::Mutex<SlowQueryLog>,
1181 /// Instant at which the database was opened (used by `/metrics` uptime).
1182 started_at: std::time::Instant,
1183}
1184
1185/// One group of deferred event notifications, held until the group fsync
1186/// completes. Replayed by [`GraphDb::flush_deferred_events`].
1187struct DeferredEvent {
1188 rec: core_storage::WalRecord,
1189 engine_deltas: Vec<EngineEdgeDelta>,
1190 seq: u64,
1191 ingest: Option<(String, usize)>,
1192}
1193
1194/// Options for [`GraphDb::open_with_options`].
1195#[derive(Clone, Copy, Debug)]
1196pub struct OpenOptions {
1197 /// Rewrite an old-format snapshot to the current VERSION after a
1198 /// successful load (default `true`). The old snapshot is kept as
1199 /// `snapshot.bin.bak` until the next clean open at the current version,
1200 /// at which point the `.bak` is deleted.
1201 ///
1202 /// Set to `false` to open a store without touching any on-disk files
1203 /// (useful for read-only inspection of a store at an older format).
1204 pub auto_migrate: bool,
1205}
1206
1207impl Default for OpenOptions {
1208 fn default() -> Self {
1209 Self { auto_migrate: true }
1210 }
1211}
1212
1213/// Authorization context carried by `write_batch_authz` / `query_write_authz`.
1214///
1215/// `None` at the call site = full authority (today's zero-cost behavior).
1216/// `Some(WriteAuthz)` = role-scoped: the decision table (plan §"authz decision
1217/// table") is evaluated per-op inside `commit_logged_batch` BEFORE any WAL
1218/// record is built. A denial returns an error with no WAL frame written.
1219///
1220/// The mask is ALWAYS `Omit`-mode: role-token paths must never acknowledge
1221/// hidden-node existence to callers.
1222#[derive(Clone, Debug)]
1223pub struct WriteAuthz {
1224 pub role: String,
1225 pub scope: WriteScope,
1226 /// Resolved by `mask_for_role` under the same write guard as the mutation.
1227 /// Always `Omit`-mode — never `Stub`.
1228 pub mask: crate::mask::NodeMask,
1229}
1230
1231/// Write `bytes` to `snapshot.bin.bak` atomically with full fsync.
1232///
1233/// Uses [`RealFs::write_atomic`] which applies `F_FULLFSYNC` on macOS and
1234/// `sync_all` on other platforms, then renames the `.tmp` file into place and
1235/// syncs the directory entry. This is the only correct path for writing the
1236/// `.bak` — plain `std::fs::write + sync_all` misses both `F_FULLFSYNC` and
1237/// the directory sync.
1238pub fn write_snapshot_bak(dir: &std::path::Path, bytes: &[u8]) -> crate::Result<()> {
1239 use core_storage::fs::{FileId, Fs as _};
1240 RealFs::new(dir)
1241 .map_err(core_storage::GraphError::Io)?
1242 .write_atomic(FileId::SnapshotBak, bytes)
1243 .map_err(core_storage::GraphError::Io)
1244}
1245
1246/// Return the on-disk snapshot format version without decoding the full snapshot.
1247///
1248/// Reads only the 6-byte header (magic + version LE). Returns `None` when no
1249/// snapshot file exists (WAL-only store). Returns an error if the header is
1250/// malformed.
1251pub fn snapshot_version_at(dir: &std::path::Path) -> crate::Result<Option<u16>> {
1252 use std::io::Read as _;
1253 let path = dir.join("snapshot.bin");
1254 let mut header = [0u8; 6];
1255 let n = match std::fs::File::open(&path) {
1256 Ok(mut f) => f.read(&mut header).map_err(core_storage::GraphError::Io)?,
1257 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1258 Err(e) => return Err(core_storage::GraphError::Io(e)),
1259 };
1260 core_storage::snapshot::peek_version(&header[..n])
1261}
1262
1263/// Options for [`GraphDb::snapshot_with`].
1264#[derive(Debug, Clone, Default)]
1265pub struct SnapshotOptions {
1266 /// When `true`, the WAL is preserved after the snapshot write.
1267 /// Pre-snapshot commits remain reachable via [`GraphDb::open_at`].
1268 /// When `false` (the default), the WAL is truncated to a minimal
1269 /// baseline so cold-start replay stays fast.
1270 pub keep_wal: bool,
1271 /// When `true`, the current WAL is renamed to `wal.<commit_seq>.archive`
1272 /// before a fresh WAL baseline is written (history-preserving snapshot).
1273 ///
1274 /// This is the feature opt-in: `false` (the default) leaves the existing
1275 /// truncation / keep-wal behaviour byte-identical. `archive_wal` takes
1276 /// precedence over `keep_wal` when both are set.
1277 ///
1278 /// Archives can be scanned by [`GraphDb::node_history`],
1279 /// [`GraphDb::edge_history`], [`GraphDb::was_linked`], and
1280 /// [`GraphDb::open_at`], extending the reachable history horizon across
1281 /// snapshot boundaries.
1282 pub archive_wal: bool,
1283}
1284
1285/// Derive the scan-label sym for the commit-skip fast-path.
1286///
1287/// Walks `ops` to find the plan's leading scan op (`ScanLabel`, `IndexScan`,
1288/// or `IndexIntersect`) with a concrete label string, then interns it.
1289///
1290/// Returns `None` in all cases where skipping is unsafe:
1291/// - Any `Expand` op is present (edge traversal; edges change results regardless
1292/// of node labels).
1293/// - The leading scan has no label (`ScanLabel { label: None }` — full scan).
1294/// - No recognizable leading scan op is found.
1295///
1296/// This is the conservative v0.4.3 boundary. The caller stores the result in
1297/// [`QuerySubEntry::scan_label`] at subscribe time; `None` means always execute.
1298fn extract_scan_label(ops: &[PlanOp], syms: &mut Interner) -> Option<u32> {
1299 // Any Expand → must always re-execute (edges can change join results).
1300 if ops.iter().any(|op| matches!(op, PlanOp::Expand { .. })) {
1301 return None;
1302 }
1303 for op in ops {
1304 match op {
1305 PlanOp::ScanLabel {
1306 label: Some(label), ..
1307 } => return Some(syms.intern(label)),
1308 PlanOp::IndexScan {
1309 label: Some(label), ..
1310 } => return Some(syms.intern(label)),
1311 PlanOp::IndexIntersect {
1312 label: Some(label), ..
1313 } => return Some(syms.intern(label)),
1314 _ => {}
1315 }
1316 }
1317 None
1318}
1319
1320impl GraphDb<RealFs> {
1321 /// Open the database at `dir` with default options.
1322 ///
1323 /// Equivalent to `open_with_options(dir, OpenOptions::default())`.
1324 /// Old-format snapshots (V5, V6) are automatically migrated to the
1325 /// current version on a successful load (see [`OpenOptions::auto_migrate`]).
1326 pub fn open(dir: &std::path::Path) -> Result<Self> {
1327 Self::open_with_options(dir, OpenOptions::default())
1328 }
1329
1330 /// Open the database at `dir` with explicit options.
1331 ///
1332 /// When `opts.auto_migrate` is `true` (the default) and the on-disk
1333 /// snapshot is an older format version, this function:
1334 /// 1. Copies the current `snapshot.bin` to `snapshot.bin.bak` (atomic
1335 /// + fsynced) before any modification.
1336 /// 2. Rewrites `snapshot.bin` at the current format version via
1337 /// [`GraphDb::snapshot_with`] with `keep_wal: true` (WAL preserved).
1338 ///
1339 /// If migration fails the error is returned and the original files are
1340 /// intact (the `.bak` was written before the new snapshot was attempted).
1341 ///
1342 /// A clean open that finds the snapshot already at the current version
1343 /// deletes any leftover `.bak` file.
1344 ///
1345 /// WAL-only stores (no snapshot) are never auto-migrated on open.
1346 pub fn open_with_options(dir: &std::path::Path, opts: OpenOptions) -> Result<Self> {
1347 // Header-only peek — 6 bytes, no full decode.
1348 let snap_version = snapshot_version_at(dir)?;
1349
1350 // Full load: decode snapshot + replay WAL + rebuild indexes.
1351 let mut db = Self::open_with(RealFs::new(dir)?)?;
1352
1353 if opts.auto_migrate {
1354 match snap_version {
1355 Some(ver) if ver < core_storage::snapshot::VERSION => {
1356 let _tm = std::time::Instant::now();
1357 // Copy the original snapshot to .bak at OS level — no in-memory
1358 // buffer required for a 2+ GiB file.
1359 //
1360 // Crash-safety: snapshot.bin remains intact (write_atomic inside
1361 // snapshot_with uses a .tmp+rename) until the V8 write succeeds.
1362 // A torn .bak on crash is acceptable because the original
1363 // snapshot.bin is the authoritative source until after the rename.
1364 std::fs::copy(dir.join("snapshot.bin"), dir.join("snapshot.bin.bak"))
1365 .map_err(core_storage::GraphError::Io)?;
1366 trace_migrate!("bak copy done", _tm);
1367 // Rewrite snapshot at current version; keep WAL intact.
1368 db.snapshot_with(SnapshotOptions {
1369 keep_wal: true,
1370 ..SnapshotOptions::default()
1371 })?;
1372 trace_migrate!("snapshot_with done", _tm);
1373 }
1374 Some(_) => {
1375 // Already current version: remove any leftover .bak.
1376 let bak = dir.join("snapshot.bin.bak");
1377 if bak.exists() {
1378 std::fs::remove_file(&bak).map_err(core_storage::GraphError::Io)?;
1379 }
1380 }
1381 None => {
1382 // WAL-only store — nothing to migrate on open.
1383 }
1384 }
1385 }
1386
1387 Ok(db)
1388 }
1389
1390 /// Open a read-only view of the database as it existed after `commit`.
1391 ///
1392 /// Commit indices are 0-based over the current WAL: commit 0 is the state
1393 /// after the first WAL frame, commit N-1 is the state after the N-th (most
1394 /// recent) frame. Call [`GraphDb::open`] to read the full current state.
1395 ///
1396 /// **Replay base.** [`GraphDb::snapshot`] truncates the WAL when it runs,
1397 /// so as-of can only reach commits recorded in the current WAL (those
1398 /// written after the most recent snapshot, or all commits if no snapshot
1399 /// was ever taken). Commit 0 in `open_at` always refers to the first
1400 /// frame in the WAL that exists on disk, not the first ever write to the
1401 /// database. When the on-disk snapshot recorded that it truncated the
1402 /// WAL (V7, default `keep_wal: false`), it is loaded as the base state
1403 /// before frame replay, so the as-of view includes all pre-snapshot data.
1404 /// Snapshots written with `keep_wal: true` (and legacy V5/V6 snapshots)
1405 /// are ignored and replay is WAL-only, as before.
1406 ///
1407 /// **Read-only.** Every mutation method and `snapshot()` on the returned
1408 /// instance returns [`GraphError::ReadOnly`]. Queries, `explain()`, and
1409 /// `stats()` work normally.
1410 ///
1411 /// # Errors
1412 /// - [`GraphError::CommitOutOfRange`] if `commit >= wal_commit_count` (including
1413 /// when the WAL is empty after a snapshot).
1414 pub fn open_at(dir: &std::path::Path, commit: u64) -> Result<Self> {
1415 Self::open_at_with(RealFs::new(dir)?, commit)
1416 }
1417
1418 /// Run a **read-only** Cypher query against the graph as it existed at
1419 /// `commit` — the "time-travel" / agent-replay query. Opens a temporal view
1420 /// of this store's directory at that commit and executes the read there.
1421 ///
1422 /// The current instance is unaffected. Write statements are rejected (the
1423 /// temporal view is read-only). `commit` is a 0-based WAL commit index;
1424 /// `commit == wal_commit_count` (or `open_at`'s range) yields the newest
1425 /// state. Prefer this over holding many historical instances open.
1426 ///
1427 /// # Errors
1428 /// - [`GraphError::CommitOutOfRange`] if `commit` is past the WAL horizon.
1429 /// - A query error for a malformed or write query.
1430 pub fn query_at(
1431 &self,
1432 commit: u64,
1433 cypher: &str,
1434 params: &std::collections::BTreeMap<String, Value>,
1435 ) -> Result<ResultSet> {
1436 let dir = self.fs.dir().to_path_buf();
1437 let temporal = Self::open_at(&dir, commit)?;
1438 if is_write_tokens(&lex(cypher).map_err(|e| GraphError::QueryError {
1439 detail: format!("lex: {e}"),
1440 })?) {
1441 return Err(GraphError::QueryError {
1442 detail: "query_at is read-only: write statements are not permitted in a \
1443 time-travel query"
1444 .into(),
1445 });
1446 }
1447 temporal.query(cypher, params)
1448 }
1449}
1450
1451impl<F: Fs> GraphDb<F> {
1452 pub fn open_with(fs: F) -> Result<Self> {
1453 let mut db = Self {
1454 fs,
1455 ids: IdMap::new(),
1456 syms: Interner::new(),
1457 topo: Topology::new(),
1458 props: ColumnStore::new(),
1459 labels: Vec::new(),
1460 edge_props: EdgeProps::new(),
1461 engine: RuleEngine::new(),
1462 view_store: ViewStore::new(),
1463 fulltext: FulltextIndex::new(),
1464 prop_index: PropertyIndex::new(),
1465 event_sink: None,
1466 fsync: FsyncPolicy::Strict,
1467 commit_seq: 0,
1468 roles: Some(vec![]),
1469 subscriptions: Vec::new(),
1470 query_subscriptions: Vec::new(),
1471 sub_capacity: DEFAULT_SUB_CAPACITY,
1472 read_only: false,
1473 total_wal_commits: 0,
1474 base: None,
1475 fold_overlay: None,
1476 delta_tail: Vec::new(),
1477 commits_since_fold: 0,
1478 defer_events: false,
1479 deferred_events: Vec::new(),
1480 degraded: false,
1481 v8_sections_loaded: std::sync::atomic::AtomicBool::new(false),
1482 v8_sections_mutex: std::sync::Mutex::new(()),
1483 last_change: HashMap::new(),
1484 wal_archive_retention: None,
1485 wal_horizon_floor: 0,
1486 archive_genesis_chain: false,
1487 pending_write_authz: None,
1488 slow_query_threshold_ms: std::env::var("MUSHROOMDB_SLOW_QUERY_MS")
1489 .ok()
1490 .and_then(|v| v.parse().ok())
1491 .unwrap_or(100),
1492 slow_queries: std::sync::Mutex::new(SlowQueryLog {
1493 entries: std::collections::VecDeque::new(),
1494 total: 0,
1495 }),
1496 started_at: std::time::Instant::now(),
1497 };
1498 db.wal_horizon_floor = db.fs.read_horizon_floor()?;
1499 db.archive_genesis_chain = db.fs.has_genesis_marker();
1500 // Opening cleanup: remove orphaned archives — archives whose frames all
1501 // fall below the horizon floor. Orphans arise when a crash interrupted
1502 // the retention-prune sequence after the floor was written but before
1503 // all surplus archives were deleted. Safe to delete: floor already
1504 // accounts for their frames.
1505 db.cleanup_orphaned_archives()?;
1506 let _t0 = std::time::Instant::now();
1507 // Peek 6 bytes to determine snapshot version without reading the full
1508 // file. For RealFs this is a true partial read (O(1)); for SimFs the
1509 // default impl reads all bytes and truncates (still correct).
1510 let snap_header = db.fs.read_prefix(FileId::Snapshot, 6)?;
1511 let is_v8 = snap_header.len() >= 6
1512 && &snap_header[0..4] == b"GDB1"
1513 && u16::from_le_bytes([snap_header[4], snap_header[5]])
1514 == core_storage::snapshot::VERSION_8;
1515 if is_v8 {
1516 // V8: map the file zero-copy (RealFs) or read full bytes (SimFs).
1517 // No 2.4GB heap Vec is allocated on RealFs.
1518 let mapped = Arc::new(
1519 if let Some(snap_path) = db.fs.snapshot_path() {
1520 core_storage::v8::MappedBase::map(&snap_path)
1521 } else {
1522 let snap_bytes = db.fs.read(FileId::Snapshot)?;
1523 core_storage::v8::MappedBase::from_bytes(snap_bytes)
1524 }
1525 .map_err(|e| GraphError::Corrupt {
1526 detail: format!("v8: mmap open: {e:?}"),
1527 })?,
1528 );
1529 db.restore_v8_base(Arc::clone(&mapped))?;
1530 trace_open!("restore_v8_base", _t0);
1531 db.base = Some(mapped);
1532 trace_open!("base assigned", _t0);
1533 } else if !snap_header.is_empty() {
1534 // Legacy V5-V7: full read required for decode.
1535 let snap_bytes = db.fs.read(FileId::Snapshot)?;
1536 if let Some(state) = core_storage::snapshot::decode(&snap_bytes)? {
1537 db.restore_snapshot_state(state)?;
1538 }
1539 }
1540 // else: snap_header is empty = no snapshot file, fresh store.
1541 //
1542 // Seed commit_seq from the highest seq persisted in last_change so that
1543 // WAL-replay frames (which start at commit_seq+1) always exceed any seq
1544 // already stored in the snapshot. Without this, a db with one snapshot
1545 // commit would save last_change["a"]=1, then on reopen the first WAL
1546 // frame would replay at seq=1 again — colliding and making WAL-tail
1547 // mutations indistinguishable from the snapshot baseline.
1548 //
1549 // Safety invariant (seq-recycling):
1550 // Recycled seqs (those below the seeded baseline) were NEVER stored in
1551 // last_change because they belonged to a previous db lifetime — a new
1552 // db starts at commit_seq=0 with an empty last_change. Therefore no
1553 // CAS precondition can carry a recycled seq as its `expected` value
1554 // and accidentally match a live node's last_change entry.
1555 //
1556 // `expected:0` on a deleted-then-reinserted node:
1557 // After deletion, last_changed() returns None; callers that call
1558 // last_changed() and then use NodeUnchangedSince get None.unwrap_or(0)
1559 // = 0. The reinserted node gets seq > 0, so a subsequent CAS with
1560 // expected=0 correctly conflicts. The only way to observe actual=0 in
1561 // a CasConflict would be a caller that invented expected=0 without ever
1562 // calling last_changed() — unreachable via the documented API contract.
1563 if let Some(&max_seq) = db.last_change.values().max() {
1564 db.commit_seq = db.commit_seq.max(max_seq);
1565 }
1566 let bytes = db.fs.read(FileId::Wal)?;
1567 let (records, valid_len) = decode_all(&bytes);
1568 if valid_len < bytes.len() {
1569 db.fs.write_atomic(FileId::Wal, &bytes[..valid_len])?;
1570 }
1571 // WAL-present path: build indexes eagerly BEFORE replay so that the
1572 // first replayed record does not trigger the lazy-init guard (which
1573 // would call reindex_all_load_ivf on an empty graph, defeating the
1574 // point of restoring IVF/HNSW blobs from the snapshot).
1575 if !records.is_empty() {
1576 db.ensure_v8_base_sections_loaded();
1577 trace_open!("lazy sections loaded (WAL path)", _t0);
1578 db.engine.consume_retained_state_eager(
1579 &db.ids,
1580 &db.syms,
1581 &db.labels,
1582 build_props_view(&db.props, &db.base),
1583 );
1584 }
1585 for rec in records {
1586 db.apply(&rec)?;
1587 // Drain per-frame to keep pending_deltas O(1) during replay (I-2).
1588 // No subscriber exists yet; discard is correct.
1589 let _ = db.engine.drain_deltas();
1590 // Track commit_seq during replay so last_change entries are
1591 // consistent with the seqs assigned by log_then_apply_with on
1592 // subsequent live commits. After N replayed frames, commit_seq=N;
1593 // live commits begin at N+1.
1594 db.commit_seq += 1;
1595 let replay_seq = db.commit_seq;
1596 db.update_last_change_from_rec(&rec, replay_seq);
1597 }
1598 // Enforce I-2: if the per-frame drain above is ever removed or skipped,
1599 // this assert catches the regression in debug builds immediately.
1600 debug_assert_eq!(
1601 db.engine.pending_delta_count(),
1602 0,
1603 "pending_deltas non-empty after replay — \
1604 per-frame drain must run inside the loop to keep memory O(1)"
1605 );
1606 // T2 note: the per-frame drain IS the suppression seam for replay.
1607 // Any future as-of replay path (Plan-15 T2) must drain here to feed
1608 // replaying subscribers; the mechanism is already in place.
1609 let _ = db.engine.drain_deltas(); // belt-and-braces no-op after loop drain
1610 trace_open!("wal replay done", _t0);
1611 // Rebuild view values after WAL replay only when there is no V8 base.
1612 // With a V8 base, view values are correct in the snapshot and are updated
1613 // incrementally during WAL replay (on_edge_changed / on_prop_changed).
1614 // A full rebuild would read overlay-only props (empty after restore_v8_base)
1615 // and overwrite correct base values with wrong results (e.g. NeighborAgg
1616 // Sum reads no "score" in overlay → writes 0.0, shadowing the correct
1617 // base value).
1618 if db.base.is_none() {
1619 let topo_view = TopologyView::owned(&db.topo);
1620 db.view_store
1621 .rebuild_all(&mut db.props, &topo_view, &db.ids, &db.syms, &db.labels);
1622 }
1623 // Rebuild full-text index after WAL replay. Corrects drift from
1624 // per-record incremental apply during replay.
1625 db.fulltext.rebuild_all(
1626 &db.ids,
1627 &db.labels,
1628 &db.syms,
1629 build_props_view(&db.props, &db.base),
1630 );
1631 db.prop_index.rebuild_all(
1632 &db.ids,
1633 &db.labels,
1634 &db.syms,
1635 build_props_view(&db.props, &db.base),
1636 );
1637 // Load roles sidecar. Missing file = no roles (Some(vec![])).
1638 // Corrupt/unparseable = poisoned (None); mask_for_role will fail-loud.
1639 db.roles = Self::load_roles_from_fs(&db.fs)?;
1640 // Capture the initial MVCC fold so reader() is ready immediately.
1641 db.fold_now();
1642 trace_open!("open_with complete", _t0);
1643 Ok(db)
1644 }
1645
1646 /// As-of replay for [`GraphDb::open_at`]: snapshot base (only when the
1647 /// snapshot truncated the WAL) plus the first `commit + 1` WAL frames;
1648 /// see [`GraphDb::open_at`] for the semantics. The per-frame drain
1649 /// mirrors `open_with` exactly so pending_delta_count is 0 on exit.
1650 /// Restore all persisted state from a decoded snapshot. Shared by
1651 /// `open_with` and (when the snapshot truncated the WAL) `open_at_with`.
1652 fn restore_snapshot_state(
1653 &mut self,
1654 state: core_storage::snapshot::SnapshotState,
1655 ) -> Result<()> {
1656 self.ids = state.ids;
1657 self.syms = state.syms;
1658 self.topo = state.topo;
1659 self.props = state.props;
1660 self.labels = state.labels;
1661 self.edge_props = state.edge_props;
1662 // Cross-section label integrity for V5/V7 snapshots: same invariants as
1663 // restore_v8_base. A crafted bincode snapshot with a short `labels` vec,
1664 // out-of-range sym ids, or a sentinel label on a live node would otherwise
1665 // open successfully and panic later in `NodeRef::label()` or
1666 // `neighborhood_masked()`. Catching it here turns those into typed
1667 // `GraphError::Corrupt` at open time.
1668 {
1669 let ids_len = self.ids.len();
1670 if self.labels.len() != ids_len {
1671 return Err(GraphError::Corrupt {
1672 detail: format!(
1673 "snapshot: labels vec has {} entries but id table has {} total slots",
1674 self.labels.len(),
1675 ids_len,
1676 ),
1677 });
1678 }
1679 let syms_len = self.syms.len() as u32;
1680 for (i, &sym) in self.labels.iter().enumerate() {
1681 let is_tombstoned = self.ids.is_tombstoned(i as u32);
1682 if sym == u32::MAX {
1683 if !is_tombstoned {
1684 return Err(GraphError::Corrupt {
1685 detail: format!(
1686 "snapshot: live node at id slot {i} has sentinel label (u32::MAX)"
1687 ),
1688 });
1689 }
1690 } else if sym >= syms_len {
1691 return Err(GraphError::Corrupt {
1692 detail: format!(
1693 "snapshot: label at id slot {i} references sym {sym} \
1694 which is out of interner range ({syms_len})"
1695 ),
1696 });
1697 }
1698 }
1699 }
1700 let defs: Vec<RuleDef> = state
1701 .rule_defs
1702 .iter()
1703 .map(|b| {
1704 decode_rule_def(b).map_err(|e| GraphError::Corrupt {
1705 detail: format!("snapshot rule_def deserialize: {e}"),
1706 })
1707 })
1708 .collect::<Result<Vec<_>>>()?;
1709 self.engine =
1710 RuleEngine::from_persist(defs, state.provenance, state.rule_tripped, state.rule_fires);
1711 // Candidate indexes are rebuilt lazily on the first mutation (see
1712 // RuleEngine::on_node_changed). HNSW blobs and IVF centroids from the
1713 // snapshot are retained without deserializing so that:
1714 // - clean-open (empty WAL): indexes stay empty; blobs load on first
1715 // ANN query via ensure_hnsw_loaded, or on first mutation via the
1716 // lazy-init guard which calls reindex_all_load_ivf + load_hnsw_state.
1717 // - WAL-present: open_with calls consume_retained_state_eager before
1718 // replay so HNSW/IVF are live before any record fires the hooks.
1719 let ivf_bytes = if state.ivf_state.is_empty() {
1720 Vec::new()
1721 } else {
1722 bincode::serialize(&state.ivf_state).expect("IVF state serialize cannot fail")
1723 };
1724 // Store blobs without eagerly deserializing them.
1725 self.engine
1726 .store_snapshot_state(state.hnsw_state, ivf_bytes);
1727 // Restore view defs from snapshot (V5).
1728 // The ColumnStore already contains view values from the snapshot;
1729 // use restore_view (no collision check, no backfill) so the store
1730 // is aware of the definitions. rebuild_all runs after WAL replay.
1731 for def_bytes in &state.view_defs {
1732 let def: ViewDef =
1733 bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
1734 detail: format!("snapshot view_def deserialize: {e}"),
1735 })?;
1736 self.view_store
1737 .restore_view(def)
1738 .map_err(|e| GraphError::Corrupt {
1739 detail: format!("snapshot view restore: {e}"),
1740 })?;
1741 }
1742 Ok(())
1743 }
1744
1745 /// Restore all persisted state from a V8 `MappedBase` snapshot, **except**
1746 /// topology (`self.topo` stays empty and serves as the WAL-replay overlay).
1747 ///
1748 /// `self.props` IS fully materialised from the base so that HNSW/IVF blob
1749 /// deserialization and view rebuild have access to all column data.
1750 fn restore_v8_base(&mut self, mapped: Arc<core_storage::v8::MappedBase>) -> Result<()> {
1751 self.ids = archived_to_idmap(mapped.ids().map_err(|e| GraphError::Corrupt {
1752 detail: format!("v8: ids section: {e:?}"),
1753 })?);
1754 self.syms = archived_to_interner(mapped.syms().map_err(|e| GraphError::Corrupt {
1755 detail: format!("v8: syms section: {e:?}"),
1756 })?);
1757
1758 // C1: self.props is left as an empty overlay. Column reads go through
1759 // props_view() (ColumnsView::with_base), which consults the archived base
1760 // section zero-copy. This avoids the O(columns) heap copy at every open.
1761
1762 // self.topo deliberately left as Topology::new() — overlay path.
1763
1764 let meta = decode_meta(mapped.meta_bytes().map_err(|e| GraphError::Corrupt {
1765 detail: format!("v8: meta section: {e:?}"),
1766 })?)
1767 .map_err(|e| GraphError::Corrupt {
1768 detail: format!("v8: meta decode: {e:?}"),
1769 })?;
1770 self.labels = meta.labels;
1771 // Cross-section label integrity: labels must cover every id slot (live
1772 // and tombstoned), every non-sentinel sym must be within the interner's
1773 // bound, and no live (non-tombstoned) node may carry the u32::MAX
1774 // sentinel label. Without this check, a crafted snapshot where the META
1775 // section (small, CRC-validated) holds a short `labels` vec, out-of-range
1776 // sym ids, or a sentinel label on a live node, would open successfully
1777 // and then panic in `NodeRef::label()`, `neighborhood_masked()`, and
1778 // related read paths. Catching the inconsistency here converts those
1779 // panics into typed `GraphError::Corrupt` at open time.
1780 {
1781 let ids_len = self.ids.len();
1782 if self.labels.len() != ids_len {
1783 return Err(GraphError::Corrupt {
1784 detail: format!(
1785 "v8: labels section has {} entries but id table has {} total slots",
1786 self.labels.len(),
1787 ids_len,
1788 ),
1789 });
1790 }
1791 let syms_len = self.syms.len() as u32;
1792 for (i, &sym) in self.labels.iter().enumerate() {
1793 let is_tombstoned = self.ids.is_tombstoned(i as u32);
1794 if sym == u32::MAX {
1795 // Sentinel is only valid for tombstoned slots.
1796 if !is_tombstoned {
1797 return Err(GraphError::Corrupt {
1798 detail: format!(
1799 "v8: live node at id slot {i} has sentinel label (u32::MAX)"
1800 ),
1801 });
1802 }
1803 } else if sym >= syms_len {
1804 return Err(GraphError::Corrupt {
1805 detail: format!(
1806 "v8: label at id slot {i} references sym {sym} \
1807 which is out of interner range ({syms_len})"
1808 ),
1809 });
1810 }
1811 }
1812 }
1813 // C3: self.edge_props stays as an empty overlay. Reads go through
1814 // edge_props_view() which consults the mmap'd base section zero-copy
1815 // via EdgePropsView::with_base. No heap decode at open time.
1816
1817 // Restore rule engine.
1818 let (rule_def_bytes, rule_tripped, rule_fires) =
1819 archived_rules_meta_to_owned(mapped.rules_meta_section().map_err(|e| {
1820 GraphError::Corrupt {
1821 detail: format!("v8: rules_meta section: {e:?}"),
1822 }
1823 })?);
1824 let defs: Vec<RuleDef> = rule_def_bytes
1825 .iter()
1826 .map(|b| {
1827 decode_rule_def(b).map_err(|e| GraphError::Corrupt {
1828 detail: format!("v8: rule_def deserialize: {e}"),
1829 })
1830 })
1831 .collect::<Result<Vec<_>>>()?;
1832 self.engine = RuleEngine::from_persist(defs, BTreeMap::new(), rule_tripped, rule_fires);
1833 // C4+C5: provenance, HNSW, and IVF sections are NOT read here.
1834 // `ensure_v8_base_sections_loaded` reads them on first use from
1835 // `self.base` (set by the caller immediately after this returns).
1836 // A clean open touches only: header + IDS + SYMS + META + RULES_META.
1837
1838 // Restore view definitions.
1839 let view_defs =
1840 archived_views_to_owned(mapped.views_section().map_err(|e| GraphError::Corrupt {
1841 detail: format!("v8: views section: {e:?}"),
1842 })?);
1843 for def_bytes in &view_defs {
1844 let def: ViewDef =
1845 bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
1846 detail: format!("v8: view_def deserialize: {e}"),
1847 })?;
1848 self.view_store
1849 .restore_view(def)
1850 .map_err(|e| GraphError::Corrupt {
1851 detail: format!("v8: view restore: {e}"),
1852 })?;
1853 }
1854 // Load the last-change map from section 11 (small section; load eagerly).
1855 // Pre-Task-3 snapshots lack this section; `last_change_bytes` returns &[]
1856 // in that case and `decode_last_change_bytes` returns an empty map.
1857 let last_change_raw = mapped
1858 .last_change_bytes()
1859 .map_err(|e| GraphError::Corrupt {
1860 detail: format!("v8: last_change section: {e:?}"),
1861 })?;
1862 self.last_change = decode_last_change_bytes(last_change_raw);
1863
1864 // Validate that all deferred sections (provenance, HNSW, IVF) fit within
1865 // the file. Pure bounds check — no bytes read, no page faults triggered.
1866 // Catches truncated snapshots at open time before the lazy deferred reads.
1867 mapped.validate_section_bounds().map_err(|e| match e {
1868 GraphError::Corrupt { detail } => GraphError::Corrupt {
1869 detail: format!("v8: section bounds: {detail}"),
1870 },
1871 other => other,
1872 })?;
1873 Ok(())
1874 }
1875
1876 /// Read provenance, HNSW, and IVF sections from the mmap base into the
1877 /// engine's retained fields on first call. Subsequent calls are a no-op
1878 /// (AtomicBool fast-path).
1879 ///
1880 /// Must be called before any code path that reads or mutates engine
1881 /// provenance, HNSW, or IVF state:
1882 /// - WAL replay (before `consume_retained_state_eager`)
1883 /// - First mutation (`log_then_apply_with`)
1884 /// - Read-only paths (`stats`, `explain`, `node_edges`)
1885 /// - Snapshot (`snapshot_with`)
1886 ///
1887 /// No-op for fresh stores and V5-V7 opens (`self.base` is `None`).
1888 fn ensure_v8_base_sections_loaded(&self) {
1889 use std::sync::atomic::Ordering;
1890 if self.v8_sections_loaded.load(Ordering::Acquire) {
1891 return;
1892 }
1893 let _guard = self
1894 .v8_sections_mutex
1895 .lock()
1896 .expect("v8 sections mutex poisoned");
1897 if self.v8_sections_loaded.load(Ordering::Acquire) {
1898 return; // another caller populated while we waited
1899 }
1900 let _t = std::time::Instant::now();
1901 if let Some(base) = &self.base {
1902 // Provenance: raw rkyv bytes; CRC validated inside section_bytes.
1903 // Bounds are already validated at open time (restore_v8_base →
1904 // validate_section_bounds) — unreachable post-validate_section_bounds;
1905 // unwrap_or_default is a safety belt against impossible errors.
1906 let prov_bytes = base
1907 .provenance_raw_bytes()
1908 .map(|b| b.to_vec())
1909 .unwrap_or_default();
1910 self.engine.store_provenance_bytes(prov_bytes);
1911 // HNSW: decode rkyv blobs into owned map.
1912 let hnsw_state = base
1913 .hnsw_section()
1914 .map(archived_hnsw_to_owned)
1915 .unwrap_or_default();
1916 // IVF: raw bincode bytes; deserialized on first mutation/query.
1917 let ivf_bytes = base.ivf_bytes().map(|b| b.to_vec()).unwrap_or_default();
1918 self.engine.store_snapshot_state(hnsw_state, ivf_bytes);
1919 }
1920 self.v8_sections_loaded.store(true, Ordering::Release);
1921 if std::env::var("MUSHROOMDB_TRACE_OPEN").is_ok() {
1922 eprintln!(
1923 "[MUSHROOMDB_TRACE_OPEN] ensure_v8_base_sections_loaded: {:>9.3?}",
1924 _t.elapsed()
1925 );
1926 }
1927 }
1928
1929 /// Return a `TopologyView` that merges the mmap'd base (when present) with
1930 /// the in-memory WAL overlay. Used by all read paths in db.rs that need
1931 /// the full merged topology without going through `self.view()`.
1932 fn topo_view(&self) -> TopologyView<'_> {
1933 match self.base {
1934 None => TopologyView::owned(&self.topo),
1935 Some(ref base) => {
1936 // SAFETY: base lives as long as self; section bounds validated at open.
1937 // topology() uses access_unchecked; all field reads are bounds-checked in seam.rs.
1938 let archived = base
1939 .topology()
1940 .expect("base topology section bounds validated at open");
1941 TopologyView::with_base(&self.topo, archived)
1942 }
1943 }
1944 }
1945
1946 /// Return a `ColumnsView` that merges the mmap'd base columns (when a V8
1947 /// snapshot is open) with the in-memory WAL overlay. Reads consult the
1948 /// overlay first, then fall through to the archived base section zero-copy.
1949 fn props_view(&self) -> core_storage::v8::seam::ColumnsView<'_> {
1950 match self.base {
1951 None => core_storage::v8::seam::ColumnsView::owned(&self.props),
1952 Some(ref base) => {
1953 // columns() uses access_unchecked; field reads are bounds-checked in seam.rs.
1954 let archived = base
1955 .columns()
1956 .expect("base columns section bounds validated at open");
1957 core_storage::v8::seam::ColumnsView::with_base(&self.props, archived)
1958 }
1959 }
1960 }
1961
1962 /// Return an `EdgePropsView` that merges the mmap'd base edge-props section
1963 /// (when a V8 snapshot is open) with the in-memory WAL overlay.
1964 ///
1965 /// Reads consult the overlay first (for post-snapshot mutations), then fall
1966 /// through to the archived base section zero-copy. Tombstones in the
1967 /// overlay mask deleted-from-base entries.
1968 fn edge_props_view(&self) -> EdgePropsView<'_> {
1969 match self.base {
1970 None => EdgePropsView::owned(&self.edge_props),
1971 Some(ref base) => {
1972 // edge_props_section() uses access_unchecked; field reads bounds-checked in seam.rs.
1973 let archived = base
1974 .edge_props_section()
1975 .expect("base edge_props section bounds validated at open");
1976 EdgePropsView::with_base(&self.edge_props, archived)
1977 }
1978 }
1979 }
1980
1981 fn open_at_with(fs: F, commit: u64) -> Result<Self> {
1982 let mut db = Self {
1983 fs,
1984 ids: IdMap::new(),
1985 syms: Interner::new(),
1986 topo: Topology::new(),
1987 props: ColumnStore::new(),
1988 labels: Vec::new(),
1989 edge_props: EdgeProps::new(),
1990 engine: RuleEngine::new(),
1991 view_store: ViewStore::new(),
1992 fulltext: FulltextIndex::new(),
1993 prop_index: PropertyIndex::new(),
1994 event_sink: None,
1995 fsync: FsyncPolicy::Strict,
1996 commit_seq: 0,
1997 roles: Some(vec![]),
1998 subscriptions: Vec::new(),
1999 query_subscriptions: Vec::new(),
2000 sub_capacity: DEFAULT_SUB_CAPACITY,
2001 read_only: false, // set to true after replay
2002 total_wal_commits: 0,
2003 base: None,
2004 fold_overlay: None,
2005 delta_tail: Vec::new(),
2006 commits_since_fold: 0,
2007 defer_events: false,
2008 deferred_events: Vec::new(),
2009 degraded: false,
2010 v8_sections_loaded: std::sync::atomic::AtomicBool::new(false),
2011 v8_sections_mutex: std::sync::Mutex::new(()),
2012 last_change: HashMap::new(),
2013 wal_archive_retention: None,
2014 wal_horizon_floor: 0,
2015 archive_genesis_chain: false,
2016 pending_write_authz: None,
2017 slow_query_threshold_ms: std::env::var("MUSHROOMDB_SLOW_QUERY_MS")
2018 .ok()
2019 .and_then(|v| v.parse().ok())
2020 .unwrap_or(100),
2021 slow_queries: std::sync::Mutex::new(SlowQueryLog {
2022 entries: std::collections::VecDeque::new(),
2023 total: 0,
2024 }),
2025 started_at: std::time::Instant::now(),
2026 };
2027 db.wal_horizon_floor = db.fs.read_horizon_floor()?;
2028 db.archive_genesis_chain = db.fs.has_genesis_marker();
2029 // Same orphaned-archive cleanup as open_with: floor was written first
2030 // during pruning, so a crash may have left stale archives below floor.
2031 db.cleanup_orphaned_archives()?;
2032 // Collect archive frames (oldest-first) and live WAL frames.
2033 // Archives represent pre-snapshot history; the snapshot captures the
2034 // cumulative state at the time of archiving. Crash-window guarantee:
2035 // A: crash before rename → WAL intact, no archive. Reopen: normal.
2036 // B: crash after rename, before new WAL → archive present, WAL
2037 // absent. Reopen: snapshot loaded (full state), no WAL replay.
2038 // C: crash after new baseline WAL written → normal post-archive.
2039 let archive_ns = db.fs.list_archives()?;
2040 let mut archive_frames_all: Vec<WalRecord> = Vec::new();
2041 for n in &archive_ns {
2042 let arc_bytes = db.fs.read_archive(*n)?;
2043 let (arc_frames, _) = decode_all(&arc_bytes);
2044 archive_frames_all.extend(arc_frames);
2045 }
2046 let total_archive_frames = archive_frames_all.len() as u64;
2047
2048 let live_bytes = db.fs.read(FileId::Wal)?;
2049 let (live_records, _valid_len) = decode_all(&live_bytes);
2050 let total_surviving = total_archive_frames + live_records.len() as u64;
2051 // Global total including any pruned history below the horizon floor.
2052 let total = db.wal_horizon_floor + total_surviving;
2053
2054 // Horizon and range check.
2055 if commit < db.wal_horizon_floor {
2056 return Err(GraphError::CommitOutOfRange { commit, total });
2057 }
2058 if commit >= total {
2059 return Err(GraphError::CommitOutOfRange { commit, total });
2060 }
2061
2062 // Local index into surviving frames (0 = first frame of oldest archive).
2063 let local = commit - db.wal_horizon_floor;
2064
2065 if local < total_archive_frames {
2066 // Target commit is in an archive. Correct replay from empty state
2067 // is only possible when the archive chain is an uninterrupted
2068 // genesis chain (first archive taken from a fresh store, no prior
2069 // WAL truncation) and no archives have been pruned (floor == 0).
2070 //
2071 // If either condition is violated the prefix needed to reconstruct
2072 // the requested state is gone; refuse rather than return wrong data.
2073 if db.wal_horizon_floor > 0 || !db.archive_genesis_chain {
2074 return Err(GraphError::CommitOutOfRange { commit, total });
2075 }
2076 // Replay all archive frames up to and including the target commit
2077 // from an empty database state. Archives must be replayed in order
2078 // so that dense-id intern tables are built up correctly.
2079 for rec in archive_frames_all.into_iter().take((local + 1) as usize) {
2080 db.apply(&rec)?;
2081 let _ = db.engine.drain_deltas();
2082 }
2083 } else {
2084 // Target commit is in the live WAL: load snapshot as base, then
2085 // replay the needed live WAL prefix.
2086 //
2087 // Base state: a truncating snapshot (wal_truncated=true) compacts
2088 // all pre-truncation / pre-archive commits. Dense-id records in
2089 // the live WAL reference ids/interns that the snapshot provides.
2090 // Peek 6 bytes (same pattern as open_with).
2091 let snap_header = db.fs.read_prefix(FileId::Snapshot, 6)?;
2092 let is_v8 = snap_header.len() >= 6
2093 && &snap_header[0..4] == b"GDB1"
2094 && u16::from_le_bytes([snap_header[4], snap_header[5]])
2095 == core_storage::snapshot::VERSION_8;
2096 if is_v8 {
2097 let state = if let Some(snap_path) = db.fs.snapshot_path() {
2098 let mapped = core_storage::v8::MappedBase::map(&snap_path).map_err(|e| {
2099 GraphError::Corrupt {
2100 detail: format!("v8: open_at mmap: {e:?}"),
2101 }
2102 })?;
2103 core_storage::snapshot::decode_v8_from_mapped(&mapped)?
2104 } else {
2105 let snap_bytes = db.fs.read(FileId::Snapshot)?;
2106 core_storage::snapshot::decode(&snap_bytes)?
2107 };
2108 if let Some(state) = state {
2109 if state.wal_truncated {
2110 db.restore_snapshot_state(state)?;
2111 }
2112 }
2113 } else if !snap_header.is_empty() {
2114 let snap_bytes = db.fs.read(FileId::Snapshot)?;
2115 if let Some(state) = core_storage::snapshot::decode(&snap_bytes)? {
2116 if state.wal_truncated {
2117 db.restore_snapshot_state(state)?;
2118 }
2119 }
2120 }
2121 // else: snap_header empty = no snapshot file.
2122 let live_local = local - total_archive_frames;
2123 for rec in live_records.into_iter().take((live_local + 1) as usize) {
2124 db.apply(&rec)?;
2125 let _ = db.engine.drain_deltas();
2126 }
2127 }
2128 // Pin: pending_delta_count must be 0 after as-of replay, mirroring T1's
2129 // post-loop assert in open_with.
2130 debug_assert_eq!(
2131 db.engine.pending_delta_count(),
2132 0,
2133 "pending_deltas non-empty after open_at replay — \
2134 per-frame drain must run inside the loop to keep memory O(1)"
2135 );
2136 let _ = db.engine.drain_deltas(); // belt-and-braces no-op
2137 // Rebuild view values after WAL replay so derived-edge-driven views
2138 // reflect the as-of state. open_at always uses the legacy path (no V8
2139 // base), so topo_view is always owned.
2140 {
2141 let topo_view = TopologyView::owned(&db.topo);
2142 db.view_store
2143 .rebuild_all(&mut db.props, &topo_view, &db.ids, &db.syms, &db.labels);
2144 }
2145 // Rebuild full-text index for as-of view (mirrors open_with pattern).
2146 db.fulltext.rebuild_all(
2147 &db.ids,
2148 &db.labels,
2149 &db.syms,
2150 build_props_view(&db.props, &db.base),
2151 );
2152 db.prop_index.rebuild_all(
2153 &db.ids,
2154 &db.labels,
2155 &db.syms,
2156 build_props_view(&db.props, &db.base),
2157 );
2158 // Load roles sidecar (current roles, not point-in-time).
2159 db.roles = Self::load_roles_from_fs(&db.fs)?;
2160 db.read_only = true;
2161 db.total_wal_commits = total;
2162 // Capture initial fold so reader() is immediately usable.
2163 db.fold_now();
2164 Ok(db)
2165 }
2166
2167 /// Whether this instance is a read-only as-of view.
2168 pub fn is_read_only(&self) -> bool {
2169 self.read_only
2170 }
2171
2172 // ── MVCC epoch reader ─────────────────────────────────────────────────────
2173
2174 /// Clone the current overlay state into a new `FrozenOverlay` and reset
2175 /// the delta tail. Called automatically every `FOLD_EVERY_K` commits and at
2176 /// the end of `open_with` / `open_at_with` to prime the reader.
2177 fn fold_now(&mut self) {
2178 let frozen = crate::reader::FrozenOverlay {
2179 ids: self.ids.clone(),
2180 syms: self.syms.clone(),
2181 topo: self.topo.clone(),
2182 props: self.props.clone(),
2183 labels: self.labels.clone(),
2184 edge_props: self.edge_props.clone(),
2185 roles: self.roles.clone(),
2186 fulltext: self.fulltext.clone(),
2187 };
2188 self.fold_overlay = Some(Arc::new(frozen));
2189 self.delta_tail.clear();
2190 self.commits_since_fold = 0;
2191 }
2192
2193 /// Capture a lock-free reader snapshot of the current db state.
2194 ///
2195 /// The read lock is held only for the duration of this call (to clone a
2196 /// handful of `Arc` handles). Subsequent query operations run without any
2197 /// lock.
2198 pub fn reader(&self) -> crate::reader::ReaderSnapshot {
2199 crate::reader::ReaderSnapshot::new(
2200 self.fold_overlay
2201 .clone()
2202 .expect("fold_overlay is always Some after open_with; call reader() after open"),
2203 self.base.clone(),
2204 self.delta_tail.clone(),
2205 )
2206 }
2207
2208 /// Total number of WAL commits at the time [`open_at`] was called.
2209 /// Returns 0 for normal (non-as-of) instances.
2210 pub fn total_wal_commits(&self) -> u64 {
2211 self.total_wal_commits
2212 }
2213
2214 /// Apply a record to in-memory state. Used by both live writes and replay,
2215 /// so replay is definitionally identical to the original execution.
2216 fn apply(&mut self, rec: &WalRecord) -> Result<()> {
2217 match rec {
2218 WalRecord::InsertNode { label, key, props } => {
2219 let id = self.ids.try_insert(key)?;
2220 let sym = self.syms.intern(label);
2221 if self.labels.len() <= id as usize {
2222 // gap slots are sentinels, never valid label symbols
2223 self.labels.resize(id as usize + 1, u32::MAX);
2224 }
2225 self.labels[id as usize] = sym;
2226 for (field, value) in props {
2227 self.props.set(id, field, value.clone());
2228 }
2229 // Initialize view values for the new node before the engine runs so
2230 // delta-based increments start from a known zero baseline.
2231 self.view_store
2232 .init_node_views(id, &mut self.props, &self.syms, &self.labels);
2233 // Fire rules for the newly inserted node.
2234 let cursor = self.engine.pending_delta_count();
2235 let mut eng = std::mem::take(&mut self.engine);
2236 {
2237 let mut gm = make_graph_mut(
2238 &self.ids,
2239 &mut self.syms,
2240 &self.labels,
2241 build_props_view(&self.props, &self.base),
2242 &mut self.topo,
2243 &mut self.edge_props,
2244 );
2245 eng.on_node_changed(id, None, &mut gm);
2246 }
2247 self.engine = eng;
2248 // Process derived-edge deltas for view maintenance.
2249 // Fast path: skip the O(delta_count) allocation when no views exist.
2250 if !self.view_store.is_empty() {
2251 #[cfg(test)]
2252 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2253 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2254 for d in &new_deltas {
2255 self.view_store.on_edge_changed(
2256 d.etype_sym,
2257 d.src_id,
2258 d.dst_id,
2259 d.fired,
2260 &mut self.props,
2261 &build_topo_view(&self.topo, &self.base),
2262 &self.ids,
2263 &self.syms,
2264 &self.labels,
2265 self.base.as_ref().map(|b| {
2266 b.columns()
2267 .expect("base columns section bounds validated at open")
2268 }),
2269 );
2270 }
2271 }
2272 // Full-text index maintenance: index enabled fields for this label.
2273 if self.fulltext.has_label(label) {
2274 for (field, value) in props {
2275 if self.fulltext.is_enabled(label, field) {
2276 self.fulltext.add_tokens(id, field, value);
2277 }
2278 }
2279 }
2280 // Property (equality) index maintenance.
2281 if self.prop_index.has_label(label) {
2282 for (field, value) in props {
2283 self.prop_index.set(label, field, id, value);
2284 }
2285 }
2286 }
2287 WalRecord::InsertEdge {
2288 edge_type,
2289 src_key,
2290 dst_key,
2291 } => {
2292 let src = self.ids.get(src_key).ok_or_else(|| GraphError::Corrupt {
2293 detail: format!("wal replay references unknown key {src_key}"),
2294 })?;
2295 let dst = self.ids.get(dst_key).ok_or_else(|| GraphError::Corrupt {
2296 detail: format!("wal replay references unknown key {dst_key}"),
2297 })?;
2298 let etype = self.syms.intern(edge_type);
2299 // Skip if the edge is already visible in the merged base+overlay
2300 // view. This keeps WAL replay idempotent when the WAL contains
2301 // pre-snapshot records that are already encoded in a V8 base
2302 // (keep_wal=true opens and crash-before-truncation scenarios).
2303 if self.base.is_some()
2304 && self
2305 .topo_view()
2306 .neighbors(etype, Direction::Out, src)
2307 .contains(&dst)
2308 {
2309 return Ok(());
2310 }
2311 self.topo.add_edge(etype, src, dst);
2312 // View maintenance for manual edge insert.
2313 self.view_store.on_edge_changed(
2314 etype,
2315 src,
2316 dst,
2317 true,
2318 &mut self.props,
2319 &build_topo_view(&self.topo, &self.base),
2320 &self.ids,
2321 &self.syms,
2322 &self.labels,
2323 self.base.as_ref().map(|b| {
2324 b.columns()
2325 .expect("base columns section bounds validated at open")
2326 }),
2327 );
2328 // Rule engine: via-hop rules must update when user edges change.
2329 let cursor = self.engine.pending_delta_count();
2330 let mut eng = std::mem::take(&mut self.engine);
2331 {
2332 let mut gm = make_graph_mut(
2333 &self.ids,
2334 &mut self.syms,
2335 &self.labels,
2336 build_props_view(&self.props, &self.base),
2337 &mut self.topo,
2338 &mut self.edge_props,
2339 );
2340 eng.on_edge_changed(edge_type, src, dst, &mut gm);
2341 }
2342 self.engine = eng;
2343 if !self.view_store.is_empty() {
2344 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2345 for d in &new_deltas {
2346 self.view_store.on_edge_changed(
2347 d.etype_sym,
2348 d.src_id,
2349 d.dst_id,
2350 d.fired,
2351 &mut self.props,
2352 &build_topo_view(&self.topo, &self.base),
2353 &self.ids,
2354 &self.syms,
2355 &self.labels,
2356 self.base.as_ref().map(|b| {
2357 b.columns()
2358 .expect("base columns section bounds validated at open")
2359 }),
2360 );
2361 }
2362 }
2363 }
2364 WalRecord::SetProp { key, field, value } => {
2365 let id = self.ids.get(key).ok_or_else(|| GraphError::Corrupt {
2366 detail: format!("wal replay references unknown key {key}"),
2367 })?;
2368 let old_value = build_props_view(&self.props, &self.base)
2369 .get(id, field)
2370 .map(|vr| vr.into_value());
2371 self.props.set(id, field, value.clone());
2372 // Fire rules for the changed field.
2373 let cursor = self.engine.pending_delta_count();
2374 let mut eng = std::mem::take(&mut self.engine);
2375 {
2376 let mut gm = make_graph_mut(
2377 &self.ids,
2378 &mut self.syms,
2379 &self.labels,
2380 build_props_view(&self.props, &self.base),
2381 &mut self.topo,
2382 &mut self.edge_props,
2383 );
2384 eng.on_node_changed(id, Some((field, old_value)), &mut gm);
2385 }
2386 self.engine = eng;
2387 // Derived-edge deltas → view updates.
2388 if !self.view_store.is_empty() {
2389 #[cfg(test)]
2390 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2391 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2392 for d in &new_deltas {
2393 self.view_store.on_edge_changed(
2394 d.etype_sym,
2395 d.src_id,
2396 d.dst_id,
2397 d.fired,
2398 &mut self.props,
2399 &build_topo_view(&self.topo, &self.base),
2400 &self.ids,
2401 &self.syms,
2402 &self.labels,
2403 self.base.as_ref().map(|b| {
2404 b.columns()
2405 .expect("base columns section bounds validated at open")
2406 }),
2407 );
2408 }
2409 }
2410 // Neighbor-aggregate views that read `field` must also update.
2411 self.view_store.on_prop_changed(
2412 id,
2413 field,
2414 &mut self.props,
2415 &build_topo_view(&self.topo, &self.base),
2416 &self.ids,
2417 &self.syms,
2418 &self.labels,
2419 self.base.as_ref().map(|b| {
2420 b.columns()
2421 .expect("base columns section bounds validated at open")
2422 }),
2423 );
2424 // Full-text index maintenance: update tokens for this field if indexed.
2425 if self.fulltext.field_indexed(field) {
2426 let label_opt = self.labels.get(id as usize).and_then(|&sym| {
2427 if sym == u32::MAX {
2428 None
2429 } else {
2430 self.syms.resolve(sym)
2431 }
2432 });
2433 if let Some(label) = label_opt {
2434 if self.fulltext.is_enabled(label, field) {
2435 self.fulltext.remove_node_field(id, field);
2436 self.fulltext.add_tokens(id, field, value);
2437 }
2438 }
2439 }
2440 // Property (equality) index maintenance: re-key this node's value.
2441 if self.prop_index.field_indexed(field) {
2442 let label_opt = self.labels.get(id as usize).and_then(|&sym| {
2443 if sym == u32::MAX {
2444 None
2445 } else {
2446 self.syms.resolve(sym)
2447 }
2448 });
2449 if let Some(label) = label_opt {
2450 self.prop_index.set(label, field, id, value);
2451 }
2452 }
2453 }
2454 WalRecord::Intern { id, text } => {
2455 if let Some(existing) = self.syms.get(text) {
2456 if existing != *id {
2457 return Err(GraphError::Corrupt {
2458 detail: format!(
2459 "wal intern mismatch for {text:?}: have {existing}, record {id}"
2460 ),
2461 });
2462 }
2463 } else {
2464 let got = self.syms.intern(text);
2465 if got != *id {
2466 return Err(GraphError::Corrupt {
2467 detail: format!(
2468 "wal intern assigned {got} for {text:?}, record wanted {id}"
2469 ),
2470 });
2471 }
2472 }
2473 }
2474 WalRecord::InsertNodeId { label, key, props } => {
2475 let id = self.ids.try_insert(key)?;
2476 if self.labels.len() <= id as usize {
2477 self.labels.resize(id as usize + 1, u32::MAX);
2478 }
2479 self.labels[id as usize] = *label;
2480 let label_str = self
2481 .syms
2482 .resolve(*label)
2483 .ok_or_else(|| GraphError::Corrupt {
2484 detail: format!("wal InsertNodeId unknown label intern {label}"),
2485 })?
2486 .to_string();
2487 for (field_sym, value) in props {
2488 let field =
2489 self.syms
2490 .resolve(*field_sym)
2491 .ok_or_else(|| GraphError::Corrupt {
2492 detail: format!(
2493 "wal InsertNodeId unknown field intern {field_sym}"
2494 ),
2495 })?;
2496 self.props.set(id, field, value.clone());
2497 }
2498 self.view_store
2499 .init_node_views(id, &mut self.props, &self.syms, &self.labels);
2500 let cursor = self.engine.pending_delta_count();
2501 let mut eng = std::mem::take(&mut self.engine);
2502 {
2503 let mut gm = make_graph_mut(
2504 &self.ids,
2505 &mut self.syms,
2506 &self.labels,
2507 build_props_view(&self.props, &self.base),
2508 &mut self.topo,
2509 &mut self.edge_props,
2510 );
2511 eng.on_node_changed(id, None, &mut gm);
2512 }
2513 self.engine = eng;
2514 if !self.view_store.is_empty() {
2515 #[cfg(test)]
2516 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2517 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2518 for d in &new_deltas {
2519 self.view_store.on_edge_changed(
2520 d.etype_sym,
2521 d.src_id,
2522 d.dst_id,
2523 d.fired,
2524 &mut self.props,
2525 &build_topo_view(&self.topo, &self.base),
2526 &self.ids,
2527 &self.syms,
2528 &self.labels,
2529 self.base.as_ref().map(|b| {
2530 b.columns()
2531 .expect("base columns section bounds validated at open")
2532 }),
2533 );
2534 }
2535 }
2536 if self.fulltext.has_label(&label_str) {
2537 for (field_sym, value) in props {
2538 let Some(field) = self.syms.resolve(*field_sym) else {
2539 continue;
2540 };
2541 if self.fulltext.is_enabled(&label_str, field) {
2542 self.fulltext.add_tokens(id, field, value);
2543 }
2544 }
2545 }
2546 if self.prop_index.has_label(&label_str) {
2547 for (field_sym, value) in props {
2548 let Some(field) = self.syms.resolve(*field_sym) else {
2549 continue;
2550 };
2551 self.prop_index.set(&label_str, field, id, value);
2552 }
2553 }
2554 }
2555 WalRecord::InsertEdgeId { etype, src, dst } => {
2556 // Replay-over-snapshot: dense ids in the pre-snapshot WAL may
2557 // already be tombstoned. Skip rather than attaching edges to
2558 // dead ids (DeleteNode keys the live re-insert, not the old id).
2559 if self.ids.is_tombstoned(*src)
2560 || self.ids.is_tombstoned(*dst)
2561 || self.ids.key_of(*src).is_none()
2562 || self.ids.key_of(*dst).is_none()
2563 {
2564 return Ok(());
2565 }
2566 // Skip if already visible in the merged view (same idempotency
2567 // guard as InsertEdge above: prevents double-counting when
2568 // pre-snapshot WAL records are replayed over a V8 base).
2569 if self.base.is_some()
2570 && self
2571 .topo_view()
2572 .neighbors(*etype, Direction::Out, *src)
2573 .contains(dst)
2574 {
2575 return Ok(());
2576 }
2577 self.topo.add_edge(*etype, *src, *dst);
2578 self.view_store.on_edge_changed(
2579 *etype,
2580 *src,
2581 *dst,
2582 true,
2583 &mut self.props,
2584 &build_topo_view(&self.topo, &self.base),
2585 &self.ids,
2586 &self.syms,
2587 &self.labels,
2588 self.base.as_ref().map(|b| {
2589 b.columns()
2590 .expect("base columns section bounds validated at open")
2591 }),
2592 );
2593 // Rule engine: via-hop rules fire when user via-edges are inserted.
2594 // Resolve etype back to string so on_edge_changed can match rules by name.
2595 if let Some(etype_str) = self.syms.resolve(*etype).map(|s| s.to_string()) {
2596 let cursor = self.engine.pending_delta_count();
2597 let mut eng = std::mem::take(&mut self.engine);
2598 {
2599 let mut gm = make_graph_mut(
2600 &self.ids,
2601 &mut self.syms,
2602 &self.labels,
2603 build_props_view(&self.props, &self.base),
2604 &mut self.topo,
2605 &mut self.edge_props,
2606 );
2607 eng.on_edge_changed(&etype_str, *src, *dst, &mut gm);
2608 }
2609 self.engine = eng;
2610 if !self.view_store.is_empty() {
2611 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2612 for d in &new_deltas {
2613 self.view_store.on_edge_changed(
2614 d.etype_sym,
2615 d.src_id,
2616 d.dst_id,
2617 d.fired,
2618 &mut self.props,
2619 &build_topo_view(&self.topo, &self.base),
2620 &self.ids,
2621 &self.syms,
2622 &self.labels,
2623 self.base.as_ref().map(|b| {
2624 b.columns()
2625 .expect("base columns section bounds validated at open")
2626 }),
2627 );
2628 }
2629 }
2630 }
2631 }
2632 WalRecord::SetPropId { id, field, value } => {
2633 if self.ids.is_tombstoned(*id) || self.ids.key_of(*id).is_none() {
2634 return Ok(());
2635 }
2636 let field_str = self
2637 .syms
2638 .resolve(*field)
2639 .ok_or_else(|| GraphError::Corrupt {
2640 detail: format!("wal SetPropId unknown field intern {field}"),
2641 })?
2642 .to_string();
2643 let old_value = build_props_view(&self.props, &self.base)
2644 .get(*id, &field_str)
2645 .map(|vr| vr.into_value());
2646 self.props.set(*id, &field_str, value.clone());
2647 let cursor = self.engine.pending_delta_count();
2648 let mut eng = std::mem::take(&mut self.engine);
2649 {
2650 let mut gm = make_graph_mut(
2651 &self.ids,
2652 &mut self.syms,
2653 &self.labels,
2654 build_props_view(&self.props, &self.base),
2655 &mut self.topo,
2656 &mut self.edge_props,
2657 );
2658 eng.on_node_changed(*id, Some((field_str.as_str(), old_value)), &mut gm);
2659 }
2660 self.engine = eng;
2661 if !self.view_store.is_empty() {
2662 #[cfg(test)]
2663 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2664 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2665 for d in &new_deltas {
2666 self.view_store.on_edge_changed(
2667 d.etype_sym,
2668 d.src_id,
2669 d.dst_id,
2670 d.fired,
2671 &mut self.props,
2672 &build_topo_view(&self.topo, &self.base),
2673 &self.ids,
2674 &self.syms,
2675 &self.labels,
2676 self.base.as_ref().map(|b| {
2677 b.columns()
2678 .expect("base columns section bounds validated at open")
2679 }),
2680 );
2681 }
2682 }
2683 self.view_store.on_prop_changed(
2684 *id,
2685 &field_str,
2686 &mut self.props,
2687 &build_topo_view(&self.topo, &self.base),
2688 &self.ids,
2689 &self.syms,
2690 &self.labels,
2691 self.base.as_ref().map(|b| {
2692 b.columns()
2693 .expect("base columns section bounds validated at open")
2694 }),
2695 );
2696 if self.fulltext.field_indexed(&field_str) {
2697 let label_opt = self.labels.get(*id as usize).and_then(|&sym| {
2698 if sym == u32::MAX {
2699 None
2700 } else {
2701 self.syms.resolve(sym)
2702 }
2703 });
2704 if let Some(label) = label_opt {
2705 if self.fulltext.is_enabled(label, &field_str) {
2706 self.fulltext.remove_node_field(*id, &field_str);
2707 self.fulltext.add_tokens(*id, &field_str, value);
2708 }
2709 }
2710 }
2711 if self.prop_index.field_indexed(&field_str) {
2712 let label_opt = self.labels.get(*id as usize).and_then(|&sym| {
2713 if sym == u32::MAX {
2714 None
2715 } else {
2716 self.syms.resolve(sym)
2717 }
2718 });
2719 if let Some(label) = label_opt {
2720 self.prop_index.set(label, &field_str, *id, value);
2721 }
2722 }
2723 }
2724 WalRecord::CreateRule { def_bytes } => {
2725 let def: RuleDef = decode_rule_def(def_bytes).map_err(|e| GraphError::Corrupt {
2726 detail: format!("CreateRule def_bytes deserialize failed: {e}"),
2727 })?;
2728 // Replay-over-snapshot idempotency: the rule was captured in the snapshot
2729 // so the engine already has it; silently skip to avoid a spurious
2730 // RuleInvalid error in the crash window between snapshot write and WAL
2731 // truncation.
2732 if self.engine.rules().any(|r| r.name == def.name) {
2733 return Ok(());
2734 }
2735 let cursor = self.engine.pending_delta_count();
2736 let mut eng = std::mem::take(&mut self.engine);
2737 let result = {
2738 let mut gm = make_graph_mut(
2739 &self.ids,
2740 &mut self.syms,
2741 &self.labels,
2742 build_props_view(&self.props, &self.base),
2743 &mut self.topo,
2744 &mut self.edge_props,
2745 );
2746 eng.create_rule(def, &mut gm)
2747 };
2748 self.engine = eng;
2749 result.map_err(|e| GraphError::RuleInvalid { detail: e })?;
2750 // Derived-edge fires from backfill → view updates.
2751 // Fast path: skip O(edge_count) allocation when no views exist.
2752 if !self.view_store.is_empty() {
2753 #[cfg(test)]
2754 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2755 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2756 for d in &new_deltas {
2757 self.view_store.on_edge_changed(
2758 d.etype_sym,
2759 d.src_id,
2760 d.dst_id,
2761 d.fired,
2762 &mut self.props,
2763 &build_topo_view(&self.topo, &self.base),
2764 &self.ids,
2765 &self.syms,
2766 &self.labels,
2767 self.base.as_ref().map(|b| {
2768 b.columns()
2769 .expect("base columns section bounds validated at open")
2770 }),
2771 );
2772 }
2773 }
2774 }
2775 WalRecord::DeleteRule { name } => {
2776 // Replay-over-snapshot idempotency: the snapshot already captured the
2777 // post-delete state so the rule is absent; silently skip to avoid a
2778 // spurious RuleNotFound error in the crash window between snapshot write
2779 // and WAL truncation.
2780 if !self.engine.rules().any(|r| r.name == *name) {
2781 return Ok(());
2782 }
2783 let cursor = self.engine.pending_delta_count();
2784 let mut eng = std::mem::take(&mut self.engine);
2785 let result = {
2786 let mut gm = make_graph_mut(
2787 &self.ids,
2788 &mut self.syms,
2789 &self.labels,
2790 build_props_view(&self.props, &self.base),
2791 &mut self.topo,
2792 &mut self.edge_props,
2793 );
2794 eng.delete_rule(name, &mut gm)
2795 };
2796 self.engine = eng;
2797 result.map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
2798 // Derived-edge retractions → view updates.
2799 if !self.view_store.is_empty() {
2800 #[cfg(test)]
2801 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2802 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2803 for d in &new_deltas {
2804 self.view_store.on_edge_changed(
2805 d.etype_sym,
2806 d.src_id,
2807 d.dst_id,
2808 d.fired,
2809 &mut self.props,
2810 &build_topo_view(&self.topo, &self.base),
2811 &self.ids,
2812 &self.syms,
2813 &self.labels,
2814 self.base.as_ref().map(|b| {
2815 b.columns()
2816 .expect("base columns section bounds validated at open")
2817 }),
2818 );
2819 }
2820 }
2821 }
2822 WalRecord::RemoveProp { key, field } => {
2823 // Recovery-safe: unknown key or already-absent field is a
2824 // clean no-op. Crash-window replay over a snapshot that
2825 // already applied this record must not Err.
2826 let Some(id) = self.ids.get(key) else {
2827 return Ok(());
2828 };
2829 // Read old value through the seam for rule retraction.
2830 let old = build_props_view(&self.props, &self.base)
2831 .get(id, field)
2832 .map(|vr| vr.into_value());
2833 self.props.remove(id, field);
2834 // If the base still supplies the value after the overlay removal,
2835 // record a tombstone so ColumnsView::get does not resurrect it.
2836 // This covers both the base-only case AND the both-resident case:
2837 // base-only (in_overlay=false): old prop was only in base, remove
2838 // is a no-op on overlay, base still visible → tombstone needed.
2839 // both-resident (in_overlay=true): overlay had v2, base has v1;
2840 // removing overlay uncovers v1 → tombstone needed.
2841 // Idempotent on double-replay: second pass sees the tombstone →
2842 // get() returns None → condition is false → no duplicate tombstone.
2843 if build_props_view(&self.props, &self.base)
2844 .get(id, field)
2845 .is_some()
2846 {
2847 self.props.record_prop_tombstone(id, field);
2848 }
2849 let cursor = self.engine.pending_delta_count();
2850 let mut eng = std::mem::take(&mut self.engine);
2851 {
2852 let mut gm = make_graph_mut(
2853 &self.ids,
2854 &mut self.syms,
2855 &self.labels,
2856 build_props_view(&self.props, &self.base),
2857 &mut self.topo,
2858 &mut self.edge_props,
2859 );
2860 eng.on_node_changed(id, Some((field, old)), &mut gm);
2861 }
2862 self.engine = eng;
2863 // Derived-edge deltas → view updates.
2864 if !self.view_store.is_empty() {
2865 #[cfg(test)]
2866 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2867 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2868 for d in &new_deltas {
2869 self.view_store.on_edge_changed(
2870 d.etype_sym,
2871 d.src_id,
2872 d.dst_id,
2873 d.fired,
2874 &mut self.props,
2875 &build_topo_view(&self.topo, &self.base),
2876 &self.ids,
2877 &self.syms,
2878 &self.labels,
2879 self.base.as_ref().map(|b| {
2880 b.columns()
2881 .expect("base columns section bounds validated at open")
2882 }),
2883 );
2884 }
2885 }
2886 // Neighbor-aggregate views that read `field` must also update.
2887 self.view_store.on_prop_changed(
2888 id,
2889 field,
2890 &mut self.props,
2891 &build_topo_view(&self.topo, &self.base),
2892 &self.ids,
2893 &self.syms,
2894 &self.labels,
2895 self.base.as_ref().map(|b| {
2896 b.columns()
2897 .expect("base columns section bounds validated at open")
2898 }),
2899 );
2900 // Full-text index maintenance: remove tokens for this field.
2901 if self.fulltext.field_indexed(field) {
2902 self.fulltext.remove_node_field(id, field);
2903 }
2904 // Property (equality) index maintenance: drop this node's entry.
2905 if self.prop_index.field_indexed(field) {
2906 if let Some(label) = self.labels.get(id as usize).and_then(|&sym| {
2907 (sym != u32::MAX).then(|| self.syms.resolve(sym)).flatten()
2908 }) {
2909 self.prop_index.remove_node(label, field, id);
2910 }
2911 }
2912 }
2913 WalRecord::DeleteEdge {
2914 edge_type,
2915 src_key,
2916 dst_key,
2917 } => {
2918 // Recovery-safe: unknown keys, unknown etype, or already-
2919 // absent edge is a clean no-op (remove_edge returns false).
2920 let Some(src) = self.ids.get(src_key) else {
2921 return Ok(());
2922 };
2923 let Some(dst) = self.ids.get(dst_key) else {
2924 return Ok(());
2925 };
2926 let Some(etype) = self.syms.get(edge_type) else {
2927 return Ok(());
2928 };
2929 // I3: phantom-tombstone guard. When a V8 base is present, a
2930 // DeleteEdge WAL record for an edge that was already absorbed into
2931 // the new base (i.e. neither in overlay nor in base) must be skipped.
2932 // Without this guard, remove_edge records a tombstone for an edge
2933 // that no longer exists, incorrectly understating edge_count.
2934 if self.base.is_some()
2935 && !self
2936 .topo_view()
2937 .neighbors(etype, core_storage::topology::Direction::Out, src)
2938 .contains(&dst)
2939 {
2940 return Ok(());
2941 }
2942 self.topo.remove_edge(etype, src, dst);
2943 self.edge_props.remove_edge(etype, src, dst);
2944 // View maintenance for manual edge delete (topo already updated above).
2945 self.view_store.on_edge_changed(
2946 etype,
2947 src,
2948 dst,
2949 false,
2950 &mut self.props,
2951 &build_topo_view(&self.topo, &self.base),
2952 &self.ids,
2953 &self.syms,
2954 &self.labels,
2955 self.base.as_ref().map(|b| {
2956 b.columns()
2957 .expect("base columns section bounds validated at open")
2958 }),
2959 );
2960 // Rule engine: via-hop rules must retract when user via-edges are deleted.
2961 let cursor = self.engine.pending_delta_count();
2962 let mut eng = std::mem::take(&mut self.engine);
2963 {
2964 let mut gm = make_graph_mut(
2965 &self.ids,
2966 &mut self.syms,
2967 &self.labels,
2968 build_props_view(&self.props, &self.base),
2969 &mut self.topo,
2970 &mut self.edge_props,
2971 );
2972 eng.on_edge_changed(edge_type, src, dst, &mut gm);
2973 }
2974 self.engine = eng;
2975 if !self.view_store.is_empty() {
2976 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2977 for d in &new_deltas {
2978 self.view_store.on_edge_changed(
2979 d.etype_sym,
2980 d.src_id,
2981 d.dst_id,
2982 d.fired,
2983 &mut self.props,
2984 &build_topo_view(&self.topo, &self.base),
2985 &self.ids,
2986 &self.syms,
2987 &self.labels,
2988 self.base.as_ref().map(|b| {
2989 b.columns()
2990 .expect("base columns section bounds validated at open")
2991 }),
2992 );
2993 }
2994 }
2995 }
2996 WalRecord::DeleteNode { key } => {
2997 // Recovery-safe: already-tombstoned / unknown key is a clean
2998 // no-op. Crash-window replay over a snapshot that already
2999 // applied this record cannot recover the retired id from the
3000 // key (`IdMap::get` is None), so every subsequent step is
3001 // skipped. Each step is independently idempotent if invoked
3002 // twice on a still-live id: retraction is a no-op on empty
3003 // provenance, `remove_edge` returns false, `remove_all` is a
3004 // no-op, `ids.delete` returns None, label sentinel is sticky.
3005 let Some(n) = self.ids.get(key) else {
3006 return Ok(());
3007 };
3008
3009 // (1) Retract derived edges + de-index while props/labels live.
3010 let cursor = self.engine.pending_delta_count();
3011 let mut eng = std::mem::take(&mut self.engine);
3012 {
3013 let mut gm = make_graph_mut(
3014 &self.ids,
3015 &mut self.syms,
3016 &self.labels,
3017 build_props_view(&self.props, &self.base),
3018 &mut self.topo,
3019 &mut self.edge_props,
3020 );
3021 eng.on_node_removed(n, &mut gm);
3022 }
3023 self.engine = eng;
3024 // Derived-edge retractions → view updates for neighbors.
3025 if !self.view_store.is_empty() {
3026 #[cfg(test)]
3027 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3028 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3029 for d in &new_deltas {
3030 self.view_store.on_edge_changed(
3031 d.etype_sym,
3032 d.src_id,
3033 d.dst_id,
3034 d.fired,
3035 &mut self.props,
3036 &build_topo_view(&self.topo, &self.base),
3037 &self.ids,
3038 &self.syms,
3039 &self.labels,
3040 self.base.as_ref().map(|b| {
3041 b.columns()
3042 .expect("base columns section bounds validated at open")
3043 }),
3044 );
3045 }
3046 }
3047
3048 // (2) Sweep ALL remaining edges incident to n, both directions,
3049 // every etype. This cascade is intentionally mask-independent:
3050 // topology integrity requires removing every edge touching the
3051 // deleted node regardless of the caller's visibility scope.
3052 // (The mask limits which nodes a role's read phase can return;
3053 // the WAL delete always executes with full storage authority.)
3054 // Collect then remove so neighbor slices stay valid during
3055 // iteration. Remove from topo first, then call view maintenance
3056 // so Avg/Min/Max recompute sees the correct (reduced) neighbor set.
3057 let etypes: Vec<u32> = self.topo.etypes().collect();
3058 let mut doomed = Vec::new();
3059 for et in &etypes {
3060 for &dst in self.topo.neighbors(*et, Direction::Out, n).as_ref() {
3061 doomed.push((*et, n, dst));
3062 }
3063 for &src in self.topo.neighbors(*et, Direction::In, n).as_ref() {
3064 doomed.push((*et, src, n));
3065 }
3066 }
3067 for (et, s, d) in doomed {
3068 self.topo.remove_edge(et, s, d);
3069 self.edge_props.remove_edge(et, s, d);
3070 // View maintenance: n's own view values will be cleared by
3071 // remove_all below; only update surviving neighbors.
3072 self.view_store.on_edge_changed(
3073 et,
3074 s,
3075 d,
3076 false,
3077 &mut self.props,
3078 &build_topo_view(&self.topo, &self.base),
3079 &self.ids,
3080 &self.syms,
3081 &self.labels,
3082 self.base.as_ref().map(|b| {
3083 b.columns()
3084 .expect("base columns section bounds validated at open")
3085 }),
3086 );
3087 }
3088
3089 // (3) Drop every remaining prop (`ColumnStore::remove_all`).
3090 self.props.remove_all(n);
3091 // Full-text index maintenance: remove all tokens for this node.
3092 self.fulltext.remove_node(n);
3093 // Property (equality) index maintenance: drop all entries for n.
3094 self.prop_index.remove_node_all(n);
3095
3096 // (4) Retire the dense id and stamp the label sentinel.
3097 self.ids.delete(key);
3098 if let Some(slot) = self.labels.get_mut(n as usize) {
3099 *slot = u32::MAX;
3100 }
3101 }
3102 WalRecord::Batch(inner) => {
3103 // Apply each inner record in order through the same apply path.
3104 // Inner records are validated free of nested Batch by encode_record.
3105 for rec in inner {
3106 self.apply(rec)?;
3107 }
3108 }
3109 WalRecord::RebuildRule { name } => {
3110 // Replay-over-snapshot idempotency: the snapshot may already
3111 // reflect a later delete_rule, so the rule is absent; skip.
3112 if !self.engine.rules().any(|r| r.name == *name) {
3113 return Ok(());
3114 }
3115 let cursor = self.engine.pending_delta_count();
3116 let mut eng = std::mem::take(&mut self.engine);
3117 let result = {
3118 let mut gm = make_graph_mut(
3119 &self.ids,
3120 &mut self.syms,
3121 &self.labels,
3122 build_props_view(&self.props, &self.base),
3123 &mut self.topo,
3124 &mut self.edge_props,
3125 );
3126 eng.rebuild(name, &mut gm)
3127 };
3128 self.engine = eng;
3129 result.map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
3130 // Derived-edge delta changes → view updates.
3131 if !self.view_store.is_empty() {
3132 #[cfg(test)]
3133 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3134 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3135 for d in &new_deltas {
3136 self.view_store.on_edge_changed(
3137 d.etype_sym,
3138 d.src_id,
3139 d.dst_id,
3140 d.fired,
3141 &mut self.props,
3142 &build_topo_view(&self.topo, &self.base),
3143 &self.ids,
3144 &self.syms,
3145 &self.labels,
3146 self.base.as_ref().map(|b| {
3147 b.columns()
3148 .expect("base columns section bounds validated at open")
3149 }),
3150 );
3151 }
3152 }
3153 }
3154 WalRecord::CreateView { def_bytes } => {
3155 let def: ViewDef =
3156 bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
3157 detail: format!("CreateView def_bytes deserialize failed: {e}"),
3158 })?;
3159 // Replay-over-snapshot idempotency: view already present → skip.
3160 if self.view_store.has_view(&def.name) {
3161 return Ok(());
3162 }
3163 self.view_store
3164 .create_view(
3165 def,
3166 &mut self.props,
3167 &build_topo_view(&self.topo, &self.base),
3168 &self.ids,
3169 &self.syms,
3170 &self.labels,
3171 )
3172 .map_err(|e| GraphError::RuleInvalid { detail: e })?;
3173 }
3174 WalRecord::DeleteView { name } => {
3175 // Replay-over-snapshot idempotency: view already absent → skip.
3176 if !self.view_store.has_view(name) {
3177 return Ok(());
3178 }
3179 self.view_store
3180 .delete_view(name, &mut self.props, &self.ids, &self.labels, &self.syms)
3181 .map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
3182 }
3183 WalRecord::EnableFulltext { label, field } => {
3184 // Replay-over-snapshot idempotency: already enabled → skip.
3185 if self.fulltext.is_enabled(label, field) {
3186 return Ok(());
3187 }
3188 self.fulltext.enable(label, field);
3189 // Backfill: index all live nodes of this label that have the field.
3190 let n = self.ids.len() as u32;
3191 for id in 0..n {
3192 let Some(&sym) = self.labels.get(id as usize) else {
3193 continue;
3194 };
3195 if sym == u32::MAX {
3196 continue; // tombstoned
3197 }
3198 let Some(lbl) = self.syms.resolve(sym) else {
3199 continue;
3200 };
3201 if lbl != label {
3202 continue;
3203 }
3204 if let Some(value) = build_props_view(&self.props, &self.base)
3205 .get(id, field)
3206 .map(|vr| vr.into_value())
3207 {
3208 self.fulltext.add_tokens(id, field, &value);
3209 }
3210 }
3211 }
3212 WalRecord::DisableFulltext { label, field } => {
3213 // Replay-over-snapshot idempotency: already disabled → skip.
3214 if !self.fulltext.is_enabled(label, field) {
3215 return Ok(());
3216 }
3217 // If another label still indexes this field, the postings column
3218 // is kept — but it must not contain node_ids from the now-disabled
3219 // label. Remove them before calling disable() so the field_indexed
3220 // guard inside disable() sees the correct post-removal state.
3221 if self.fulltext.field_indexed_by_other(label, field) {
3222 if let Some(label_sym) = self.syms.get(label) {
3223 for (node_id, &lsym) in self.labels.iter().enumerate() {
3224 if lsym == label_sym {
3225 self.fulltext.remove_node_field(node_id as u32, field);
3226 }
3227 }
3228 }
3229 }
3230 self.fulltext.disable(label, field);
3231 }
3232 WalRecord::EnableIndex { label, field } => {
3233 // Replay-over-snapshot idempotency: already enabled → skip.
3234 if self.prop_index.is_enabled(label, field) {
3235 return Ok(());
3236 }
3237 self.prop_index.enable(label, field);
3238 // Backfill: index all live nodes of this label that have the field.
3239 let n = self.ids.len() as u32;
3240 for id in 0..n {
3241 let Some(&sym) = self.labels.get(id as usize) else {
3242 continue;
3243 };
3244 if sym == u32::MAX {
3245 continue; // tombstoned
3246 }
3247 let Some(lbl) = self.syms.resolve(sym) else {
3248 continue;
3249 };
3250 if lbl != label {
3251 continue;
3252 }
3253 if let Some(value) = build_props_view(&self.props, &self.base)
3254 .get(id, field)
3255 .map(|vr| vr.into_value())
3256 {
3257 self.prop_index.set(label, field, id, &value);
3258 }
3259 }
3260 }
3261 WalRecord::DisableIndex { label, field } => {
3262 self.prop_index.disable(label, field);
3263 }
3264 // History markers carry no replay state — rules re-derive edges
3265 // deterministically on open/replay. Skip unconditionally.
3266 WalRecord::DerivedEdgeAdded { .. } | WalRecord::DerivedEdgeRetracted { .. } => {}
3267 // ── rename_node ──────────────────────────────────────────────────
3268 WalRecord::RenameNode { old_key, new_key } => {
3269 // Recovery-safe: if old_key is already gone (key was renamed
3270 // by a snapshot or a prior replay frame), skip cleanly.
3271 if self.ids.get(old_key).is_none() {
3272 return Ok(());
3273 }
3274 // The rename only updates the key-table; the dense id, all
3275 // topo edges, props, labels, and rule state are id-indexed and
3276 // require no change.
3277 self.ids
3278 .rename(old_key, new_key)
3279 .map_err(|e| GraphError::Corrupt {
3280 detail: format!("wal replay RenameNode {old_key}→{new_key}: {e}"),
3281 })?;
3282 }
3283 }
3284 Ok(())
3285 }
3286
3287 /// Intern `s` in `syms` and emit a WAL `Intern` record so `*Id` records
3288 /// replay on WAL-only `open_at` (no snapshot intern table). Apply is
3289 /// idempotent when the string is already bound. Always emit: after
3290 /// `snapshot()` the WAL is truncated and live intern is not on disk.
3291 fn intern_wal(&mut self, s: &str) -> (u32, WalRecord) {
3292 let id = if let Some(id) = self.syms.get(s) {
3293 id
3294 } else {
3295 self.syms.intern(s)
3296 };
3297 (
3298 id,
3299 WalRecord::Intern {
3300 id,
3301 text: s.to_string(),
3302 },
3303 )
3304 }
3305
3306 /// Rewrite user-facing records into dense-id records. On `Err`, no live
3307 /// state is left mutated: speculative interns made while building the
3308 /// output are rolled back, so a later successful mutation cannot log an
3309 /// `Intern` record whose id replay would never reproduce.
3310 fn rewrite_wal_dense(&mut self, recs: Vec<WalRecord>) -> Result<Vec<WalRecord>> {
3311 let syms_checkpoint = self.syms.len();
3312 let result = self.rewrite_wal_dense_inner(recs);
3313 if result.is_err() {
3314 self.syms.truncate(syms_checkpoint);
3315 }
3316 result
3317 }
3318
3319 fn rewrite_wal_dense_inner(&mut self, recs: Vec<WalRecord>) -> Result<Vec<WalRecord>> {
3320 let mut out = Vec::with_capacity(recs.len());
3321 // Node ids allocated by later apply(InsertNodeId) in this same batch.
3322 let mut pending: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
3323 let mut interned = std::collections::HashSet::<u32>::new();
3324 let mut next = u32::try_from(self.ids.len()).map_err(|_| GraphError::Corrupt {
3325 detail: "id space exhausted".into(),
3326 })?;
3327 let lookup = |ids: &IdMap,
3328 pending: &std::collections::HashMap<String, u32>,
3329 key: &str|
3330 -> Option<u32> { ids.get(key).or_else(|| pending.get(key).copied()) };
3331 for rec in recs {
3332 match rec {
3333 WalRecord::InsertNode { label, key, props } => {
3334 let (label_id, intern) = self.intern_wal(&label);
3335 if interned.insert(label_id) {
3336 out.push(intern);
3337 }
3338 let mut props_id = Vec::with_capacity(props.len());
3339 for (field, value) in props {
3340 let (field_id, intern) = self.intern_wal(&field);
3341 if interned.insert(field_id) {
3342 out.push(intern);
3343 }
3344 props_id.push((field_id, value));
3345 }
3346 if lookup(&self.ids, &pending, &key).is_none() {
3347 pending.insert(key.clone(), next);
3348 next = next.checked_add(1).ok_or_else(|| GraphError::Corrupt {
3349 detail: "id space exhausted".into(),
3350 })?;
3351 }
3352 out.push(WalRecord::InsertNodeId {
3353 label: label_id,
3354 key,
3355 props: props_id,
3356 });
3357 }
3358 WalRecord::SetProp { key, field, value } => {
3359 let id =
3360 lookup(&self.ids, &pending, &key).ok_or_else(|| GraphError::Corrupt {
3361 detail: format!("dense WAL rewrite missing key {key}"),
3362 })?;
3363 let (field_id, intern) = self.intern_wal(&field);
3364 if interned.insert(field_id) {
3365 out.push(intern);
3366 }
3367 out.push(WalRecord::SetPropId {
3368 id,
3369 field: field_id,
3370 value,
3371 });
3372 }
3373 WalRecord::InsertEdge {
3374 edge_type,
3375 src_key,
3376 dst_key,
3377 } => {
3378 let (etype, intern) = self.intern_wal(&edge_type);
3379 if interned.insert(etype) {
3380 out.push(intern);
3381 }
3382 let src = lookup(&self.ids, &pending, &src_key).ok_or_else(|| {
3383 GraphError::Corrupt {
3384 detail: format!("dense WAL rewrite missing src {src_key}"),
3385 }
3386 })?;
3387 let dst = lookup(&self.ids, &pending, &dst_key).ok_or_else(|| {
3388 GraphError::Corrupt {
3389 detail: format!("dense WAL rewrite missing dst {dst_key}"),
3390 }
3391 })?;
3392 out.push(WalRecord::InsertEdgeId { etype, src, dst });
3393 }
3394 WalRecord::RenameNode {
3395 ref old_key,
3396 ref new_key,
3397 } => {
3398 // Track the rename in `pending` so subsequent InsertEdge /
3399 // SetProp records in this batch can resolve the new key.
3400 let id = lookup(&self.ids, &pending, old_key).ok_or_else(|| {
3401 GraphError::Corrupt {
3402 detail: format!(
3403 "dense WAL rewrite: RenameNode old key {old_key} not found"
3404 ),
3405 }
3406 })?;
3407 pending.remove(old_key.as_str());
3408 pending.insert(new_key.clone(), id);
3409 out.push(rec);
3410 }
3411 other => out.push(other),
3412 }
3413 }
3414 Ok(out)
3415 }
3416
3417 fn log_dense(&mut self, recs: Vec<WalRecord>) -> Result<()> {
3418 let recs = self.rewrite_wal_dense(recs)?;
3419 match recs.len() {
3420 0 => Ok(()),
3421 1 => self.log_then_apply(recs.into_iter().next().unwrap()),
3422 _ => self.log_then_apply(WalRecord::Batch(recs)),
3423 }
3424 }
3425
3426 /// Durable write, then notify the event sink. Replay (`apply` during
3427 /// `open`) never enters this function, so it is the replay-silent seam.
3428 fn log_then_apply(&mut self, rec: WalRecord) -> Result<()> {
3429 self.log_then_apply_with(rec, None, self.fsync)
3430 }
3431
3432 /// Whether this frame must fsync under `policy`.
3433 ///
3434 /// Batched contract: user-visible batches (>1 mutation) fsync; single
3435 /// mutations do not. The dense rewrite wraps a single mutation in a
3436 /// `Batch([Intern.., <one *Id record>])`, so `Intern` records are excluded
3437 /// from the count — removing that filter would make every single-op write
3438 /// fsync under Batched (or, if the threshold were raised instead, skip a
3439 /// needed fsync for real two-op batches).
3440 fn wal_needs_sync(policy: FsyncPolicy, rec: &WalRecord) -> bool {
3441 match policy {
3442 FsyncPolicy::Relaxed => false,
3443 FsyncPolicy::Strict => true,
3444 FsyncPolicy::Batched => match rec {
3445 // Intern + one mutation is the single-op rewrite, not a user batch.
3446 WalRecord::Batch(inner) => {
3447 inner
3448 .iter()
3449 .filter(|r| !matches!(r, WalRecord::Intern { .. }))
3450 .count()
3451 > 1
3452 }
3453 _ => false,
3454 },
3455 }
3456 }
3457
3458 /// # Apply-infallibility invariant (load-bearing)
3459 ///
3460 /// The ordering is: WAL append → fsync → apply. If `apply` returned `Err`
3461 /// for a `Batch` frame after a successful WAL write, the WAL would contain
3462 /// the full frame while in-memory state would reflect only the ops before
3463 /// the failure. On reopen, WAL replay would then apply the entire batch —
3464 /// diverging permanently from what the pre-crash process had in memory.
3465 ///
3466 /// For `Batch` frames this situation cannot arise because:
3467 /// - All validation runs via `commit_logged_batch`/`MutPreview` **before**
3468 /// the WAL write. `MutPreview` uses the same `&mut self` that apply will
3469 /// use, with no concurrent mutation between validation exit and apply entry.
3470 /// - Every `apply` arm for a validated op is either infallible by construction
3471 /// (`InsertNode`, `RemoveProp`, `DeleteEdge`, `DeleteNode`), has idempotency
3472 /// guards that return `Ok(())` (`CreateRule`, `DeleteRule`), or is
3473 /// guaranteed-present by validation (`InsertEdge`/`SetProp` key lookups).
3474 /// - `on_node_changed` and `on_node_removed` return `()` — never `Err`.
3475 ///
3476 /// A `debug_assert!` below fires in debug builds if `apply` ever returns
3477 /// `Err` for a `Batch` frame, making any future regression immediately visible
3478 /// in tests rather than silently diverging crash-recovery behaviour.
3479 fn log_then_apply_with(
3480 &mut self,
3481 rec: WalRecord,
3482 ingest: Option<(String, usize)>,
3483 policy: FsyncPolicy,
3484 ) -> Result<()> {
3485 // Read-only guard: as-of instances must never write the WAL.
3486 if self.read_only {
3487 return Err(GraphError::ReadOnly);
3488 }
3489 // Degraded guard: fsync failure left WAL truncated; in-memory state
3490 // is ahead of the on-disk WAL, so further mutations would deepen the
3491 // divergence. Reopen the database to recover.
3492 if self.degraded {
3493 return Err(GraphError::Io(std::io::Error::other(
3494 "database degraded after group-commit fsync failure; reopen required",
3495 )));
3496 }
3497 // Ensure retained provenance bytes are decoded into the live mutable
3498 // fields before any mutation touches self.engine.provenance. This is a
3499 // no-op if provenance was never stored (fresh store) or has already been
3500 // consumed (subsequent mutations). WAL replay calls apply() directly
3501 // and is covered by consume_retained_state_eager before replay.
3502 self.ensure_v8_base_sections_loaded();
3503 self.engine.ensure_provenance_loaded_mut();
3504 // Invariant (I-1): no stale deltas may enter from a previous apply.
3505 // If any engine method ever accumulates deltas before erroring, they would
3506 // contaminate the *next* commit's event stream. This assert fires in debug
3507 // builds, making any future regression visible at the earliest point.
3508 debug_assert_eq!(
3509 self.engine.pending_delta_count(),
3510 0,
3511 "stale engine deltas at log_then_apply_with entry — \
3512 a previous apply arm may have accumulated deltas before erroring; \
3513 the caller must drain_deltas() on any error path before returning"
3514 );
3515 self.fs.append(FileId::Wal, &encode_record(&rec))?;
3516 if Self::wal_needs_sync(policy, &rec) {
3517 self.fs.sync(FileId::Wal)?;
3518 }
3519 // Marker writing always needs the engine deltas, but the engine only
3520 // accumulates them when emit_deltas is true (normally gated on subscribers
3521 // or views being present). Enable emission for this apply if it is
3522 // currently off, then restore the original state unconditionally via an
3523 // RAII guard — this prevents a panic in apply() from leaking the flag.
3524 struct RestoreEmitDeltas(*mut RuleEngine, bool);
3525 impl Drop for RestoreEmitDeltas {
3526 fn drop(&mut self) {
3527 // SAFETY: pointer into self (GraphDb); guard is dropped within
3528 // this frame before log_then_apply_with returns.
3529 unsafe { (*self.0).set_emit_deltas(self.1) };
3530 }
3531 }
3532 let original_emit = self.engine.emit_deltas();
3533 if !original_emit {
3534 self.engine.set_emit_deltas(true);
3535 }
3536 // SAFETY: raw pointer into self; guard dropped within this frame.
3537 let _emit_guard = RestoreEmitDeltas(&mut self.engine as *mut _, original_emit);
3538
3539 let apply_result = self.apply(&rec);
3540 // For Batch frames, post-validation apply must be infallible (see above).
3541 // A debug_assert here catches any future change that makes apply fallible
3542 // before the caller notices via silent WAL/memory divergence.
3543 if matches!(&rec, WalRecord::Batch(_)) {
3544 debug_assert!(
3545 apply_result.is_ok(),
3546 "Batch apply returned Err after successful WAL write — \
3547 the validate-then-apply invariant has been violated; \
3548 see log_then_apply_with invariant doc"
3549 );
3550 }
3551 if apply_result.is_err() {
3552 // Discard any partial deltas accumulated by the failed apply.
3553 // They must not ride the next commit's event stream (I-1).
3554 // _emit_guard restores emit_deltas on drop automatically.
3555 let _ = self.engine.drain_deltas();
3556 let _ = self.engine.take_rebuild_needed();
3557 apply_result?;
3558 }
3559 self.commit_seq += 1;
3560 let seq = self.commit_seq;
3561 // Update per-node last-change map for the committed record.
3562 // Must happen after commit_seq is incremented so the seq is correct.
3563 self.update_last_change_from_rec(&rec, seq);
3564 // Drain engine deltas and distribute to subscribers before the existing
3565 // MutationEvent sink fires — both happen post-fsync, post-apply.
3566 // _emit_guard restores emit_deltas after this line when it drops.
3567 let engine_deltas = self.engine.drain_deltas();
3568
3569 // Append history-marker WAL records for any derived-edge changes so
3570 // that `edge_history` and `was_linked` can surface rule-attributed
3571 // events. Markers are STATE NO-OPS during replay; they are written
3572 // without an additional fsync (the triggering commit's sync already
3573 // happened; the next commit's sync covers these lazily).
3574 if !engine_deltas.is_empty() {
3575 let markers: Vec<WalRecord> = engine_deltas
3576 .iter()
3577 .map(|d| {
3578 if d.fired {
3579 WalRecord::DerivedEdgeAdded {
3580 rule: d.rule.clone(),
3581 edge_type: d.edge_type.clone(),
3582 src_key: d.src_key.clone(),
3583 dst_key: d.dst_key.clone(),
3584 }
3585 } else {
3586 WalRecord::DerivedEdgeRetracted {
3587 rule: d.rule.clone(),
3588 edge_type: d.edge_type.clone(),
3589 src_key: d.src_key.clone(),
3590 dst_key: d.dst_key.clone(),
3591 }
3592 }
3593 })
3594 .collect();
3595 let marker_frame = if markers.len() == 1 {
3596 markers.into_iter().next().unwrap()
3597 } else {
3598 WalRecord::Batch(markers)
3599 };
3600 // Ignore append errors: markers are best-effort history
3601 // annotations. Losing them does not affect state correctness.
3602 let _ = self.fs.append(FileId::Wal, &encode_record(&marker_frame));
3603 }
3604
3605 // Record MVCC CommitDelta for the epoch reader. The WAL record is
3606 // stored as-is (including any nested Batch / Intern records); the
3607 // ReaderSnapshot's apply_one function handles all variants.
3608 {
3609 let derived_inserts = engine_deltas
3610 .iter()
3611 .filter(|d| d.fired)
3612 .map(|d| (d.etype_sym, d.src_id, d.dst_id))
3613 .collect();
3614 let derived_deletes = engine_deltas
3615 .iter()
3616 .filter(|d| !d.fired)
3617 .map(|d| (d.etype_sym, d.src_id, d.dst_id))
3618 .collect();
3619 let delta = Arc::new(crate::reader::CommitDelta {
3620 records: vec![rec.clone()],
3621 derived_inserts,
3622 derived_deletes,
3623 });
3624 self.delta_tail.push(delta);
3625 self.commits_since_fold += 1;
3626 if self.commits_since_fold >= crate::reader::FOLD_EVERY_K {
3627 self.fold_now();
3628 }
3629 }
3630
3631 if self.defer_events {
3632 // Group-commit drain thread: hold events until after the group
3633 // fsync so subscribers only observe durable data (R2).
3634 self.deferred_events.push(DeferredEvent {
3635 rec: rec.clone(),
3636 engine_deltas,
3637 seq,
3638 ingest,
3639 });
3640 } else {
3641 self.distribute_events(&rec, &engine_deltas, seq);
3642 self.emit_committed(&rec, ingest);
3643 }
3644 // Drift is only known after apply, so auto-rebuild cannot join the
3645 // triggering op's WAL frame. Issue RebuildRule as a second commit.
3646 // Skip when `rec` is itself RebuildRule: rebuild resets drift, so a
3647 // retrigger loop is impossible if the fit succeeded, but we still
3648 // drain the flag so a leftover cannot re-enter.
3649 let rebuilds = self.engine.take_rebuild_needed();
3650 if !matches!(&rec, WalRecord::RebuildRule { .. }) {
3651 let mut failed = Vec::new();
3652 for name in rebuilds {
3653 if self.engine.rules().any(|r| r.name == name) {
3654 // User op is already durable. A failed second commit must
3655 // not surface as the caller's error.
3656 if let Err(e) =
3657 self.log_then_apply(WalRecord::RebuildRule { name: name.clone() })
3658 {
3659 eprintln!(
3660 "auto-rebuild of rule {name:?} failed after durable user commit: {e}"
3661 );
3662 failed.push(name);
3663 }
3664 }
3665 }
3666 for name in failed {
3667 self.engine.queue_rebuild_needed(name);
3668 }
3669 }
3670 Ok(())
3671 }
3672
3673 /// Install a post-commit hook. Replaces any previous sink.
3674 ///
3675 /// The sink runs inside `log_then_apply` after a successful
3676 /// durable commit, while the caller still holds `&mut self`. When this
3677 /// database is behind a [`crate::SharedDb`], that means the **write
3678 /// guard is held**. The sink must never call `read` / `write` (or any
3679 /// other method) on the same `SharedDb` — the `RwLock` is not
3680 /// re-entrant and doing so deadlocks. The sink is `Send + Sync`;
3681 /// `std::sync::mpsc::Sender` is not `Sync` and will not type-check.
3682 /// Intended examples: `std::sync::mpsc::SyncSender`,
3683 /// `tokio::sync::mpsc::Sender`, `tokio::sync::broadcast::Sender`
3684 /// (non-blocking `send`), or `Arc<Mutex<Vec<MutationEvent>>>`.
3685 pub fn set_event_sink(&mut self, sink: Box<dyn Fn(MutationEvent) + Send + Sync>) {
3686 self.event_sink = Some(sink);
3687 }
3688
3689 /// Whether a post-commit event sink is currently installed.
3690 pub fn has_event_sink(&self) -> bool {
3691 self.event_sink.is_some()
3692 }
3693
3694 /// Set WAL fsync cadence. Default [`FsyncPolicy::Strict`].
3695 pub fn set_fsync_policy(&mut self, p: FsyncPolicy) {
3696 self.fsync = p;
3697 }
3698
3699 /// Return the current WAL fsync cadence.
3700 pub fn fsync_policy(&self) -> FsyncPolicy {
3701 self.fsync
3702 }
3703
3704 // ── Group-commit event deferral ───────────────────────────────────────────
3705
3706 /// Enable or disable deferred event mode.
3707 ///
3708 /// When `true`, event notifications (subscription `DbEvent`s and legacy
3709 /// `MutationEvent` sink calls) are buffered rather than fired immediately.
3710 /// Call [`flush_deferred_events`] after the group fsync to deliver them,
3711 /// or [`discard_deferred_events`] if the fsync failed and the group must
3712 /// be treated as lost.
3713 pub fn set_deferred_events_mode(&mut self, defer: bool) {
3714 self.defer_events = defer;
3715 }
3716
3717 /// Fire all buffered events accumulated since [`set_deferred_events_mode`]
3718 /// was set to true. Clears the buffer.
3719 ///
3720 /// Called by the drain thread AFTER a successful group fsync, so
3721 /// subscribers observe only data that is durably on disk.
3722 pub fn flush_deferred_events(&mut self) {
3723 let events = std::mem::take(&mut self.deferred_events);
3724 for de in events {
3725 self.distribute_events(&de.rec, &de.engine_deltas, de.seq);
3726 self.emit_committed(&de.rec, de.ingest);
3727 }
3728 }
3729
3730 /// Discard all buffered events without firing them.
3731 ///
3732 /// Called by the drain thread when a group fsync fails: the WAL has been
3733 /// truncated back to the pre-group offset, so the committed-but-unsynced
3734 /// ops must not be observable to subscribers.
3735 pub fn discard_deferred_events(&mut self) {
3736 self.deferred_events.clear();
3737 }
3738
3739 // ── Degraded state ────────────────────────────────────────────────────────
3740
3741 /// Mark this database as degraded.
3742 ///
3743 /// Called by the group-commit drain thread after a group fsync failure and
3744 /// WAL truncation: the in-memory state is now ahead of the on-disk WAL, so
3745 /// further mutations would deepen the divergence. All subsequent calls to
3746 /// [`log_then_apply_with`] return `Err` until the database is reopened.
3747 pub fn set_degraded(&mut self) {
3748 self.degraded = true;
3749 }
3750
3751 fn emit(&self, ev: MutationEvent) {
3752 if let Some(sink) = &self.event_sink {
3753 sink(ev);
3754 }
3755 }
3756
3757 fn emit_committed(&self, rec: &WalRecord, ingest: Option<(String, usize)>) {
3758 match rec {
3759 WalRecord::Batch(inner) => {
3760 for r in inner {
3761 if let Some(ev) = event_from_record(r, &self.syms, &self.ids) {
3762 self.emit(ev);
3763 }
3764 }
3765 match ingest {
3766 Some((label, inserted)) => {
3767 self.emit(MutationEvent::Ingested { label, inserted })
3768 }
3769 None => {
3770 let ops = inner
3771 .iter()
3772 .filter(|r| !matches!(r, WalRecord::Intern { .. }))
3773 .count();
3774 if ops > 1 {
3775 self.emit(MutationEvent::BatchApplied { ops });
3776 }
3777 }
3778 }
3779 }
3780 other => {
3781 if let Some(ev) = event_from_record(other, &self.syms, &self.ids) {
3782 self.emit(ev);
3783 }
3784 }
3785 }
3786 }
3787
3788 // -----------------------------------------------------------------------
3789 // Subscription API
3790 // -----------------------------------------------------------------------
3791
3792 /// Distribute post-commit events to all live subscribers.
3793 ///
3794 /// Build a row-key → row-data map from a [`ResultSet`].
3795 ///
3796 /// Each row is serialized to JSON to form its key; a debug fallback is used
3797 /// if serialization fails. Used by both the initial-seed path in
3798 /// [`Self::subscribe_query`] and the per-commit diff path in
3799 /// [`Self::distribute_events`] to keep the two in sync.
3800 fn result_to_row_map(
3801 result: &core_query::ResultSet,
3802 ) -> std::collections::HashMap<String, Vec<Option<Value>>> {
3803 (0..result.len())
3804 .map(|i| {
3805 let row = result.row(i).to_vec();
3806 let key = serde_json::to_string(&row).unwrap_or_else(|_| format!("{row:?}"));
3807 (key, row)
3808 })
3809 .collect()
3810 }
3811
3812 /// Collect the set of label syms touched by a WAL record.
3813 ///
3814 /// Returns `Some(set)` when every record in this commit can be attributed to
3815 /// a known label sym. Returns `None` when the commit must not be skipped:
3816 /// edge records, unresolvable key→label lookups, or any record type not in
3817 /// the explicit handled set.
3818 ///
3819 /// Handled record types and their actions:
3820 /// - `InsertNode` → look up label in interner (fails → None)
3821 /// - `InsertNodeId` → label sym is carried directly
3822 /// - `SetProp` → resolve key→id→label (fails → None)
3823 /// - `DeleteNode` → resolve key→id→label (fails → None)
3824 /// - `Batch` → recurse into every inner record
3825 /// - `InsertEdge`, `DeleteEdge`, `InsertEdgeId` → always None (edge records)
3826 /// - everything else → None (conservative)
3827 fn commit_touched_labels(
3828 rec: &WalRecord,
3829 syms: &Interner,
3830 ids: &IdMap,
3831 labels: &[u32],
3832 ) -> Option<BTreeSet<u32>> {
3833 let mut out = BTreeSet::new();
3834 if Self::collect_touched_labels(rec, syms, ids, labels, &mut out) {
3835 Some(out)
3836 } else {
3837 None
3838 }
3839 }
3840
3841 fn collect_touched_labels(
3842 rec: &WalRecord,
3843 syms: &Interner,
3844 ids: &IdMap,
3845 labels: &[u32],
3846 out: &mut BTreeSet<u32>,
3847 ) -> bool {
3848 match rec {
3849 // String-key insert: the dense rewrite converts this to
3850 // [Intern, InsertNodeId], so this arm fires only for legacy WAL
3851 // records written before the dense path was added.
3852 WalRecord::InsertNode { label, .. } => {
3853 if let Some(sym) = syms.get(label) {
3854 out.insert(sym);
3855 true
3856 } else {
3857 false
3858 }
3859 }
3860 // Dense-id insert (produced by rewrite_wal_dense for every
3861 // insert_node call in the current codebase).
3862 WalRecord::InsertNodeId { label, .. } => {
3863 out.insert(*label);
3864 true
3865 }
3866 // String-key prop set: dense path converts to [Intern, SetPropId].
3867 WalRecord::SetProp { key, .. } => {
3868 if let Some(sym) = Self::resolve_key_label_sym(key, ids, labels) {
3869 out.insert(sym);
3870 true
3871 } else {
3872 false
3873 }
3874 }
3875 // Dense-id prop set (produced by rewrite_wal_dense for set_prop).
3876 WalRecord::SetPropId { id, .. } => {
3877 if let Some(sym) = labels.get(*id as usize).copied().filter(|&s| s != u32::MAX) {
3878 out.insert(sym);
3879 true
3880 } else {
3881 false
3882 }
3883 }
3884 WalRecord::DeleteNode { key } => {
3885 if let Some(sym) = Self::resolve_key_label_sym(key, ids, labels) {
3886 out.insert(sym);
3887 true
3888 } else {
3889 false
3890 }
3891 }
3892 WalRecord::Batch(inner) => inner
3893 .iter()
3894 .all(|r| Self::collect_touched_labels(r, syms, ids, labels, out)),
3895 // Intern is a pure metadata record — it does not touch any node's
3896 // label and is safe to skip for the label-skip predicate.
3897 WalRecord::Intern { .. } => true,
3898 // Edge records: always re-execute (edges can change join results).
3899 WalRecord::InsertEdge { .. }
3900 | WalRecord::DeleteEdge { .. }
3901 | WalRecord::InsertEdgeId { .. } => false,
3902 _ => false,
3903 }
3904 }
3905
3906 /// Resolve a node key to its label sym via the dense id table.
3907 /// Returns `None` if the key is unknown or the label is a tombstone sentinel.
3908 fn resolve_key_label_sym(key: &str, ids: &IdMap, labels: &[u32]) -> Option<u32> {
3909 let id = ids.get(key)?;
3910 let sym = labels.get(id as usize).copied()?;
3911 (sym != u32::MAX).then_some(sym)
3912 }
3913
3914 /// Distribute post-commit events to all live subscribers.
3915 ///
3916 /// Called from `log_then_apply_with` after apply + fsync, before the
3917 /// legacy MutationEvent sink. Prunes dead `Weak` entries in-place.
3918 ///
3919 /// Query subscriptions (subscribe_query) re-execute their plan on every
3920 /// call and diff the result against the previous run. Zero overhead when
3921 /// no query subscriptions are active.
3922 fn distribute_events(&mut self, rec: &WalRecord, engine_deltas: &[EngineEdgeDelta], seq: u64) {
3923 if self.subscriptions.is_empty() && self.query_subscriptions.is_empty() {
3924 return;
3925 }
3926
3927 if !self.subscriptions.is_empty() {
3928 // Build write events from the WAL record.
3929 let write_events: Vec<DbEvent> =
3930 Self::write_events_from_record(rec, seq, &self.syms, &self.ids);
3931
3932 // Build edge events from engine deltas. Weight is looked up from
3933 // edge_props at distribution time (after apply), so it's always fresh.
3934 let edge_events: Vec<DbEvent> = engine_deltas
3935 .iter()
3936 .map(|d| {
3937 if d.fired {
3938 let weight = self
3939 .edge_props
3940 .get(d.etype_sym, d.src_id, d.dst_id, "weight")
3941 .and_then(|v| {
3942 if let core_storage::Value::Float(f) = v {
3943 Some(*f)
3944 } else {
3945 None
3946 }
3947 });
3948 DbEvent::EdgeFired {
3949 rule: d.rule.clone(),
3950 src_key: d.src_key.clone(),
3951 dst_key: d.dst_key.clone(),
3952 edge_type: d.edge_type.clone(),
3953 weight,
3954 commit_seq: seq,
3955 }
3956 } else {
3957 DbEvent::EdgeRetracted {
3958 rule: d.rule.clone(),
3959 src_key: d.src_key.clone(),
3960 dst_key: d.dst_key.clone(),
3961 edge_type: d.edge_type.clone(),
3962 commit_seq: seq,
3963 }
3964 }
3965 })
3966 .collect();
3967
3968 // Prune dead entries; push matching events to live ones.
3969 self.subscriptions.retain(|entry| {
3970 let Some(inner) = entry.inner.upgrade() else {
3971 return false;
3972 };
3973 for ev in &write_events {
3974 if event_matches(ev, &entry.filter) {
3975 inner.push(ev.clone());
3976 }
3977 }
3978 for ev in &edge_events {
3979 if event_matches(ev, &entry.filter) {
3980 inner.push(ev.clone());
3981 }
3982 }
3983 true
3984 });
3985
3986 // Turn off delta accumulation if all subscribers dropped and no views remain.
3987 if self.subscriptions.is_empty() && self.view_store.is_empty() {
3988 self.engine.set_emit_deltas(false);
3989 }
3990 }
3991
3992 // Query subscriptions: full re-run per commit, then diff rows.
3993 // IMPORTANT: full re-execution on every commit — use LIMIT to bound cost.
3994 // Differential evaluation is roadmap / Phase 5.
3995 if !self.query_subscriptions.is_empty() {
3996 // Take the list out so we can call self.view() without borrow conflict.
3997 let mut query_subs = std::mem::take(&mut self.query_subscriptions);
3998 let empty_params = BTreeMap::new();
3999 query_subs.retain_mut(|entry| {
4000 let Some(inner) = entry.inner.upgrade() else {
4001 return false; // subscriber dropped — prune
4002 };
4003 // Label-skip: if the plan has a known scan label and this commit
4004 // can be proven to touch only different labels (and no rule-derived
4005 // edge deltas fired), the result set cannot have changed — skip.
4006 if let Some(scan_sym) = entry.scan_label {
4007 if engine_deltas.is_empty() {
4008 let touched =
4009 Self::commit_touched_labels(rec, &self.syms, &self.ids, &self.labels);
4010 if touched.map(|t| !t.contains(&scan_sym)).unwrap_or(false) {
4011 return true; // safe to skip — result set unchanged
4012 }
4013 }
4014 }
4015 QUERY_SUB_EXECS_TL.with(|c| c.set(c.get() + 1));
4016 let result = match execute(&self.view(), &entry.ops, &Params(&empty_params)) {
4017 Ok(r) => r,
4018 Err(e) => {
4019 // Keep the subscription alive; skip the diff for this commit.
4020 // Re-run errors are transient (e.g., planner change) and
4021 // self-heal when the next commit succeeds.
4022 eprintln!("[mushroomdb] subscribe_query re-run failed: {e}");
4023 return true;
4024 }
4025 };
4026 // Build new row map: serialized-key → row data.
4027 let new_row_map = Self::result_to_row_map(&result);
4028 // Removed rows: in prev but not in new.
4029 for (key, row) in &entry.prev_row_map {
4030 if !new_row_map.contains_key(key) {
4031 inner.push(DbEvent::QueryRowRemoved {
4032 columns: entry.columns.clone(),
4033 row: row.clone(),
4034 });
4035 }
4036 }
4037 // Added rows: in new but not in prev.
4038 for (key, row) in &new_row_map {
4039 if !entry.prev_row_map.contains_key(key) {
4040 inner.push(DbEvent::QueryRowAdded {
4041 columns: entry.columns.clone(),
4042 row: row.clone(),
4043 });
4044 }
4045 }
4046 entry.prev_row_map = new_row_map;
4047 true
4048 });
4049 self.query_subscriptions = query_subs;
4050 }
4051 }
4052
4053 /// Returns `true` if any live subscriber or view definition requires delta
4054 /// accumulation. Used to set `engine.emit_deltas` on subscribe/view DDL.
4055 fn needs_emit_deltas(&self) -> bool {
4056 !self.view_store.is_empty()
4057 || self
4058 .subscriptions
4059 .iter()
4060 .any(|e| e.inner.upgrade().is_some())
4061 }
4062
4063 /// Convert a WAL record into `DbEvent` write events with the given seq.
4064 fn write_events_from_record(
4065 rec: &WalRecord,
4066 seq: u64,
4067 intern: &Interner,
4068 ids: &IdMap,
4069 ) -> Vec<DbEvent> {
4070 match rec {
4071 WalRecord::InsertNode { label, key, .. } => vec![DbEvent::NodeInserted {
4072 label: label.clone(),
4073 key: key.clone(),
4074 commit_seq: seq,
4075 }],
4076 // *Id arms run after a successful apply, so resolution can only
4077 // fail on a programming error. Skip the event rather than emit a
4078 // fabricated "" that clients can't tell from a real empty value
4079 // (mirrors event_from_record returning None).
4080 WalRecord::InsertNodeId { label, key, .. } => intern
4081 .resolve(*label)
4082 .map(|label| DbEvent::NodeInserted {
4083 label: label.to_string(),
4084 key: key.clone(),
4085 commit_seq: seq,
4086 })
4087 .into_iter()
4088 .collect(),
4089 WalRecord::SetProp { key, field, .. } => vec![DbEvent::PropSet {
4090 key: key.clone(),
4091 field: field.clone(),
4092 commit_seq: seq,
4093 }],
4094 WalRecord::SetPropId { id, field, .. } => ids
4095 .key_of(*id)
4096 .zip(intern.resolve(*field))
4097 .map(|(key, field)| DbEvent::PropSet {
4098 key: key.to_string(),
4099 field: field.to_string(),
4100 commit_seq: seq,
4101 })
4102 .into_iter()
4103 .collect(),
4104 WalRecord::RemoveProp { key, field } => vec![DbEvent::PropRemoved {
4105 key: key.clone(),
4106 field: field.clone(),
4107 commit_seq: seq,
4108 }],
4109 WalRecord::InsertEdge {
4110 edge_type,
4111 src_key,
4112 dst_key,
4113 } => vec![DbEvent::EdgeInserted {
4114 edge_type: edge_type.clone(),
4115 src: src_key.clone(),
4116 dst: dst_key.clone(),
4117 commit_seq: seq,
4118 }],
4119 WalRecord::InsertEdgeId { etype, src, dst } => (|| {
4120 Some(DbEvent::EdgeInserted {
4121 edge_type: intern.resolve(*etype)?.to_string(),
4122 src: ids.key_of(*src)?.to_string(),
4123 dst: ids.key_of(*dst)?.to_string(),
4124 commit_seq: seq,
4125 })
4126 })()
4127 .into_iter()
4128 .collect(),
4129 WalRecord::DeleteEdge {
4130 edge_type,
4131 src_key,
4132 dst_key,
4133 } => vec![DbEvent::EdgeDeleted {
4134 edge_type: edge_type.clone(),
4135 src: src_key.clone(),
4136 dst: dst_key.clone(),
4137 commit_seq: seq,
4138 }],
4139 WalRecord::DeleteNode { key } => vec![DbEvent::NodeDeleted {
4140 key: key.clone(),
4141 commit_seq: seq,
4142 }],
4143 WalRecord::Batch(inner) => inner
4144 .iter()
4145 .flat_map(|r| Self::write_events_from_record(r, seq, intern, ids))
4146 .collect(),
4147 WalRecord::CreateRule { .. }
4148 | WalRecord::DeleteRule { .. }
4149 | WalRecord::RebuildRule { .. }
4150 | WalRecord::CreateView { .. }
4151 | WalRecord::DeleteView { .. }
4152 | WalRecord::EnableFulltext { .. }
4153 | WalRecord::DisableFulltext { .. }
4154 | WalRecord::EnableIndex { .. }
4155 | WalRecord::DisableIndex { .. }
4156 | WalRecord::Intern { .. }
4157 // History markers produce no DbEvent — the engine delta already
4158 // fired the EdgeFired/EdgeRetracted subscription events.
4159 | WalRecord::DerivedEdgeAdded { .. }
4160 | WalRecord::DerivedEdgeRetracted { .. }
4161 | WalRecord::RenameNode { .. } => vec![],
4162 }
4163 }
4164
4165 /// Subscribe to edge-fire and edge-retract events for one named rule.
4166 ///
4167 /// Returns `Err(GraphError::RuleNotFound)` if `rule_name` is not
4168 /// currently registered. Dropping the returned [`Subscription`] handle
4169 /// unregisters the subscriber — no further events are queued, no
4170 /// resources leak.
4171 pub fn subscribe_rule(&mut self, rule_name: &str) -> core_storage::Result<Subscription> {
4172 if self.read_only {
4173 return Err(core_storage::GraphError::ReadOnly);
4174 }
4175 if !self.engine.rules().any(|r| r.name == rule_name) {
4176 return Err(core_storage::GraphError::RuleNotFound {
4177 name: rule_name.to_string(),
4178 });
4179 }
4180 let inner = SubInner::new(self.sub_capacity());
4181 self.subscriptions.push(SubEntry {
4182 filter: SubFilter::Rule(rule_name.to_string()),
4183 inner: std::sync::Arc::downgrade(&inner),
4184 });
4185 self.engine.set_emit_deltas(true);
4186 Ok(Subscription(inner))
4187 }
4188
4189 /// Subscribe to edge-fire and edge-retract events for **all** rules.
4190 ///
4191 /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
4192 /// as-of instances never commit, so `distribute_events` never runs and the
4193 /// subscription would never deliver events.
4194 pub fn subscribe_all_rules(&mut self) -> core_storage::Result<Subscription> {
4195 if self.read_only {
4196 return Err(core_storage::GraphError::ReadOnly);
4197 }
4198 let inner = SubInner::new(self.sub_capacity());
4199 self.subscriptions.push(SubEntry {
4200 filter: SubFilter::AllRules,
4201 inner: std::sync::Arc::downgrade(&inner),
4202 });
4203 self.engine.set_emit_deltas(true);
4204 Ok(Subscription(inner))
4205 }
4206
4207 /// Subscribe to write events: node insert/delete, prop set/remove.
4208 ///
4209 /// Does not include edge-fire / edge-retract (rule-derived edge events).
4210 ///
4211 /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
4212 /// as-of instances never commit, so `distribute_events` never runs and the
4213 /// subscription would never deliver events.
4214 pub fn subscribe_writes(&mut self) -> core_storage::Result<Subscription> {
4215 if self.read_only {
4216 return Err(core_storage::GraphError::ReadOnly);
4217 }
4218 let inner = SubInner::new(self.sub_capacity());
4219 self.subscriptions.push(SubEntry {
4220 filter: SubFilter::Writes,
4221 inner: std::sync::Arc::downgrade(&inner),
4222 });
4223 self.engine.set_emit_deltas(true);
4224 Ok(Subscription(inner))
4225 }
4226
4227 /// Subscribe to incremental Cypher query results.
4228 ///
4229 /// Parses and plans `cypher`; rejects the query if the plan is not in the
4230 /// allowlisted subset (see [`core_query::cypher::is_subscribable`]):
4231 /// - `MATCH (n:Label) WHERE … RETURN … [LIMIT n]`
4232 /// - `MATCH (a)-[r:TYPE]->(b) RETURN … [LIMIT n]` (exactly one hop)
4233 ///
4234 /// SKIP is not supported — it shifts the result window on every commit,
4235 /// causing spurious Added/Removed churn for rows whose data never changed.
4236 /// Multi-hop Expand chains are not supported; each additional MATCH clause
4237 /// widens scope beyond the documented single-scan / single-hop subset.
4238 ///
4239 /// After each successful commit, the plan is **fully re-executed** and the
4240 /// result is diffed against the previous run. Added rows produce
4241 /// [`DbEvent::QueryRowAdded`]; removed rows produce
4242 /// [`DbEvent::QueryRowRemoved`].
4243 ///
4244 /// **Full re-run per commit; use LIMIT to bound execution cost.**
4245 /// The existing 1 M intermediate-row cap applies. Differential evaluation
4246 /// is roadmap / Phase 5.
4247 ///
4248 /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
4249 /// as-of instances never commit, so `distribute_events` never runs and the
4250 /// subscription would never deliver events.
4251 ///
4252 /// Returns `Err(GraphError::QueryError)` if the query fails to parse, plan,
4253 /// or if the plan shape is not in the allowlist.
4254 pub fn subscribe_query(&mut self, cypher: &str) -> Result<Subscription> {
4255 if self.read_only {
4256 return Err(GraphError::ReadOnly);
4257 }
4258 let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
4259 detail: format!("lex: {e}"),
4260 })?;
4261 let ast = parse(&tokens).map_err(|e| GraphError::QueryError {
4262 detail: format!("parse: {e}"),
4263 })?;
4264 let ops = plan(&ast).map_err(|e| GraphError::QueryError {
4265 detail: format!("plan: {e}"),
4266 })?;
4267 if !is_subscribable(&ops) {
4268 return Err(GraphError::QueryError {
4269 detail: "subscribe_query only supports allowlisted plan shapes: \
4270 MATCH (n:Label) WHERE … RETURN … [LIMIT n] or \
4271 MATCH (a)-[r:TYPE]->(b) RETURN … [LIMIT n] (exactly one hop). \
4272 Not supported: multi-hop Expand chains, SKIP (creates \
4273 unstable offset windows), ORDER BY, DISTINCT, aggregates, \
4274 variable-length paths, OPTIONAL MATCH, WITH, UNWIND. \
4275 Use LIMIT to bound re-execution cost."
4276 .to_string(),
4277 });
4278 }
4279 // Execute once to capture initial state (initial rows are not emitted as
4280 // events — the subscriber learns the baseline via the first query call).
4281 let empty_params = BTreeMap::new();
4282 let initial = execute(&self.view(), &ops, &Params(&empty_params)).map_err(|e| {
4283 GraphError::QueryError {
4284 detail: format!("execute: {e}"),
4285 }
4286 })?;
4287 let columns = initial.columns().to_vec();
4288 let prev_row_map = Self::result_to_row_map(&initial);
4289 let inner = SubInner::new(self.sub_capacity());
4290 // Derive the scan-label sym for the commit-skip fast-path. Any Expand op
4291 // or unrecognized leading scan → None (always re-execute).
4292 let scan_label = extract_scan_label(&ops, &mut self.syms);
4293 self.query_subscriptions.push(QuerySubEntry {
4294 ops,
4295 columns,
4296 prev_row_map,
4297 inner: std::sync::Arc::downgrade(&inner),
4298 scan_label,
4299 });
4300 Ok(Subscription(inner))
4301 }
4302
4303 /// Queue capacity used for new subscriptions.
4304 fn sub_capacity(&self) -> usize {
4305 self.sub_capacity
4306 }
4307
4308 /// Override per-subscriber queue capacity for subsequently created
4309 /// subscriptions on this db instance.
4310 ///
4311 /// Default is [`DEFAULT_SUB_CAPACITY`] (65,536 events). Use a smaller
4312 /// value in tests to exercise the [`DbEvent::Lagged`] path without
4313 /// generating tens of thousands of events.
4314 ///
4315 /// This is a test-support escape hatch. Calling it in production reduces
4316 /// subscriber reliability (more Lagged events). It is hidden from rustdoc
4317 /// to discourage accidental production use.
4318 #[doc(hidden)]
4319 pub fn set_sub_capacity(&mut self, capacity: usize) {
4320 self.sub_capacity = capacity;
4321 }
4322
4323 // -----------------------------------------------------------------------
4324
4325 /// Start an atomic batch.
4326 ///
4327 /// The returned [`BatchBuilder`] borrows `self` mutably until
4328 /// [`BatchBuilder::commit`]. Builder methods queue ops only — no
4329 /// validation, no WAL I/O. `commit` validates every queued op against
4330 /// live state plus preceding ops in this batch (duplicate key inside
4331 /// the batch is `Err`; an edge between two nodes created earlier in
4332 /// the batch is valid; `delete_node` then insert of the same key is a
4333 /// fresh identity). Validation never mutates the database. Any failure
4334 /// leaves WAL bytes and in-memory state identical to before `commit`.
4335 /// On success, one `WalRecord::Batch` frame is appended (one fsync)
4336 /// and each inner record is applied in order so rules fire per record.
4337 /// An empty batch, or a batch of only no-ops, writes zero WAL bytes.
4338 ///
4339 /// **Rule-window limitation:** batch validation cannot see edges that a
4340 /// rule created earlier in the *same* batch will derive at apply time, so
4341 /// a `delete_edge` / `insert_edge` in that window is silently no-oped
4342 /// where sequential calls would return `Err(RuleOwned)`. State integrity
4343 /// is unaffected (idempotent apply, provenance intact). Create rules in
4344 /// their own batch, or sequentially, when later ops may touch derived
4345 /// edges.
4346 pub fn batch(&mut self) -> BatchBuilder<'_, F> {
4347 BatchBuilder {
4348 db: self,
4349 ops: Vec::new(),
4350 }
4351 }
4352
4353 /// Closure-style atomic write batch.
4354 ///
4355 /// Equivalent to calling [`GraphDb::batch`], invoking `build` to queue ops,
4356 /// then committing. All ops queued inside `build` are validated in order and
4357 /// committed as a single `WalRecord::Batch` frame (one fsync). Rules fire
4358 /// once per inner record, in order, after commit — semantically identical to
4359 /// sequential single-op writes.
4360 ///
4361 /// **Error semantics — validate-then-apply.** `build` queues ops without
4362 /// touching the database. [`BatchBuilder::commit`] validates every op against
4363 /// live state plus earlier ops in this batch before writing anything. If op N
4364 /// fails validation (duplicate key, unknown key, rule-owned edge, …) the
4365 /// entire batch is rejected: no WAL bytes are written and no in-memory state
4366 /// changes. The database is identical to its state before `write_batch` was
4367 /// called.
4368 ///
4369 /// **Atomicity is crash-level, NOT isolation-level.** On replay after a crash,
4370 /// a partial (torn) `Batch` frame applies NONE of its ops — the frame is
4371 /// either fully applied or not at all. However, while applying a committed
4372 /// batch, concurrent readers may observe intermediate states as ops are applied
4373 /// sequentially in memory. There is no interactive transaction isolation in v1.
4374 /// This is documented as "crash-atomic write batches; no interactive
4375 /// transactions or read isolation."
4376 ///
4377 /// **Returns** `(nodes_inserted, edges_inserted)`. An empty or all-noop batch
4378 /// writes zero WAL bytes and returns `(0, 0)`.
4379 ///
4380 /// # Example
4381 ///
4382 /// ```rust,ignore
4383 /// let (nodes, edges) = db.write_batch(|b| {
4384 /// b.insert_node("Person", "alice", vec![("age".into(), Value::Int(30))]);
4385 /// b.insert_node("Person", "bob", vec![]);
4386 /// b.insert_edge("KNOWS", "alice", "bob");
4387 /// b.set_prop("alice", "role", Value::Str("admin".into()));
4388 /// b.delete_node("old_key");
4389 /// })?;
4390 /// // One fsync; on crash replay: all five ops land or none do.
4391 /// ```
4392 pub fn write_batch<C>(&mut self, build: C) -> Result<(usize, usize)>
4393 where
4394 C: FnOnce(&mut BatchBuilder<'_, F>),
4395 {
4396 let mut b = self.batch();
4397 build(&mut b);
4398 b.commit()
4399 }
4400
4401 /// Insert `rows` as nodes of `label`. One call is one atomic batch:
4402 /// auto-declared KeyMatch rules (if any) first, then the accepted node
4403 /// inserts, so incremental fire sees the new rules. Per-row key problems
4404 /// are collected in [`IngestReport::row_errors`] and skipped; a commit
4405 /// `Err` means nothing was applied.
4406 ///
4407 /// Auto-FK rule names are `auto_fk_<src_label_lowercase>_<field>` so
4408 /// distinct source labels sharing an FK field each get their own rule.
4409 pub fn ingest(
4410 &mut self,
4411 label: &str,
4412 rows: Vec<BTreeMap<String, Value>>,
4413 opts: &IngestOptions,
4414 ) -> Result<IngestReport> {
4415 self.ingest_with_edges(label, rows, opts, &[])
4416 }
4417
4418 /// [`ingest`] plus user edges in the **same** previewed WAL batch.
4419 /// A failing edge rejects the whole request; nothing is applied.
4420 pub fn ingest_with_edges(
4421 &mut self,
4422 label: &str,
4423 rows: Vec<BTreeMap<String, Value>>,
4424 opts: &IngestOptions,
4425 edges: &[(String, String, String)],
4426 ) -> Result<IngestReport> {
4427 crate::ingest::run(self, label, rows, opts, edges)
4428 }
4429
4430 /// Parse `json` as an array of objects and ingest via [`GraphDb::ingest`].
4431 ///
4432 /// JSON `null` fields are silently omitted (not stored, not a row error).
4433 /// Nested objects and arrays-of-objects are a per-row error (row skipped).
4434 /// Parse failures and a top-level value that is not an array of objects
4435 /// return [`GraphError::IngestError`].
4436 pub fn ingest_json(
4437 &mut self,
4438 label: &str,
4439 json: &str,
4440 opts: &IngestOptions,
4441 ) -> Result<IngestReport> {
4442 crate::ingest::run_json(self, label, json, opts)
4443 }
4444
4445 fn commit_logged_batch(
4446 &mut self,
4447 ops: Vec<BatchOp>,
4448 ingest: Option<(String, usize)>,
4449 // Two-source rule: write_batch_authz threads authz here directly (never
4450 // touches pending_write_authz); query_write_authz sets the field instead
4451 // and passes None. Only one source is non-None per call.
4452 param_authz: Option<WriteAuthz>,
4453 ) -> Result<(usize, usize)> {
4454 // Read-only guard: catches empty-batch calls before the early-return
4455 // that skips log_then_apply_with, ensuring all mutation entry points fail.
4456 if self.read_only {
4457 return Err(GraphError::ReadOnly);
4458 }
4459 // Ensure provenance is decoded before MutPreview accesses it
4460 // (note_delete_rule / is_rule_owned may call engine.provenance()).
4461 self.engine.ensure_provenance_loaded_mut();
4462
4463 // ── Authz pre-check ──────────────────────────────────────────────────
4464 // Evaluate the decision table per-op BEFORE MutPreview so that a denial
4465 // produces no WAL frame (all-or-nothing at the authz boundary extends
4466 // the existing validate-then-apply contract to role-scope checks).
4467 //
4468 // `batch_created` tracks key→label for nodes created by earlier ops in
4469 // THIS batch, so InsertEdgeUpsert can count same-batch placeholder nodes
4470 // as visible without needing to call `self.ids.get` on not-yet-committed
4471 // keys (they won't be there yet).
4472 //
4473 // Two-source rule: param_authz (write_batch_authz path) takes precedence;
4474 // fall back to self.pending_write_authz (query_write_authz/Cypher path).
4475 // Cloning the field copy avoids a simultaneous borrow of self.ids below.
4476 let authz_opt = param_authz.or_else(|| self.pending_write_authz.clone());
4477 if let Some(ref authz) = authz_opt {
4478 let mut batch_created: BTreeMap<String, String> = BTreeMap::new();
4479 for op in &ops {
4480 self.check_single_op_authz(authz, op, &batch_created)?;
4481 // Update batch_created after a passing authz check so that
4482 // subsequent ops in this batch see the nodes as "about to exist".
4483 match op {
4484 BatchOp::InsertNode { label, key, .. } => {
4485 // Only track genuinely new nodes (absent from the
4486 // snapshot at authz-check time). A pre-existing visible
4487 // key would be a DuplicateKey — not a real creation —
4488 // so MutPreview handles it. Letting it into batch_created
4489 // would allow a later SetProp to bypass update_labels
4490 // via the "batch-created → always updatable" ruling
4491 // (delete+recreate exploit, fix for I1 review round 2).
4492 //
4493 // Accepted edge: for a delete+recreate-with-different-
4494 // label batch, node_status resolves the pre-delete
4495 // (store) label for any subsequent update checks. This
4496 // grants no net-new capability — a role that can delete+
4497 // create can already place arbitrary props via
4498 // InsertNode's own props field.
4499 if self.ids.get(key.as_str()).is_none() {
4500 batch_created.insert(key.clone(), label.clone());
4501 }
4502 }
4503 BatchOp::InsertEdgeUpsert {
4504 placeholder_label,
4505 src_key,
4506 dst_key,
4507 ..
4508 } => {
4509 // Both endpoints will be created if not already in store.
4510 for ep_key in [src_key, dst_key] {
4511 if self.ids.get(ep_key.as_str()).is_none()
4512 && !batch_created.contains_key(ep_key.as_str())
4513 {
4514 batch_created.insert(ep_key.clone(), placeholder_label.clone());
4515 }
4516 }
4517 }
4518 _ => {}
4519 }
4520 }
4521 }
4522
4523 let recs = {
4524 let mut preview = MutPreview::new(self);
4525 let mut recs = Vec::with_capacity(ops.len());
4526 for op in ops {
4527 match op {
4528 BatchOp::InsertNode { label, key, props } => {
4529 preview.check_insert_node(&key)?;
4530 preview.note_insert_node(&key, &props);
4531 recs.push(WalRecord::InsertNode { label, key, props });
4532 }
4533 BatchOp::InsertEdge {
4534 edge_type,
4535 src_key,
4536 dst_key,
4537 } => {
4538 if preview.prepare_insert_edge(&edge_type, &src_key, &dst_key)? {
4539 preview.note_insert_edge(&edge_type, &src_key, &dst_key);
4540 recs.push(WalRecord::InsertEdge {
4541 edge_type,
4542 src_key,
4543 dst_key,
4544 });
4545 }
4546 }
4547 BatchOp::SetProp { key, field, value } => {
4548 preview.check_live_key(&key)?;
4549 preview.note_set_prop(&key, &field, &value);
4550 recs.push(WalRecord::SetProp { key, field, value });
4551 }
4552 BatchOp::RemoveProp { key, field } => {
4553 if preview.prepare_remove_prop(&key, &field)? {
4554 preview.note_remove_prop(&key, &field);
4555 recs.push(WalRecord::RemoveProp { key, field });
4556 }
4557 }
4558 BatchOp::DeleteEdge {
4559 edge_type,
4560 src_key,
4561 dst_key,
4562 } => {
4563 if preview.prepare_delete_edge(&edge_type, &src_key, &dst_key)? {
4564 preview.note_delete_edge(&edge_type, &src_key, &dst_key);
4565 recs.push(WalRecord::DeleteEdge {
4566 edge_type,
4567 src_key,
4568 dst_key,
4569 });
4570 }
4571 }
4572 BatchOp::DeleteNode { key } => {
4573 preview.check_live_key(&key)?;
4574 preview.note_delete_node(&key);
4575 recs.push(WalRecord::DeleteNode { key });
4576 }
4577 BatchOp::CreateRule(def) => {
4578 preview.check_create_rule(&def)?;
4579 let def_bytes =
4580 bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
4581 detail: format!("serialize rule: {e}"),
4582 })?;
4583 preview.note_create_rule(&def.name);
4584 recs.push(WalRecord::CreateRule { def_bytes });
4585 }
4586 BatchOp::DeleteRule { name } => {
4587 preview.check_delete_rule(&name)?;
4588 preview.note_delete_rule(&name);
4589 recs.push(WalRecord::DeleteRule { name });
4590 }
4591 BatchOp::RenameNode { old_key, new_key } => {
4592 preview.check_rename_node(&old_key, &new_key)?;
4593 preview.note_rename_node(&old_key, &new_key);
4594 recs.push(WalRecord::RenameNode { old_key, new_key });
4595 }
4596 BatchOp::InsertEdgeUpsert {
4597 edge_type,
4598 src_key,
4599 dst_key,
4600 placeholder_label,
4601 } => {
4602 // Auto-create any missing endpoints as plain InsertNode ops.
4603 // Rules fire and last-change is updated for each created node.
4604 for key in [&src_key, &dst_key] {
4605 if !preview.has_key(key) {
4606 preview.check_insert_node(key)?;
4607 preview.note_insert_node(key, &[]);
4608 recs.push(WalRecord::InsertNode {
4609 label: placeholder_label.clone(),
4610 key: key.clone(),
4611 props: vec![],
4612 });
4613 }
4614 }
4615 if preview.prepare_insert_edge(&edge_type, &src_key, &dst_key)? {
4616 preview.note_insert_edge(&edge_type, &src_key, &dst_key);
4617 recs.push(WalRecord::InsertEdge {
4618 edge_type,
4619 src_key,
4620 dst_key,
4621 });
4622 }
4623 }
4624 }
4625 }
4626 recs
4627 };
4628 if recs.is_empty() {
4629 return Ok((0, 0));
4630 }
4631 // rewrite_wal_dense converts every InsertNode/InsertEdge into its
4632 // *Id form, so only the dense variants can appear in `recs` here.
4633 let recs = self.rewrite_wal_dense(recs)?;
4634 let nodes_inserted = recs
4635 .iter()
4636 .filter(|r| matches!(r, WalRecord::InsertNodeId { .. }))
4637 .count();
4638 let edges_inserted = recs
4639 .iter()
4640 .filter(|r| matches!(r, WalRecord::InsertEdgeId { .. }))
4641 .count();
4642 // Ingest / write_batch / query_write: one Batch frame, one fsync per call
4643 // under Strict. Pass self.fsync directly so Strict stays Strict —
4644 // wal_needs_sync(Strict, _) always returns true regardless of op count.
4645 // Mapping Strict → Batched (the prior bug) caused wal_needs_sync to
4646 // short-circuit on single-op batches and silently skip the fsync.
4647 // Batched fsyncs only for multi-op batches; Relaxed always skips.
4648 self.log_then_apply_with(WalRecord::Batch(recs), ingest, self.fsync)?;
4649 Ok((nodes_inserted, edges_inserted))
4650 }
4651
4652 fn commit_batch(&mut self, ops: Vec<BatchOp>) -> Result<(usize, usize)> {
4653 self.commit_logged_batch(ops, None, None)
4654 }
4655
4656 /// Commit one submission WITHOUT an fsync — for use inside `commit_group`
4657 /// and the group-commit drain thread, which do a single group fsync later.
4658 fn commit_batch_nosync(&mut self, ops: Vec<BatchOp>) -> Result<(usize, usize)> {
4659 // Restore fsync policy even on panic via a raw-pointer drop guard.
4660 // A panic here would poison the RwLock anyway, but the correct policy
4661 // must be in place if the guard is ever unwrapped.
4662 struct RestoreFsync(*mut FsyncPolicy, FsyncPolicy);
4663 impl Drop for RestoreFsync {
4664 fn drop(&mut self) {
4665 // SAFETY: the pointer is valid for the full duration of
4666 // commit_batch_nosync; the guard is dropped before the frame
4667 // returns, and GraphDb outlives this frame.
4668 unsafe {
4669 *self.0 = self.1;
4670 }
4671 }
4672 }
4673 let saved = self.fsync;
4674 // SAFETY: raw pointer into self; guard dropped within this frame.
4675 let _g = RestoreFsync(&mut self.fsync as *mut FsyncPolicy, saved);
4676 self.fsync = FsyncPolicy::Relaxed;
4677 self.commit_logged_batch(ops, None, None)
4678 }
4679
4680 /// Commit multiple op-batches as a **group**: each submission gets its own
4681 /// WAL `Batch` frame, but there is exactly **one** `Fs::sync` for the whole
4682 /// group (under `Strict` / `Batched` policy; `Relaxed` skips all syncs).
4683 ///
4684 /// # Durability semantics
4685 ///
4686 /// A crash before the group fsync may lose **all** submissions in the group.
4687 /// A crash after the group fsync preserves all of them. No submission is
4688 /// ever torn: each WAL frame is either fully applied on replay or dropped
4689 /// in its entirety (CRC-protected frame boundaries).
4690 ///
4691 /// Events and subscription notifications fire per-submission immediately
4692 /// after apply, which may be before the group fsync. From a subscriber's
4693 /// perspective this is equivalent to the `Relaxed` durability window.
4694 /// Submitters using [`SharedDb::submit_batch`] only unblock after the group
4695 /// fsync, so from their perspective durability is fully guaranteed.
4696 ///
4697 /// # MVCC interplay
4698 ///
4699 /// Each submission records its own `CommitDelta`; the fold-every-K counter
4700 /// increments per submission (not per group), preserving existing reader
4701 /// snapshot semantics.
4702 ///
4703 /// # Returns
4704 ///
4705 /// One `Result<(nodes_inserted, edges_inserted)>` per input group element,
4706 /// in order. Failures are per-submission (validation errors); the group
4707 /// fsync error (if any) is returned as the second tuple element.
4708 pub fn commit_group(
4709 &mut self,
4710 groups: Vec<Vec<BatchOp>>,
4711 ) -> (Vec<Result<(usize, usize)>>, Option<GraphError>) {
4712 let mut results = Vec::with_capacity(groups.len());
4713 for ops in groups {
4714 results.push(self.commit_batch_nosync(ops));
4715 }
4716 let any_ok = results.iter().any(|r| r.is_ok());
4717 let sync_err = if self.fsync != FsyncPolicy::Relaxed && any_ok {
4718 self.fs
4719 .sync(core_storage::fs::FileId::Wal)
4720 .map_err(GraphError::Io)
4721 .err()
4722 } else {
4723 None
4724 };
4725 (results, sync_err)
4726 }
4727
4728 /// Like [`commit_group`] but skips the group fsync entirely.
4729 ///
4730 /// Used by the drain thread to apply submissions under the write lock and
4731 /// then perform the single fsync OUTSIDE the lock (via
4732 /// `core_storage::sync_wal_at`), reducing the write-lock hold time visible
4733 /// to concurrent readers.
4734 pub fn commit_group_nosync(
4735 &mut self,
4736 groups: Vec<Vec<BatchOp>>,
4737 ) -> Vec<Result<(usize, usize)>> {
4738 let mut results = Vec::with_capacity(groups.len());
4739 for ops in groups {
4740 results.push(self.commit_batch_nosync(ops));
4741 }
4742 results
4743 }
4744
4745 pub fn insert_node(
4746 &mut self,
4747 label: &str,
4748 key: &str,
4749 props: Vec<(String, Value)>,
4750 ) -> Result<()> {
4751 if self.read_only {
4752 return Err(GraphError::ReadOnly);
4753 }
4754 MutPreview::new(self).check_insert_node(key)?;
4755 self.log_dense(vec![WalRecord::InsertNode {
4756 label: label.into(),
4757 key: key.into(),
4758 props,
4759 }])
4760 }
4761
4762 pub fn insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
4763 if self.read_only {
4764 return Err(GraphError::ReadOnly);
4765 }
4766 if !MutPreview::new(self).prepare_insert_edge(edge_type, src_key, dst_key)? {
4767 return Ok(false);
4768 }
4769 self.log_dense(vec![WalRecord::InsertEdge {
4770 edge_type: edge_type.into(),
4771 src_key: src_key.into(),
4772 dst_key: dst_key.into(),
4773 }])?;
4774 Ok(true)
4775 }
4776
4777 pub fn set_prop(&mut self, key: &str, field: &str, value: Value) -> Result<()> {
4778 if self.read_only {
4779 return Err(GraphError::ReadOnly);
4780 }
4781 if let Some(view_name) = self.view_store.view_for_prop(field) {
4782 return Err(GraphError::ViewPropReadOnly {
4783 view_name: view_name.to_string(),
4784 });
4785 }
4786 MutPreview::new(self).check_live_key(key)?;
4787 self.log_dense(vec![WalRecord::SetProp {
4788 key: key.into(),
4789 field: field.into(),
4790 value,
4791 }])
4792 }
4793
4794 /// Remove a property. Returns `Ok(false)` (and does not log) if the field
4795 /// is already absent. Unknown or tombstoned keys are `Err(KeyNotFound)`.
4796 pub fn remove_prop(&mut self, key: &str, field: &str) -> Result<bool> {
4797 if self.read_only {
4798 return Err(GraphError::ReadOnly);
4799 }
4800 if let Some(view_name) = self.view_store.view_for_prop(field) {
4801 return Err(GraphError::ViewPropReadOnly {
4802 view_name: view_name.to_string(),
4803 });
4804 }
4805 if !MutPreview::new(self).prepare_remove_prop(key, field)? {
4806 return Ok(false);
4807 }
4808 self.log_then_apply(WalRecord::RemoveProp {
4809 key: key.into(),
4810 field: field.into(),
4811 })?;
4812 Ok(true)
4813 }
4814
4815 /// Delete a user edge. Returns `Ok(false)` (and does not log) if the edge
4816 /// is absent. Unknown keys are `Err(KeyNotFound)`. Rule-owned edges — in
4817 /// provenance, or a pair a live rule would derive — are `Err(RuleOwned)`
4818 /// (the rule would just put the edge back; delete or change the rule).
4819 pub fn delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
4820 if self.read_only {
4821 return Err(GraphError::ReadOnly);
4822 }
4823 if !MutPreview::new(self).prepare_delete_edge(edge_type, src_key, dst_key)? {
4824 return Ok(false);
4825 }
4826 self.log_then_apply(WalRecord::DeleteEdge {
4827 edge_type: edge_type.into(),
4828 src_key: src_key.into(),
4829 dst_key: dst_key.into(),
4830 })?;
4831 Ok(true)
4832 }
4833
4834 /// Delete a live node. Unknown or already-tombstoned keys are
4835 /// `Err(KeyNotFound)` and are not logged. Validation runs before the WAL
4836 /// write; `apply` of a logged `DeleteNode` for an already-tombstoned key
4837 /// (crash window) is a clean no-op.
4838 ///
4839 /// Returns a [`DeleteReport`] with counts of manual and derived edges
4840 /// removed (computed from live state before the deletion is applied).
4841 pub fn delete_node(&mut self, key: &str) -> Result<DeleteReport> {
4842 if self.read_only {
4843 return Err(GraphError::ReadOnly);
4844 }
4845 // Provenance must be loaded before we query provenance_touching.
4846 self.engine.ensure_provenance_loaded_mut();
4847 let id = self
4848 .ids
4849 .get(key)
4850 .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
4851
4852 // Count edges before the delete is applied so we can report counts.
4853 let derived_set: BTreeSet<(u32, u32, u32)> = self
4854 .engine
4855 .provenance_touching(id)
4856 .map(|(_, etype, src, dst)| (etype, src, dst))
4857 .collect();
4858 let derived_edges = derived_set.len() as u64;
4859
4860 let mut total_topo = 0u64;
4861 let tv = self.topo_view();
4862 for et in tv.etypes() {
4863 total_topo += tv.neighbors(et, Direction::Out, id).len() as u64
4864 + tv.neighbors(et, Direction::In, id).len() as u64;
4865 }
4866 // For symmetric rules (e.g. Overlap), a→b and b→a are two separate directed
4867 // triples in both the topo scan (Out and In from id) and in provenance_touching.
4868 // The subtraction remains correct because both counts include both directions.
4869 let manual_edges = total_topo.saturating_sub(derived_edges);
4870
4871 self.log_then_apply(WalRecord::DeleteNode { key: key.into() })?;
4872 Ok(DeleteReport {
4873 manual_edges,
4874 derived_edges,
4875 })
4876 }
4877
4878 /// Rename a live node's key. The dense id (and therefore all edges,
4879 /// props, history, and last-change tracking) is unaffected.
4880 ///
4881 /// Returns `Err(KeyNotFound)` if `old` is not a live key.
4882 /// Returns `Err(DuplicateKey)` if `new` is already live.
4883 pub fn rename_node(&mut self, old: &str, new: &str) -> Result<()> {
4884 if self.read_only {
4885 return Err(GraphError::ReadOnly);
4886 }
4887 MutPreview::new(self).check_rename_node(old, new)?;
4888 self.log_then_apply(WalRecord::RenameNode {
4889 old_key: old.into(),
4890 new_key: new.into(),
4891 })
4892 }
4893
4894 /// Return the IVF drift counter for the dst-side candidate index of `rule`.
4895 /// `None` if the rule does not exist or is not approximate.
4896 ///
4897 /// The drift counter increments on IVF insert/remove after the last fit.
4898 /// When dst-side drift exceeds [`core_rules::IVF_DRIFT_REBUILD`], apply
4899 /// WAL-logs `RebuildRule` as a second commit (rebuild resets the counter).
4900 pub fn ivf_dst_drift(&self, rule: &str) -> Option<u64> {
4901 // SideIvfExport = (centroids, node→cluster, drift)
4902 self.engine
4903 .export_ivf_state()
4904 .remove(rule)
4905 .map(|(_src, dst)| dst.2)
4906 }
4907
4908 /// Validate and WAL-log a new rule, then backfill derived edges inside apply.
4909 /// Validation and duplicate-name check run before logging so invalid rules
4910 /// never enter the WAL.
4911 pub fn create_rule(&mut self, def: RuleDef) -> Result<()> {
4912 if self.read_only {
4913 return Err(GraphError::ReadOnly);
4914 }
4915 MutPreview::new(self).check_create_rule(&def)?;
4916 let def_bytes = bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
4917 detail: format!("serialize rule: {e}"),
4918 })?;
4919 self.log_then_apply(WalRecord::CreateRule { def_bytes })
4920 }
4921
4922 /// WAL-log rule deletion. Returns RuleNotFound if the rule does not exist.
4923 pub fn delete_rule(&mut self, name: &str) -> Result<()> {
4924 if self.read_only {
4925 return Err(GraphError::ReadOnly);
4926 }
4927 MutPreview::new(self).check_delete_rule(name)?;
4928 self.log_then_apply(WalRecord::DeleteRule { name: name.into() })
4929 }
4930
4931 /// Return a snapshot of all registered rules.
4932 pub fn rules(&self) -> Vec<RuleDef> {
4933 self.engine.rules().cloned().collect()
4934 }
4935
4936 // -----------------------------------------------------------------------
4937 // Rule suggestion API
4938 // -----------------------------------------------------------------------
4939
4940 /// Profile the database and suggest linking rules with previewed edge counts.
4941 ///
4942 /// Uses the default seed ([`core_rules::SUGGEST_DEFAULT_SEED`]) for deterministic
4943 /// sampling. Suggestions are sorted by estimated edge count (descending).
4944 /// **NO auto-accept** — call [`GraphDb::create_rule`] explicitly to apply.
4945 pub fn suggest_rules(&self) -> Vec<core_rules::RuleSuggestion> {
4946 self.suggest_rules_seeded(core_rules::SUGGEST_DEFAULT_SEED)
4947 }
4948
4949 /// Like [`suggest_rules`] but with a caller-supplied RNG seed for
4950 /// reproducibility. Same seed + same data = identical output.
4951 pub fn suggest_rules_seeded(&self, seed: u64) -> Vec<core_rules::RuleSuggestion> {
4952 self.suggest_rules_with_config(&core_rules::suggest::SuggestConfig::default(), seed)
4953 .suggestions
4954 }
4955
4956 /// [`suggest_rules_seeded`] with a fully custom [`SuggestConfig`].
4957 ///
4958 /// Returns a [`core_rules::SuggestReport`] that includes both the candidate list
4959 /// and a `truncated` flag indicating whether the global budget fired before all
4960 /// candidates were evaluated.
4961 pub fn suggest_rules_with_config(
4962 &self,
4963 config: &core_rules::suggest::SuggestConfig,
4964 seed: u64,
4965 ) -> core_rules::SuggestReport {
4966 use std::collections::BTreeMap;
4967
4968 // Collect (node_id, key) pairs per label, skipping tombstoned nodes.
4969 let mut label_nodes: BTreeMap<String, Vec<(u32, String)>> = BTreeMap::new();
4970 for id in 0..self.ids.len() as u32 {
4971 let Some(key) = self.ids.key_of(id) else {
4972 continue;
4973 };
4974 let Some(&sym) = self.labels.get(id as usize) else {
4975 continue;
4976 };
4977 if sym == u32::MAX {
4978 continue; // tombstoned
4979 }
4980 let Some(label) = self.syms.resolve(sym) else {
4981 continue;
4982 };
4983 label_nodes
4984 .entry(label.to_string())
4985 .or_default()
4986 .push((id, key.to_string()));
4987 }
4988
4989 let existing = self.rules();
4990 let pv = build_props_view(&self.props, &self.base);
4991 let all_fields: Vec<String> = pv.field_names();
4992
4993 core_rules::suggest::suggest_rules(
4994 &label_nodes,
4995 &|id, field| pv.get(id, field).map(|vr| vr.into_value()),
4996 &all_fields,
4997 &existing,
4998 config,
4999 seed,
5000 )
5001 }
5002
5003 /// Recompute a rule's derived edges from scratch. WAL-logged so un-trip
5004 /// plus later mutations replay identically (rebuild is a pure function
5005 /// of state).
5006 ///
5007 /// Only exit from the tripped latch: if the full desired set fits the
5008 /// budget, it is applied completely and `tripped` clears; if it still
5009 /// exceeds the budget, provenance is left untouched and `tripped` stays
5010 /// true. Counts as a fire evaluation (see [`RuleStats::fires`]).
5011 /// Unknown rule → `RuleNotFound`, nothing logged.
5012 pub fn rebuild_rule(&mut self, name: &str) -> Result<()> {
5013 if self.read_only {
5014 return Err(GraphError::ReadOnly);
5015 }
5016 if !self.engine.rules().any(|r| r.name == name) {
5017 return Err(GraphError::RuleNotFound { name: name.into() });
5018 }
5019 self.log_then_apply(WalRecord::RebuildRule { name: name.into() })
5020 }
5021
5022 // -----------------------------------------------------------------------
5023 // Materialized view API
5024 // -----------------------------------------------------------------------
5025
5026 /// Register a new materialized property view, backfill its values for all
5027 /// existing nodes, and WAL-log the definition.
5028 ///
5029 /// # Errors
5030 /// - `ReadOnly`: called on an as-of instance.
5031 /// - `RuleInvalid`: name collision, view_prop collision, or invalid def.
5032 pub fn create_view(&mut self, def: ViewDef) -> Result<()> {
5033 if self.read_only {
5034 return Err(GraphError::ReadOnly);
5035 }
5036 // Pre-validate before WAL write.
5037 def.validate()
5038 .map_err(|e| GraphError::RuleInvalid { detail: e })?;
5039 if self.view_store.has_view(&def.name) {
5040 return Err(GraphError::RuleInvalid {
5041 detail: format!("view {:?} already exists", def.name),
5042 });
5043 }
5044 if let Some(existing) = self.view_store.view_for_prop(&def.view_prop) {
5045 return Err(GraphError::RuleInvalid {
5046 detail: format!(
5047 "view_prop {:?} is already used by view {:?}",
5048 def.view_prop, existing
5049 ),
5050 });
5051 }
5052 let def_bytes = bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
5053 detail: format!("serialize view: {e}"),
5054 })?;
5055 // Enable delta accumulation before the view is registered so subsequent
5056 // incremental edge events reach view maintenance from this point onward.
5057 // (The backfill inside create_view reads topo directly; it does not rely
5058 // on pending deltas.)
5059 self.engine.set_emit_deltas(true);
5060 self.log_then_apply(WalRecord::CreateView { def_bytes })
5061 }
5062
5063 /// Remove a named view and delete its values from every node.
5064 ///
5065 /// # Errors
5066 /// - `ReadOnly`: called on an as-of instance.
5067 /// - `RuleNotFound`: view does not exist.
5068 pub fn delete_view(&mut self, name: &str) -> Result<()> {
5069 if self.read_only {
5070 return Err(GraphError::ReadOnly);
5071 }
5072 if !self.view_store.has_view(name) {
5073 return Err(GraphError::RuleNotFound { name: name.into() });
5074 }
5075 let result = self.log_then_apply(WalRecord::DeleteView { name: name.into() });
5076 // After deletion, disable accumulation if no listeners remain.
5077 if !self.needs_emit_deltas() {
5078 self.engine.set_emit_deltas(false);
5079 }
5080 result
5081 }
5082
5083 /// Snapshot of all registered view definitions.
5084 pub fn views(&self) -> Vec<ViewDef> {
5085 self.view_store.views().cloned().collect()
5086 }
5087
5088 // -----------------------------------------------------------------------
5089 // Full-text-lite API
5090 // -----------------------------------------------------------------------
5091
5092 /// Enable full-text indexing for all nodes of `label` on property `field`.
5093 ///
5094 /// After this call, every subsequent write to `(label, field)` is reflected
5095 /// in the index incrementally. Existing nodes are backfilled immediately.
5096 /// The declaration is persisted as a WAL record; the index itself is rebuilt
5097 /// from scratch on re-open (no snapshot format changes).
5098 ///
5099 /// # Errors
5100 /// - [`GraphError::ReadOnly`]: called on an as-of instance.
5101 /// - [`GraphError::RuleInvalid`]: `(label, field)` is already indexed.
5102 pub fn enable_fulltext(&mut self, label: &str, field: &str) -> Result<()> {
5103 if self.read_only {
5104 return Err(GraphError::ReadOnly);
5105 }
5106 if self.fulltext.is_enabled(label, field) {
5107 return Err(GraphError::RuleInvalid {
5108 detail: format!("full-text index for ({label:?}, {field:?}) already enabled"),
5109 });
5110 }
5111 self.log_then_apply(WalRecord::EnableFulltext {
5112 label: label.into(),
5113 field: field.into(),
5114 })
5115 }
5116
5117 /// Disable full-text indexing for `(label, field)` and drop its postings.
5118 ///
5119 /// # Errors
5120 /// - [`GraphError::ReadOnly`]: called on an as-of instance.
5121 /// - [`GraphError::RuleNotFound`]: `(label, field)` is not currently indexed.
5122 pub fn disable_fulltext(&mut self, label: &str, field: &str) -> Result<()> {
5123 if self.read_only {
5124 return Err(GraphError::ReadOnly);
5125 }
5126 if !self.fulltext.is_enabled(label, field) {
5127 return Err(GraphError::RuleNotFound {
5128 name: format!("fulltext({label},{field})"),
5129 });
5130 }
5131 self.log_then_apply(WalRecord::DisableFulltext {
5132 label: label.into(),
5133 field: field.into(),
5134 })
5135 }
5136
5137 /// Whether `(label, field)` is currently indexed for full-text search.
5138 pub fn is_fulltext_enabled(&self, label: &str, field: &str) -> bool {
5139 self.fulltext.is_enabled(label, field)
5140 }
5141
5142 /// Enable an equality index for all nodes of `label` on scalar property
5143 /// `field`. Subsequent `WHERE n.field = value` lookups become O(matches)
5144 /// instead of an O(N_label) scan. Existing nodes are backfilled; the
5145 /// declaration persists via WAL and the postings rebuild on re-open.
5146 ///
5147 /// # Errors
5148 /// - [`GraphError::ReadOnly`]: called on an as-of instance.
5149 /// - [`GraphError::RuleInvalid`]: `(label, field)` is already indexed.
5150 pub fn enable_index(&mut self, label: &str, field: &str) -> Result<()> {
5151 if self.read_only {
5152 return Err(GraphError::ReadOnly);
5153 }
5154 if self.prop_index.is_enabled(label, field) {
5155 return Err(GraphError::RuleInvalid {
5156 detail: format!("property index for ({label:?}, {field:?}) already enabled"),
5157 });
5158 }
5159 self.log_then_apply(WalRecord::EnableIndex {
5160 label: label.into(),
5161 field: field.into(),
5162 })
5163 }
5164
5165 /// Disable the equality index for `(label, field)` and drop its postings.
5166 ///
5167 /// # Errors
5168 /// - [`GraphError::ReadOnly`]: called on an as-of instance.
5169 /// - [`GraphError::RuleNotFound`]: `(label, field)` is not currently indexed.
5170 pub fn disable_index(&mut self, label: &str, field: &str) -> Result<()> {
5171 if self.read_only {
5172 return Err(GraphError::ReadOnly);
5173 }
5174 if !self.prop_index.is_enabled(label, field) {
5175 return Err(GraphError::RuleNotFound {
5176 name: format!("index({label},{field})"),
5177 });
5178 }
5179 self.log_then_apply(WalRecord::DisableIndex {
5180 label: label.into(),
5181 field: field.into(),
5182 })
5183 }
5184
5185 /// Whether `(label, field)` currently has an equality index.
5186 pub fn is_index_enabled(&self, label: &str, field: &str) -> bool {
5187 self.prop_index.is_enabled(label, field)
5188 }
5189
5190 /// Search a full-text-indexed field.
5191 ///
5192 /// Returns `(node_key, match_count)` pairs sorted by match_count descending,
5193 /// ties broken by key (lexicographic). Tombstoned nodes are excluded.
5194 ///
5195 /// **Query syntax:**
5196 /// - Space-separated terms are AND'd: `"foo bar"` requires both.
5197 /// - `OR` between terms forms disjunction: `"foo OR bar"` matches either.
5198 /// - Trailing `*` on a term is a prefix match: `"rust*"` matches `rustlang`, `rusty`.
5199 /// - `AND` keyword is accepted explicitly and is the default.
5200 /// - Tokenization is unicode-alphanumeric (same as index time); case-insensitive.
5201 ///
5202 /// **Unindexed field:** returns `Ok(vec![])` if `field` is not indexed.
5203 /// Pin: this is the documented, tested, stable behavior for v1.
5204 ///
5205 /// **Memory / performance:** O(postings) lookup; no scan. The index is
5206 /// in-memory and proportional to total indexed text across all enabled fields.
5207 ///
5208 /// **v2 grammar:** supports `"phrase"`, `-negation`, `prefix*`, `OR`, `AND`.
5209 /// Results are BM25-scored (k1=1.2, b=0.75) and sorted by score descending,
5210 /// key ascending for deterministic tiebreaking.
5211 pub fn search(&self, field: &str, query: &str) -> Vec<(String, f64)> {
5212 // Resolve node_ids to keys (excluding tombstones) then re-sort by
5213 // (score DESC, key ASC) to give a deterministic, key-lexicographic
5214 // tiebreak. FulltextIndex::search sorts by (score DESC, node_id ASC)
5215 // which diverges from key order when nodes were not inserted in key-lex order.
5216 let mut results: Vec<(String, f64)> = self
5217 .fulltext
5218 .search(field, query, 0)
5219 .into_iter()
5220 .filter_map(|(id, score)| self.ids.key_of(id).map(|key| (key.to_string(), score)))
5221 .collect();
5222 results.sort_by(|a, b| {
5223 b.1.partial_cmp(&a.1)
5224 .unwrap_or(std::cmp::Ordering::Equal)
5225 .then(a.0.cmp(&b.0))
5226 });
5227 results
5228 }
5229
5230 /// Hybrid search: Reciprocal Rank Fusion (RRF) over fulltext + vector results.
5231 ///
5232 /// Takes up to `4*k` fulltext hits for `(text_field, query_text)` and up to
5233 /// `4*k` vector hits for `(vector_field, query_vec, min=0.0)`, then fuses
5234 /// them with RRF using a fixed constant of 60.
5235 ///
5236 /// ```text
5237 /// score(d) = Σ 1 / (60 + rank_i(d)) (rank 1-based per list)
5238 /// ```
5239 ///
5240 /// Returns the top `k` nodes by fused score, ties broken by node key
5241 /// ascending (deterministic).
5242 ///
5243 /// # Vector leg fallback
5244 ///
5245 /// When `query_vec` is empty the vector leg is skipped entirely and
5246 /// results are ranked by the text list alone through the same RRF path
5247 /// (each text result scores `1/(60 + rank)` from that single list).
5248 ///
5249 /// When `label` is `None`, the vector leg **always** returns empty results.
5250 /// Internally `label` is mapped to `""`, which does not match any rule-created
5251 /// HNSW index (all such indexes are keyed to a specific non-empty label), and
5252 /// the brute-force fallback finds no nodes with an empty label. The fused
5253 /// ranking is therefore text-only in this case.
5254 pub fn search_hybrid(
5255 &self,
5256 text_field: &str,
5257 query_text: &str,
5258 vector_field: &str,
5259 query_vec: &[f64],
5260 label: Option<&str>,
5261 k: usize,
5262 ) -> Vec<(String, f64)> {
5263 use std::collections::HashMap;
5264
5265 const RRF_K: f64 = 60.0;
5266 let pool = 4 * k;
5267
5268 // Accumulate per-node RRF scores.
5269 let mut scores: HashMap<String, f64> = HashMap::new();
5270
5271 // Text leg.
5272 let text_hits = self.search(text_field, query_text);
5273 for (rank0, (key, _count)) in text_hits.into_iter().take(pool).enumerate() {
5274 let rank = (rank0 + 1) as f64;
5275 *scores.entry(key).or_insert(0.0) += 1.0 / (RRF_K + rank);
5276 }
5277
5278 // Vector leg (skipped when query_vec is empty).
5279 if !query_vec.is_empty() {
5280 let vec_hits = self.find_similar_vector(vector_field, label, query_vec, pool, 0.0);
5281 for (rank0, (key, _sim)) in vec_hits.into_iter().enumerate() {
5282 let rank = (rank0 + 1) as f64;
5283 *scores.entry(key).or_insert(0.0) += 1.0 / (RRF_K + rank);
5284 }
5285 }
5286
5287 // Sort: score DESC, then key ASC for deterministic tie-breaking.
5288 let mut ranked: Vec<(String, f64)> = scores.into_iter().collect();
5289 ranked.sort_by(|a, b| {
5290 b.1.partial_cmp(&a.1)
5291 .unwrap_or(std::cmp::Ordering::Equal)
5292 .then(a.0.cmp(&b.0))
5293 });
5294 ranked.truncate(k);
5295 ranked
5296 }
5297
5298 /// For DST/testing: scratch BM25 search over live nodes without the index.
5299 /// Walks every live node, re-stems field tokens, computes corpus stats, and
5300 /// returns BM25-ranked results.
5301 ///
5302 /// The oracle: the ordered key list of `search(field, q)` must equal that of
5303 /// `scratch_search(field, q)` at every quiescent state.
5304 #[doc(hidden)]
5305 pub fn scratch_search(&self, field: &str, query: &str) -> Vec<(String, f64)> {
5306 use core_storage::fulltext::{parse_query, value_tokens_stemmed_with_positions};
5307 use std::collections::BTreeMap;
5308
5309 let groups = parse_query(query);
5310 if groups.is_empty() {
5311 return vec![];
5312 }
5313
5314 // --- Pass 1: collect all live indexed nodes with stemmed token data ---
5315 struct NodeData {
5316 key: String,
5317 /// stemmed_token → positions (sorted)
5318 tokens: BTreeMap<String, Vec<u32>>,
5319 dl: u32,
5320 }
5321
5322 let mut nodes: Vec<NodeData> = Vec::new();
5323 for id in 0..self.ids.len() as u32 {
5324 let Some(key) = self.ids.key_of(id) else {
5325 continue;
5326 };
5327 let Some(&sym) = self.labels.get(id as usize) else {
5328 continue;
5329 };
5330 if sym == u32::MAX {
5331 continue;
5332 }
5333 let label = match self.syms.resolve(sym) {
5334 Some(l) => l,
5335 None => continue,
5336 };
5337 if !self.fulltext.is_enabled(label, field) {
5338 continue;
5339 }
5340 let Some(value) = self.props_view().get(id, field).map(|vr| vr.into_value()) else {
5341 continue;
5342 };
5343 // Use value_tokens_stemmed_with_positions so list elements are
5344 // separated by POSITION_GAP — identical to the index path, which
5345 // prevents phrase queries from matching across element boundaries.
5346 let stemmed_with_pos = match &value {
5347 Value::Str(_) | Value::List(_) => value_tokens_stemmed_with_positions(&value),
5348 _ => continue,
5349 };
5350 let dl = stemmed_with_pos.len() as u32;
5351 let mut tok_map: BTreeMap<String, Vec<u32>> = BTreeMap::new();
5352 for (tok, pos) in stemmed_with_pos {
5353 tok_map.entry(tok).or_default().push(pos);
5354 }
5355 nodes.push(NodeData {
5356 key: key.to_string(),
5357 tokens: tok_map,
5358 dl,
5359 });
5360 }
5361
5362 if nodes.is_empty() {
5363 return vec![];
5364 }
5365
5366 // --- BM25 corpus stats ---
5367 let n = nodes.len() as f64;
5368 let avg_dl: f64 = nodes.iter().map(|nd| nd.dl as f64).sum::<f64>() / n;
5369 // df per stemmed token across all live indexed nodes.
5370 let mut df_map: BTreeMap<&str, f64> = BTreeMap::new();
5371 for nd in &nodes {
5372 for tok in nd.tokens.keys() {
5373 *df_map.entry(tok.as_str()).or_insert(0.0) += 1.0;
5374 }
5375 }
5376
5377 const K1: f64 = 1.2;
5378 const B: f64 = 0.75;
5379
5380 // --- Pass 2: score each node against each OR-group ---
5381 let mut results: Vec<(String, f64)> = Vec::new();
5382 for nd in &nodes {
5383 let dl = nd.dl as f64;
5384 let mut total_score = 0.0f64;
5385
5386 'group: for group in &groups {
5387 let mut group_score = 0.0f64;
5388
5389 for term in group {
5390 if term.negated {
5391 // Negated: if doc has this stemmed token → group fails.
5392 let present = if term.prefix {
5393 nd.tokens.keys().any(|t| t.starts_with(term.token.as_str()))
5394 } else {
5395 nd.tokens.contains_key(term.token.as_str())
5396 };
5397 if present {
5398 continue 'group;
5399 }
5400 continue;
5401 }
5402 if term.prefix {
5403 // Prefix: sum BM25 for all matching stemmed tokens.
5404 let mut prefix_matched = false;
5405 for (tok, positions) in &nd.tokens {
5406 if tok.starts_with(term.token.as_str()) {
5407 let tf = positions.len() as f64;
5408 let df = df_map.get(tok.as_str()).copied().unwrap_or(1.0);
5409 let idf = ((n - df + 0.5) / (df + 0.5) + 1.0).ln();
5410 let tf_norm =
5411 tf * (K1 + 1.0) / (tf + K1 * (1.0 - B + B * dl / avg_dl));
5412 group_score += idf * tf_norm;
5413 prefix_matched = true;
5414 }
5415 }
5416 if !prefix_matched {
5417 continue 'group;
5418 }
5419 } else {
5420 // term.token is already stemmed by parse_query; use directly.
5421 match nd.tokens.get(term.token.as_str()) {
5422 None => continue 'group,
5423 Some(positions) => {
5424 let tf = positions.len() as f64;
5425 let df = df_map.get(term.token.as_str()).copied().unwrap_or(1.0);
5426 let idf = ((n - df + 0.5) / (df + 0.5) + 1.0).ln();
5427 let tf_norm =
5428 tf * (K1 + 1.0) / (tf + K1 * (1.0 - B + B * dl / avg_dl));
5429 group_score += idf * tf_norm;
5430 }
5431 }
5432 }
5433 }
5434
5435 if group_score > 0.0 {
5436 total_score += group_score;
5437 }
5438 }
5439
5440 if total_score > 0.0 {
5441 results.push((nd.key.clone(), total_score));
5442 }
5443 }
5444
5445 results.sort_by(|a, b| {
5446 b.1.partial_cmp(&a.1)
5447 .unwrap_or(std::cmp::Ordering::Equal)
5448 .then(a.0.cmp(&b.0))
5449 });
5450 results
5451 }
5452
5453 /// Return the current view-maintained value of `view_prop` for node `key`.
5454 /// Equivalent to `get_prop` but documents that it reads a view-managed column.
5455 pub fn get_view_prop(&self, key: &str, view_prop: &str) -> Option<Value> {
5456 let id = self.ids.get(key)?;
5457 self.props_view()
5458 .get(id, view_prop)
5459 .map(|vr| vr.into_value())
5460 }
5461
5462 /// For testing / DST oracle: scratch recompute of a view value for one node.
5463 ///
5464 /// Returns `None` if the node does not exist, the view does not exist, or
5465 /// the view has no result for the node (e.g. Avg with no qualifying neighbors).
5466 #[doc(hidden)]
5467 pub fn scratch_view_value(&self, key: &str, view_name: &str) -> Option<Value> {
5468 let node = self.ids.get(key)?;
5469 let def = self.view_store.views().find(|v| v.name == view_name)?;
5470 // Use TopologyView so that NeighborAgg sees base + overlay edges
5471 // without materialising a temporary Topology (I1).
5472 let topo_view = self.topo_view();
5473 core_rules::views::compute_view_value(
5474 def,
5475 node,
5476 self.props_view(),
5477 &topo_view,
5478 &self.ids,
5479 &self.syms,
5480 &self.labels,
5481 )
5482 }
5483
5484 // -----------------------------------------------------------------------
5485 // Graph algorithm API
5486 // -----------------------------------------------------------------------
5487
5488 /// Run PageRank over the unified topology (manual + derived edges).
5489 ///
5490 /// Returns a [`PageRankReport`] with scores sorted descending (ties: key
5491 /// ascending). Set `config.edge_type` to restrict to one edge type.
5492 /// `config.converged` is `true` only when the power iteration converged
5493 /// within `config.max_iters` and within any time budget.
5494 pub fn pagerank(&self, config: &crate::algo::PageRankConfig) -> crate::algo::PageRankReport {
5495 let topo = build_topo_view(&self.topo, &self.base);
5496 crate::algo::pagerank(&topo, &self.ids, &self.syms, &self.labels, config)
5497 }
5498
5499 /// Weakly-connected components over the unified topology (treated as
5500 /// undirected regardless of how edges were inserted).
5501 ///
5502 /// Component IDs are the key of the smallest member in the component
5503 /// (deterministic). Result sorted by (component_id, key).
5504 pub fn connected_components(&self, config: &crate::algo::WccConfig) -> crate::algo::WccReport {
5505 let topo = build_topo_view(&self.topo, &self.base);
5506 crate::algo::wcc(&topo, &self.ids, &self.syms, &self.labels, config)
5507 }
5508
5509 /// Degree centrality for every live node.
5510 ///
5511 /// `direction`: `AlgoDir::Out` = out-degree, `AlgoDir::In` = in-degree,
5512 /// `AlgoDir::Both` = out + in (total directed degree).
5513 ///
5514 /// For one-shot ranking use this; for a live property updated on every
5515 /// write, create a Degree materialized view instead (see `docs/site/algorithms.md`).
5516 pub fn degree_centrality(
5517 &self,
5518 config: &crate::algo::DegreeConfig,
5519 ) -> crate::algo::DegreeReport {
5520 let topo = build_topo_view(&self.topo, &self.base);
5521 crate::algo::degree_centrality(&topo, &self.ids, &self.syms, &self.labels, config)
5522 }
5523
5524 /// Write a vector of `(node_key, score)` pairs as `prop_name` on each node,
5525 /// atomically via a single write-batch (one WAL frame, one fsync).
5526 ///
5527 /// # Errors
5528 /// - [`GraphError::ReadOnly`]: called on an as-of instance.
5529 /// - [`GraphError::RuleInvalid`]: `prop_name` is managed by an existing view
5530 /// (collision check mirrors `create_view`).
5531 /// - [`GraphError::KeyNotFound`]: a key in `scores` does not exist as a live node.
5532 pub fn write_scores(&mut self, prop_name: &str, scores: &[(String, f64)]) -> Result<()> {
5533 if self.read_only {
5534 return Err(GraphError::ReadOnly);
5535 }
5536 // Collision check: refuse if prop_name is view-managed.
5537 if let Some(view_name) = self.view_store.view_for_prop(prop_name) {
5538 return Err(GraphError::RuleInvalid {
5539 detail: format!(
5540 "prop {:?} is managed by view {:?} and cannot be written as scores",
5541 prop_name, view_name
5542 ),
5543 });
5544 }
5545 // Refuse if prop_name is a view name itself (confusing namespace collision).
5546 if self.view_store.has_view(prop_name) {
5547 return Err(GraphError::RuleInvalid {
5548 detail: format!(
5549 "prop_name {:?} collides with an existing view name",
5550 prop_name
5551 ),
5552 });
5553 }
5554 // Write all scores in a single crash-atomic batch.
5555 self.write_batch(|b| {
5556 for (key, score) in scores {
5557 b.set_prop(key, prop_name, Value::Float(*score));
5558 }
5559 })?;
5560 Ok(())
5561 }
5562
5563 /// Return the value of `field` for the node with key `key`, or `None` if
5564 /// the node or field is absent. Reads through the overlay-over-base
5565 /// `ColumnsView`, materialising base values on demand (zero heap cost for
5566 /// overlay hits; one clone per base hit).
5567 pub fn get_prop(&self, key: &str, field: &str) -> Option<Value> {
5568 let id = self.ids.get(key)?;
5569 self.props_view().get(id, field).map(|vr| vr.into_value())
5570 }
5571
5572 pub fn has_node(&self, key: &str) -> bool {
5573 self.ids.get(key).is_some()
5574 }
5575
5576 /// Borrow the raw id map. Used by `NodeMask::from_keys` to resolve keys.
5577 pub(crate) fn ids(&self) -> &IdMap {
5578 &self.ids
5579 }
5580
5581 // -----------------------------------------------------------------------
5582 // RBAC role resolution
5583 // -----------------------------------------------------------------------
5584
5585 /// Parse `roles.json` bytes from `fs`.
5586 ///
5587 /// Return values:
5588 /// `Ok(Some(roles))` — file absent (returns `vec![]`) **or** file present
5589 /// and valid; in both cases `mask_for_role` uses the
5590 /// list normally (an absent file means no roles defined).
5591 /// `Ok(None)` — file present but corrupt or unrecognised version
5592 /// → poisoned state; `mask_for_role` returns `Err` for
5593 /// any role name until the file is fixed and the DB
5594 /// re-opened (or `apply_schema` is called to repair it).
5595 ///
5596 /// Note: `None` signals corruption, not absence — the opposite of what an
5597 /// optional "file missing" convention would suggest. The open path stores
5598 /// this result on `db.roles` directly.
5599 fn load_roles_from_fs(fs: &F) -> Result<Option<Vec<RoleDef>>> {
5600 let bytes = fs.read(FileId::Roles).map_err(GraphError::Io)?;
5601 if bytes.is_empty() {
5602 // Empty bytes means either the file is absent or zero-byte — both
5603 // are treated identically as "no roles defined". A zero-byte
5604 // roles.json does NOT widen access: an absent file and a zero-byte
5605 // file both resolve to an empty role list (sees nothing by default).
5606 return Ok(Some(vec![]));
5607 }
5608 match serde_json::from_slice::<RolesFile>(&bytes) {
5609 Ok(f) if f.version == 1 || f.version == 2 => Ok(Some(f.roles)),
5610 // Corrupt or unrecognised version (>2): poison the roles state.
5611 _ => Ok(None),
5612 }
5613 }
5614
5615 /// Resolve a role to a node-visibility mask against the current graph state.
5616 ///
5617 /// Returns `Err` when:
5618 /// - `roles.json` was present but corrupt at open (poisoned state), or
5619 /// - `role` does not match any defined role name.
5620 ///
5621 /// The mask union is: explicit `keys` (unknown keys silently ignored) plus
5622 /// all live nodes carrying any label in `labels`. Label resolution is live
5623 /// — new nodes of an allowed label are visible without re-applying the
5624 /// schema. An empty union = empty mask = sees nothing.
5625 pub fn mask_for_role(&self, role: &str) -> Result<crate::mask::NodeMask> {
5626 let roles = self.roles.as_ref().ok_or_else(|| GraphError::Corrupt {
5627 detail:
5628 "roles.json was corrupt at open; fix the file and re-open to restore role access"
5629 .into(),
5630 })?;
5631 let def = roles
5632 .iter()
5633 .find(|r| r.name == role)
5634 .ok_or_else(|| GraphError::KeyNotFound {
5635 key: format!("role:{role}"),
5636 })?;
5637
5638 let mut visible = std::collections::HashSet::new();
5639
5640 // Key leg: resolve explicit keys to dense ids (unknown keys ignored).
5641 for key in &def.keys {
5642 if let Some(id) = self.ids.get(key) {
5643 visible.insert(id);
5644 }
5645 }
5646
5647 // Label leg: live scan — iterate labels vec for matching symbol.
5648 for label_name in &def.labels {
5649 if let Some(sym) = self.syms.get(label_name) {
5650 for (i, &s) in self.labels.iter().enumerate() {
5651 if s == sym {
5652 visible.insert(i as u32);
5653 }
5654 }
5655 }
5656 }
5657
5658 Ok(crate::mask::NodeMask::from_ids(visible))
5659 }
5660
5661 /// Return the current list of role definitions.
5662 ///
5663 /// Returns an empty list when no roles are defined or when `roles.json`
5664 /// was corrupt at open (check [`mask_for_role`](Self::mask_for_role) for
5665 /// the fail-loud error in that case).
5666 pub fn roles(&self) -> Vec<RoleDef> {
5667 self.roles.as_deref().unwrap_or(&[]).to_vec()
5668 }
5669
5670 // ── Role-scoped write authz ───────────────────────────────────────────────
5671
5672 /// Execute `ops` with optional role-scoped write authorization.
5673 ///
5674 /// - `None` → full authority, identical to [`write_batch`](Self::write_batch)
5675 /// (zero-cost bypass of all authz checks).
5676 /// - `Some(authz)` → the decision table is evaluated per-op BEFORE any WAL
5677 /// record is built. A denial returns an error with no WAL frame written
5678 /// (all-or-nothing at the authz boundary, then at the MutPreview boundary).
5679 ///
5680 /// See the plan's "authz decision table" section for the full semantics.
5681 pub fn write_batch_authz(
5682 &mut self,
5683 authz: Option<&WriteAuthz>,
5684 ops: Vec<BatchOp>,
5685 ) -> Result<(usize, usize)> {
5686 // Thread authz as a direct parameter — never touches pending_write_authz.
5687 self.commit_logged_batch(ops, None, authz.cloned())
5688 }
5689
5690 /// Execute a Cypher write statement with role-scoped write authorization.
5691 ///
5692 /// Resolves scope + mask from `self.roles` inside the call (same write-guard
5693 /// lifetime as execution, satisfying §5 lock discipline). The resolved
5694 /// `WriteAuthz` is stored as `pending_write_authz` for the duration of the
5695 /// call so that all inner `batch.commit()` calls are authz-checked.
5696 ///
5697 /// MERGE is handled specially: the MERGE scope precondition (§3.3) is
5698 /// checked in `exec_merge` BEFORE `has_node` to close the §6.2
5699 /// timing-oracle item (hidden ≡ absent for unscoped roles).
5700 ///
5701 /// Roles with `write: None` (v1 behavior) → `RoleWriteDenied` with
5702 /// "this endpoint is not permitted".
5703 pub fn query_write_authz(
5704 &mut self,
5705 role: &str,
5706 cypher: &str,
5707 params: &BTreeMap<String, Value>,
5708 ) -> Result<ResultSet> {
5709 // Resolve scope (fails fast if role has no write scope).
5710 // write:None → byte-identical v1 blanket-403 body (plan §v1-sidecar mandate).
5711 let scope =
5712 {
5713 let roles = self.roles.as_deref().ok_or_else(|| GraphError::Corrupt {
5714 detail: "roles.json was corrupt at open; re-open to restore role access".into(),
5715 })?;
5716 let def = roles.iter().find(|r| r.name == role).ok_or_else(|| {
5717 GraphError::KeyNotFound {
5718 key: format!("role:{role}"),
5719 }
5720 })?;
5721 def.write
5722 .clone()
5723 .ok_or_else(|| GraphError::RoleWriteDenied {
5724 reason: "role-bound token: writes are not permitted".into(),
5725 })?
5726 };
5727 // Resolve mask inside the call (same guard, §5 coherence).
5728 let mask = self.mask_for_role(role)?;
5729 self.pending_write_authz = Some(WriteAuthz {
5730 role: role.into(),
5731 scope,
5732 mask,
5733 });
5734 // RAII guard: always clears pending_write_authz on scope exit, including
5735 // on panic or early-return, mirroring the RestoreEmitDeltas precedent.
5736 struct ClearPendingAuthzOnDrop(*mut Option<WriteAuthz>);
5737 impl Drop for ClearPendingAuthzOnDrop {
5738 fn drop(&mut self) {
5739 // SAFETY: pointer into the owning GraphDb; guard is dropped
5740 // within this function's frame before it returns.
5741 unsafe { *self.0 = None };
5742 }
5743 }
5744 // SAFETY: raw pointer into self; guard dropped before this fn returns.
5745 let _authz_guard = ClearPendingAuthzOnDrop(&mut self.pending_write_authz as *mut _);
5746 let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
5747 detail: format!("lex: {e}"),
5748 })?;
5749 let stmt = parse_write(&tokens).map_err(|e| GraphError::QueryError {
5750 detail: format!("parse: {e}"),
5751 })?;
5752 self.exec_write_stmt(stmt, params)
5753 }
5754
5755 /// Execute `ops` with optional role-scoped write authorization, suppressing
5756 /// fsync (for use inside the group-commit drain thread, which performs one
5757 /// group fsync after releasing the write lock).
5758 ///
5759 /// Identical to [`write_batch_authz`] except the fsync policy is temporarily
5760 /// forced to `Relaxed` for the duration of the call, matching the drain-thread
5761 /// contract established by [`commit_batch_nosync`].
5762 pub(crate) fn write_batch_authz_nosync(
5763 &mut self,
5764 authz: Option<&WriteAuthz>,
5765 ops: Vec<BatchOp>,
5766 ) -> Result<(usize, usize)> {
5767 let saved = self.fsync;
5768 struct RestoreFsync(*mut FsyncPolicy, FsyncPolicy);
5769 impl Drop for RestoreFsync {
5770 fn drop(&mut self) {
5771 // SAFETY: pointer into the owning GraphDb; guard is dropped
5772 // within the enclosing function's frame before it returns.
5773 unsafe { *self.0 = self.1 };
5774 }
5775 }
5776 // SAFETY: raw pointer into self; guard dropped before this fn returns.
5777 let _g = RestoreFsync(&mut self.fsync as *mut FsyncPolicy, saved);
5778 self.fsync = FsyncPolicy::Relaxed;
5779 self.commit_logged_batch(ops, None, authz.cloned())
5780 }
5781
5782 /// Execute a `/ingest` request with role-scoped write authorization.
5783 ///
5784 /// Resolves the role's `WriteScope` and `NodeMask` inside this call (same
5785 /// write-guard lifetime as the mutation, satisfying §5 lock discipline).
5786 /// Sets `pending_write_authz` for the duration of the call so that the
5787 /// `commit_ingest` → `commit_logged_batch` path picks up the authz context
5788 /// and evaluates the decision table per-op before any WAL write.
5789 ///
5790 /// §7.3: roles with empty `create_labels` will see every `InsertNode` op
5791 /// denied by the decision table with the appropriate §4.3 scope reason;
5792 /// no special HTTP-layer check is needed.
5793 ///
5794 /// Roles with `write: None` return `RoleWriteDenied` with
5795 /// "writes are not permitted" (byte-identical to v1 blanket 403).
5796 pub fn ingest_with_edges_authz(
5797 &mut self,
5798 role: &str,
5799 label: &str,
5800 rows: Vec<std::collections::BTreeMap<String, Value>>,
5801 opts: &crate::ingest::IngestOptions,
5802 edges: &[(String, String, String)],
5803 ) -> Result<crate::ingest::IngestReport> {
5804 // Resolve scope (fails fast if role has no write scope).
5805 // write:None → byte-identical v1 blanket-403 body (plan §v1-sidecar mandate).
5806 let scope =
5807 {
5808 let roles = self.roles.as_deref().ok_or_else(|| GraphError::Corrupt {
5809 detail: "roles.json was corrupt at open; re-open to restore role access".into(),
5810 })?;
5811 let def = roles.iter().find(|r| r.name == role).ok_or_else(|| {
5812 GraphError::KeyNotFound {
5813 key: format!("role:{role}"),
5814 }
5815 })?;
5816 def.write
5817 .clone()
5818 .ok_or_else(|| GraphError::RoleWriteDenied {
5819 reason: "role-bound token: writes are not permitted".into(),
5820 })?
5821 };
5822 let mask = self.mask_for_role(role)?;
5823 self.pending_write_authz = Some(WriteAuthz {
5824 role: role.into(),
5825 scope,
5826 mask,
5827 });
5828 // RAII guard: always clears pending_write_authz on scope exit, including
5829 // on panic or early-return, mirroring the RestoreEmitDeltas precedent.
5830 struct ClearPendingAuthzOnDrop(*mut Option<WriteAuthz>);
5831 impl Drop for ClearPendingAuthzOnDrop {
5832 fn drop(&mut self) {
5833 // SAFETY: pointer into the owning GraphDb; guard is dropped
5834 // within this function's frame before it returns.
5835 unsafe { *self.0 = None };
5836 }
5837 }
5838 // SAFETY: raw pointer into self; guard dropped before this fn returns.
5839 let _authz_guard = ClearPendingAuthzOnDrop(&mut self.pending_write_authz as *mut _);
5840 self.ingest_with_edges(label, rows, opts, edges)
5841 }
5842
5843 /// Evaluate the write-authz decision table for one `BatchOp`.
5844 ///
5845 /// Called by `commit_logged_batch` for each op when `pending_write_authz`
5846 /// is `Some`, BEFORE MutPreview. A denial returns an error immediately;
5847 /// the remaining ops are not evaluated and no WAL frame is written.
5848 ///
5849 /// `batch_created` carries the key→label pairs of nodes that earlier ops in
5850 /// THIS batch will create. Used by `InsertEdgeUpsert` to count same-batch
5851 /// placeholder nodes as visible (spec: "a placeholder endpoint the SAME
5852 /// batch creates counts as visible if its label passed the create-class gate").
5853 fn check_single_op_authz(
5854 &self,
5855 authz: &WriteAuthz,
5856 op: &BatchOp,
5857 batch_created: &BTreeMap<String, String>,
5858 ) -> Result<()> {
5859 // Helper: 3-way node status under the authz mask.
5860 //
5861 // Batch-created nodes (from earlier InsertNode in THIS batch) are treated
5862 // as Visible with their recorded label — their create gate already passed
5863 // and they are not yet in self.ids (not committed). This fixes the
5864 // MERGE+ON CREATE SET case where InsertNode + SetProp arrive together:
5865 // the SetProp must not see the node as Absent.
5866 let node_status = |key: &str| -> NodeAuthzStatus {
5867 if let Some(label) = batch_created.get(key) {
5868 return NodeAuthzStatus::Visible(label.clone());
5869 }
5870 match self.ids.get(key) {
5871 None => NodeAuthzStatus::Absent,
5872 Some(id) if !authz.mask.contains_id(id) => NodeAuthzStatus::Hidden,
5873 Some(id) => {
5874 let label = self
5875 .labels
5876 .get(id as usize)
5877 .and_then(|&sym| {
5878 if sym == u32::MAX {
5879 None
5880 } else {
5881 self.syms.resolve(sym).map(str::to_string)
5882 }
5883 })
5884 .unwrap_or_default();
5885 NodeAuthzStatus::Visible(label)
5886 }
5887 }
5888 };
5889
5890 // Helper: is an InsertEdgeUpsert endpoint visible?
5891 // A same-batch placeholder counts as visible if its label passed
5892 // the create-class gate (spec "upsert placeholder-counts-as-visible").
5893 let upsert_ep_visible = |ep_key: &str, placeholder_label: &str| -> bool {
5894 // In store and visible?
5895 if let Some(id) = self.ids.get(ep_key) {
5896 return authz.mask.contains_id(id);
5897 }
5898 // Created by an earlier op in this batch?
5899 if let Some(created_label) = batch_created.get(ep_key) {
5900 return authz.scope.create_labels.contains(created_label);
5901 }
5902 // Will be created by THIS InsertEdgeUpsert: placeholder_label
5903 // must pass the create-class gate.
5904 authz
5905 .scope
5906 .create_labels
5907 .contains(&placeholder_label.to_string())
5908 };
5909
5910 match op {
5911 // RenameNode / CreateRule / DeleteRule: defense-in-depth gate.
5912 // These ops are never routed to role-scoped paths by the HTTP layer,
5913 // but we 403 them here to close any future bypass route.
5914 BatchOp::RenameNode { .. } | BatchOp::CreateRule(_) | BatchOp::DeleteRule { .. } => {
5915 return Err(GraphError::RoleWriteDenied {
5916 reason: "role-bound token: this endpoint is not permitted".into(),
5917 });
5918 }
5919
5920 // ── CREATE-class: InsertNode ─────────────────────────────────────
5921 //
5922 // Decision table row 1 (scope-before-lookup): check label in
5923 // create_labels BEFORE any key lookup. This is the structural
5924 // closure of the §6.2 timing-oracle item — the denial fires even
5925 // when the store is EMPTY (see test_create_scope_denied_empty_store).
5926 BatchOp::InsertNode { label, key, .. } => {
5927 if !authz.scope.create_labels.contains(label) {
5928 return Err(GraphError::RoleWriteDenied {
5929 reason: format!(
5930 "role-bound token: label '{}' not in write scope (create_labels)",
5931 label
5932 ),
5933 });
5934 }
5935 // Row 2/3: key lookup.
5936 match self.ids.get(key.as_str()) {
5937 Some(id) if authz.mask.contains_id(id) => {
5938 // Visible: DuplicateKey — let MutPreview handle this.
5939 }
5940 Some(_) => {
5941 // Hidden: indistinguishable from absent to the role.
5942 return Err(GraphError::RoleWriteDenied {
5943 reason: "role-bound token: target node not visible".into(),
5944 });
5945 }
5946 None => {
5947 // Absent: proceed (create).
5948 }
5949 }
5950 }
5951
5952 // ── UPDATE-class: SetProp, RemoveProp ────────────────────────────
5953 BatchOp::SetProp { key, .. } | BatchOp::RemoveProp { key, .. } => {
5954 if batch_created.contains_key(key.as_str()) {
5955 // Batch-created node: create gate already passed this batch.
5956 // Updating it in the same batch is always allowed, regardless
5957 // of update_labels (ruling §3.5: "writer just created it").
5958 } else {
5959 let label = match node_status(key) {
5960 NodeAuthzStatus::Visible(lbl) => lbl,
5961 _ => {
5962 return Err(GraphError::RoleWriteDenied {
5963 reason: "role-bound token: target node not visible".into(),
5964 });
5965 }
5966 };
5967 if !authz.scope.update_labels.contains(&label) {
5968 return Err(GraphError::RoleWriteDenied {
5969 reason: format!(
5970 "role-bound token: label '{}' not in write scope (update_labels)",
5971 label
5972 ),
5973 });
5974 }
5975 }
5976 }
5977
5978 // ── DELETE-class: DeleteNode ─────────────────────────────────────
5979 BatchOp::DeleteNode { key } => {
5980 let label = match node_status(key) {
5981 NodeAuthzStatus::Visible(lbl) => lbl,
5982 _ => {
5983 return Err(GraphError::RoleWriteDenied {
5984 reason: "role-bound token: target node not visible".into(),
5985 });
5986 }
5987 };
5988 if !authz.scope.delete_labels.contains(&label) {
5989 return Err(GraphError::RoleWriteDenied {
5990 reason: format!(
5991 "role-bound token: label '{}' not in write scope (delete_labels)",
5992 label
5993 ),
5994 });
5995 }
5996 }
5997
5998 // ── DELETE-class: DeleteEdge ─────────────────────────────────────
5999 //
6000 // Derived-edge rejection runs BEFORE the delete_edge_types scope
6001 // check (spec §3.5: "existing derived-edge rejection precedes
6002 // delete_edge_types check").
6003 BatchOp::DeleteEdge {
6004 edge_type,
6005 src_key,
6006 dst_key,
6007 } => {
6008 // Check provenance ownership BEFORE scope (spec §3.5 ordering).
6009 if let (Some(src_id), Some(dst_id), Some(et_sym)) = (
6010 self.ids.get(src_key.as_str()),
6011 self.ids.get(dst_key.as_str()),
6012 self.syms.get(edge_type.as_str()),
6013 ) {
6014 if self.engine.is_owned(et_sym, src_id, dst_id) {
6015 return Err(GraphError::RuleOwned {
6016 detail: format!(
6017 "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
6018 delete or change the owning rule"
6019 ),
6020 });
6021 }
6022 // Also check would_derive via MutPreview (empty overlay, pre-batch).
6023 let preview = MutPreview::new(self);
6024 if preview.would_derive(edge_type, src_key, dst_key) {
6025 return Err(GraphError::RuleOwned {
6026 detail: format!(
6027 "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
6028 delete or change the owning rule, or a live rule would \
6029 re-derive it"
6030 ),
6031 });
6032 }
6033 }
6034 // Scope check (AFTER derived-edge check, BEFORE endpoint visibility).
6035 if !authz.scope.delete_edge_types.contains(edge_type) {
6036 return Err(GraphError::RoleWriteDenied {
6037 reason: format!(
6038 "role-bound token: edge type '{}' not in write scope (delete_edge_types)",
6039 edge_type
6040 ),
6041 });
6042 }
6043 // Both endpoints must be visible.
6044 for ep_key in [src_key.as_str(), dst_key.as_str()] {
6045 match self.ids.get(ep_key) {
6046 None => {
6047 return Err(GraphError::RoleWriteDenied {
6048 reason: "role-bound token: edge endpoint not visible".into(),
6049 });
6050 }
6051 Some(id) if !authz.mask.contains_id(id) => {
6052 return Err(GraphError::RoleWriteDenied {
6053 reason: "role-bound token: edge endpoint not visible".into(),
6054 });
6055 }
6056 _ => {}
6057 }
6058 }
6059 }
6060
6061 // ── EDGE-CREATE: InsertEdge ──────────────────────────────────────
6062 //
6063 // Scope check BEFORE endpoint lookup (preserves timing symmetry).
6064 BatchOp::InsertEdge {
6065 edge_type,
6066 src_key,
6067 dst_key,
6068 } => {
6069 if !authz.scope.create_edge_types.contains(edge_type) {
6070 return Err(GraphError::RoleWriteDenied {
6071 reason: format!(
6072 "role-bound token: edge type '{}' not in write scope (create_edge_types)",
6073 edge_type
6074 ),
6075 });
6076 }
6077 // Both endpoints must be visible. A node created by an earlier
6078 // InsertNode in the same batch (tracked in batch_created) counts
6079 // as visible if its label passed the create-class gate.
6080 for ep_key in [src_key.as_str(), dst_key.as_str()] {
6081 if batch_created.contains_key(ep_key) {
6082 // Created earlier this batch — already scope-checked.
6083 continue;
6084 }
6085 match self.ids.get(ep_key) {
6086 None => {
6087 return Err(GraphError::RoleWriteDenied {
6088 reason: "role-bound token: edge endpoint not visible".into(),
6089 });
6090 }
6091 Some(id) if !authz.mask.contains_id(id) => {
6092 return Err(GraphError::RoleWriteDenied {
6093 reason: "role-bound token: edge endpoint not visible".into(),
6094 });
6095 }
6096 _ => {}
6097 }
6098 }
6099 }
6100
6101 // ── EDGE-CREATE: InsertEdgeUpsert ────────────────────────────────
6102 //
6103 // Scope check first; then endpoint visibility using same-batch
6104 // placeholder awareness (spec: "a placeholder endpoint the SAME
6105 // batch creates counts as visible if its label passed the
6106 // create-class gate").
6107 BatchOp::InsertEdgeUpsert {
6108 edge_type,
6109 src_key,
6110 dst_key,
6111 placeholder_label,
6112 } => {
6113 if !authz.scope.create_edge_types.contains(edge_type) {
6114 return Err(GraphError::RoleWriteDenied {
6115 reason: format!(
6116 "role-bound token: edge type '{}' not in write scope (create_edge_types)",
6117 edge_type
6118 ),
6119 });
6120 }
6121 // Check placeholder label against create_labels (create-class gate).
6122 // This ensures the auto-created endpoints are scope-allowed.
6123 for ep_key in [src_key.as_str(), dst_key.as_str()] {
6124 if !upsert_ep_visible(ep_key, placeholder_label) {
6125 return Err(GraphError::RoleWriteDenied {
6126 reason: "role-bound token: edge endpoint not visible".into(),
6127 });
6128 }
6129 }
6130 }
6131 }
6132 Ok(())
6133 }
6134
6135 /// Write `roles` to `roles.json` atomically and update the in-memory list.
6136 ///
6137 /// Called by `apply_schema` when roles change. Never called on unchanged
6138 /// re-apply — this preserves byte-identical idempotency.
6139 pub(crate) fn commit_roles(&mut self, roles: Vec<RoleDef>) -> Result<()> {
6140 let file = RolesFile::new_versioned(roles.clone());
6141 let bytes = serde_json::to_vec(&file).map_err(|e| GraphError::Corrupt {
6142 detail: format!("roles serialization: {e}"),
6143 })?;
6144 self.fs
6145 .write_atomic(FileId::Roles, &bytes)
6146 .map_err(GraphError::Io)?;
6147 self.roles = Some(roles);
6148 // Refresh the MVCC frozen overlay so that reader() immediately sees the
6149 // updated role definitions without waiting for the next K-commit fold.
6150 self.fold_now();
6151 Ok(())
6152 }
6153
6154 fn view(&self) -> GraphView<'_> {
6155 GraphView {
6156 ids: &self.ids,
6157 syms: &self.syms,
6158 labels: &self.labels,
6159 props: self.props_view(),
6160 topo: self.topo_view(),
6161 edge_props: self.edge_props_view(),
6162 mask: None,
6163 prop_index: Some(&self.prop_index),
6164 }
6165 }
6166
6167 fn view_masked<'a>(&'a self, mask: &'a crate::mask::NodeMask) -> GraphView<'a> {
6168 GraphView {
6169 ids: &self.ids,
6170 syms: &self.syms,
6171 labels: &self.labels,
6172 props: self.props_view(),
6173 topo: self.topo_view(),
6174 edge_props: self.edge_props_view(),
6175 mask: Some(&mask.visible),
6176 prop_index: Some(&self.prop_index),
6177 }
6178 }
6179
6180 /// Execute a read-only Cypher query with a node visibility mask.
6181 ///
6182 /// Only nodes whose key is in `mask` are accessible: label scans, key
6183 /// lookups, and neighbor expansions all respect the mask. Edges where
6184 /// either endpoint is hidden are silently dropped.
6185 ///
6186 /// Returns `Err` with a "masked queries are read-only" message when
6187 /// `cypher` is a write statement (CREATE / MERGE / MATCH…SET / DELETE).
6188 pub fn query_masked(
6189 &self,
6190 cypher: &str,
6191 params: &std::collections::BTreeMap<String, Value>,
6192 mask: &crate::mask::NodeMask,
6193 ) -> Result<ResultSet> {
6194 // Reject write statements up front.
6195 let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
6196 detail: format!("lex: {e}"),
6197 })?;
6198 if is_write_tokens(&tokens) {
6199 return Err(GraphError::MaskedReadOnly);
6200 }
6201 let union = parse_read(&tokens).map_err(|e| GraphError::QueryError {
6202 detail: format!("parse: {e}"),
6203 })?;
6204 // Each UNION part executes against the same masked view, so the mask
6205 // applies uniformly across the chain.
6206 execute_union(&self.view_masked(mask), &union, &Params(params)).map_err(|e| {
6207 GraphError::QueryError {
6208 detail: format!("execute: {e}"),
6209 }
6210 })
6211 }
6212
6213 pub fn node_ref(&self, key: &str) -> Option<NodeRef<'_, F>> {
6214 let id = self.ids.get(key)?;
6215 Some(NodeRef { db: self, id })
6216 }
6217
6218 /// BFS neighborhood expansion restricted to visible nodes in `mask`.
6219 ///
6220 /// Hidden nodes are never used as traversal intermediaries in either
6221 /// [`MaskMode::Omit`] or [`MaskMode::Stub`] — a visible node reachable
6222 /// only through a hidden node will not appear in results.
6223 ///
6224 /// In [`MaskMode::Stub`] mode, hidden nodes that are direct neighbours of
6225 /// a visited visible node are appended to the result as stub rows
6226 /// (`label` column is `null`, same key+depth columns as visible rows).
6227 /// They are NOT added to the BFS frontier.
6228 ///
6229 /// Returns `None` when `key` does not exist (caller should 404).
6230 ///
6231 /// **SECURITY**: role-token callers always pass an Omit-mode mask, so
6232 /// stub rows are never produced on the role path.
6233 pub fn neighborhood_masked(
6234 &self,
6235 key: &str,
6236 depth: u32,
6237 edge_types: Option<&[&str]>,
6238 dir: Dir,
6239 mask: &crate::mask::NodeMask,
6240 ) -> Option<ResultSet> {
6241 let start_id = self.ids.get(key)?;
6242 let view = self.view_masked(mask);
6243 let resolved: Option<Vec<u32>> = edge_types.map(|names| {
6244 names
6245 .iter()
6246 .filter_map(|name| view.syms.get(name))
6247 .collect()
6248 });
6249 let nb = neighborhood(&view, start_id, depth, resolved.as_deref(), dir);
6250 let mut rs = ResultSet::new(vec!["key".into(), "label".into(), "depth".into()]);
6251 // Collect visible BFS results (start_id at depth 0, BFS nodes after).
6252 let mut visited: Vec<(u32, u32)> = Vec::with_capacity(nb.nodes.len() + 1);
6253 visited.push((start_id, 0));
6254 for (nid, d) in &nb.nodes {
6255 let k = view.key_of(*nid);
6256 let label = view
6257 .label_of(*nid)
6258 .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
6259 rs.push_row(vec![
6260 Some(Value::Str(k.to_string())),
6261 Some(Value::Str(label.to_string())),
6262 Some(Value::Int(*d as i64)),
6263 ]);
6264 visited.push((*nid, *d));
6265 }
6266 // Stub mode: add hidden direct neighbours of each visited node as stubs.
6267 // Hidden nodes are edge-endpoints only — they are not added to the BFS
6268 // frontier, so the BFS never expands through them.
6269 if mask.mode() == crate::mask::MaskMode::Stub {
6270 let raw_view = self.view();
6271 let mut seen: std::collections::HashSet<u32> =
6272 visited.iter().map(|(id, _)| *id).collect();
6273 for (node_id, node_depth) in &visited {
6274 if *node_depth >= depth {
6275 continue;
6276 }
6277 for e in expand(&raw_view, *node_id, resolved.as_deref(), dir) {
6278 let nbr = if e.src == *node_id { e.dst } else { e.src };
6279 if !mask.contains_id(nbr) && seen.insert(nbr) {
6280 if let Some(k) = self.ids.key_of(nbr) {
6281 rs.push_row(vec![
6282 Some(Value::Str(k.to_string())),
6283 None,
6284 Some(Value::Int((*node_depth + 1) as i64)),
6285 ]);
6286 }
6287 }
6288 }
6289 }
6290 }
6291 Some(rs)
6292 }
6293
6294 /// Live node's key, label, and columnar props. Unknown or tombstoned → `None`.
6295 pub fn node_info(&self, key: &str) -> Option<NodeInfo> {
6296 let n = self.node_ref(key)?;
6297 Some(NodeInfo {
6298 key: n.key().to_string(),
6299 label: n.label().to_string(),
6300 props: n.props(),
6301 })
6302 }
6303
6304 /// Look up a node with mask awareness.
6305 ///
6306 /// | Key state | Omit mode | Stub mode |
6307 /// |-------------------|-----------------|------------------------|
6308 /// | does not exist | `None` (→ 404) | `None` (→ 404) |
6309 /// | exists, visible | `Some(Visible)` | `Some(Visible)` |
6310 /// | exists, hidden | `None` (→ 404) | `Some(Restricted)` |
6311 ///
6312 /// **SECURITY**: only call from client-mask (full-token) paths.
6313 /// Role-token paths must use [`node_info`] after an explicit visibility check.
6314 pub fn node_info_masked(
6315 &self,
6316 key: &str,
6317 mask: &crate::mask::NodeMask,
6318 ) -> Option<MaskedNodeResult> {
6319 let id = self.ids.get(key)?;
6320 if mask.contains_id(id) {
6321 Some(MaskedNodeResult::Visible(self.node_info(key)?))
6322 } else {
6323 match mask.mode() {
6324 crate::mask::MaskMode::Stub => Some(MaskedNodeResult::Restricted),
6325 crate::mask::MaskMode::Omit => None,
6326 }
6327 }
6328 }
6329
6330 /// Get edges for `key` with mask-aware hidden-endpoint handling.
6331 ///
6332 /// - Omit mode: edges to hidden endpoints are excluded (same as role-path filtering).
6333 /// - Stub mode: edges to hidden endpoints are included; `src_restricted`/`dst_restricted`
6334 /// is `true` for each hidden endpoint.
6335 ///
6336 /// Unknown key → [`GraphError::KeyNotFound`].
6337 ///
6338 /// **SECURITY**: only call from client-mask (full-token) paths.
6339 pub fn node_edges_masked(
6340 &self,
6341 key: &str,
6342 mask: &crate::mask::NodeMask,
6343 ) -> Result<Vec<MaskedEdge>> {
6344 self.ensure_v8_base_sections_loaded();
6345 let id = self
6346 .ids
6347 .get(key)
6348 .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
6349 let derived: BTreeSet<(u32, u32, u32)> = self
6350 .engine
6351 .provenance_touching(id)
6352 .map(|(_rule, etype, src, dst)| (etype, src, dst))
6353 .collect();
6354 let mut edges = Vec::new();
6355 let tv = self.topo_view();
6356 for etype in tv.etypes() {
6357 // etype comes from the archived CSR (access_unchecked, no eager CRC).
6358 // A bit-flip in the large TOPOLOGY section can produce an etype id
6359 // that is not in the interner. Return Corrupt rather than panic.
6360 let edge_type = self
6361 .syms
6362 .resolve(etype)
6363 .ok_or_else(|| GraphError::Corrupt {
6364 detail: format!("v8: topology etype {etype} not in interner"),
6365 })?
6366 .to_string();
6367 for dir in [Direction::Out, Direction::In] {
6368 for &nbr in tv.neighbors(etype, dir, id).as_ref() {
6369 let nbr_restricted = !mask.contains_id(nbr);
6370 if nbr_restricted && mask.mode() == crate::mask::MaskMode::Omit {
6371 continue;
6372 }
6373 let nbr_key = self
6374 .ids
6375 .key_of(nbr)
6376 .ok_or_else(|| GraphError::Corrupt {
6377 detail: format!("topology id {nbr} has no key"),
6378 })?
6379 .to_string();
6380 let (src_id, dst_id, src_key, dst_key, src_restricted, dst_restricted) =
6381 match dir {
6382 Direction::Out => {
6383 (id, nbr, key.to_string(), nbr_key, false, nbr_restricted)
6384 }
6385 Direction::In => {
6386 (nbr, id, nbr_key, key.to_string(), nbr_restricted, false)
6387 }
6388 };
6389 edges.push(MaskedEdge {
6390 edge_type: edge_type.clone(),
6391 src_key,
6392 src_restricted,
6393 dst_key,
6394 dst_restricted,
6395 derived: derived.contains(&(etype, src_id, dst_id)),
6396 });
6397 }
6398 }
6399 }
6400 edges.sort_by(|a, b| {
6401 a.edge_type
6402 .cmp(&b.edge_type)
6403 .then(a.src_key.cmp(&b.src_key))
6404 .then(a.dst_key.cmp(&b.dst_key))
6405 });
6406 edges.dedup_by(|a, b| {
6407 a.edge_type == b.edge_type && a.src_key == b.src_key && a.dst_key == b.dst_key
6408 });
6409 Ok(edges)
6410 }
6411
6412 /// Every directed edge incident on `key`, both directions, every etype.
6413 ///
6414 /// Walk is `topology.etypes()` × `{Out, In}` × `neighbors()`. `derived` is
6415 /// membership in [`RuleEngine::provenance_touching`] (O(degree) via the
6416 /// Plan-8 `by_node` index). Sorted by `(edge_type, src_key, dst_key)`.
6417 /// Unknown key → [`GraphError::KeyNotFound`].
6418 pub fn node_edges(&self, key: &str) -> Result<Vec<EdgeInfo>> {
6419 self.ensure_v8_base_sections_loaded();
6420 let id = self
6421 .ids
6422 .get(key)
6423 .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
6424 let derived: BTreeSet<(u32, u32, u32)> = self
6425 .engine
6426 .provenance_touching(id)
6427 .map(|(_rule, etype, src, dst)| (etype, src, dst))
6428 .collect();
6429 let mut edges = Vec::new();
6430 let tv = self.topo_view();
6431 for etype in tv.etypes() {
6432 // Same guard as node_edges_masked: etype from unchecked-CRC CSR.
6433 let edge_type = self
6434 .syms
6435 .resolve(etype)
6436 .ok_or_else(|| GraphError::Corrupt {
6437 detail: format!("v8: topology etype {etype} not in interner"),
6438 })?
6439 .to_string();
6440 for dir in [Direction::Out, Direction::In] {
6441 for &nbr in tv.neighbors(etype, dir, id).as_ref() {
6442 let (src, dst, src_key, dst_key) = match dir {
6443 Direction::Out => (
6444 id,
6445 nbr,
6446 key.to_string(),
6447 self.ids
6448 .key_of(nbr)
6449 .ok_or_else(|| GraphError::Corrupt {
6450 detail: format!("topology id {nbr} has no key"),
6451 })?
6452 .to_string(),
6453 ),
6454 Direction::In => (
6455 nbr,
6456 id,
6457 self.ids
6458 .key_of(nbr)
6459 .ok_or_else(|| GraphError::Corrupt {
6460 detail: format!("topology id {nbr} has no key"),
6461 })?
6462 .to_string(),
6463 key.to_string(),
6464 ),
6465 };
6466 edges.push(EdgeInfo {
6467 edge_type: edge_type.clone(),
6468 src_key,
6469 dst_key,
6470 derived: derived.contains(&(etype, src, dst)),
6471 });
6472 }
6473 }
6474 }
6475 edges.sort_by(|a, b| {
6476 a.edge_type
6477 .cmp(&b.edge_type)
6478 .then(a.src_key.cmp(&b.src_key))
6479 .then(a.dst_key.cmp(&b.dst_key))
6480 });
6481 // Self-loops appear in both Out and In; sort makes the pair adjacent
6482 // (sort key matches PartialEq for this case) so one pass drops the dup.
6483 edges.dedup();
6484 Ok(edges)
6485 }
6486
6487 // ── Backup ────────────────────────────────────────────────────────────────
6488
6489 /// Copy this store to `dest` as a consistent, verified snapshot.
6490 ///
6491 /// Copies every durable file in the database directory — `snapshot.bin`,
6492 /// `wal.bin`, all `wal.<N>.archive` files, `wal.floor`, `wal.genesis`, and
6493 /// `roles.json` — into a freshly created `dest` directory using OS-level
6494 /// `copy` calls (no large in-process buffers).
6495 ///
6496 /// # Consistency guarantee
6497 ///
6498 /// The guarantee is **process-local**: the caller holds `&self`, which
6499 /// prevents any concurrent writer in the **same process** from modifying
6500 /// the files during the copy. Running `mushroomdb backup` against a
6501 /// directory that is **concurrently being written by another process** (e.g.
6502 /// `mushroomdb serve`) is **unsafe** — the copy can be torn. The post-copy
6503 /// `verified: true` result reduces but does not eliminate the risk of a
6504 /// silent corrupt backup (CRC catches many bit-flips; it cannot catch a
6505 /// consistent mid-write snapshot).
6506 ///
6507 /// **The safe path for a live-served store is `POST /backup` on the HTTP
6508 /// server.** That handler acquires the read lock on the shared database
6509 /// before calling this method, which is the correct cross-process
6510 /// synchronisation point because the server is the single process writing
6511 /// the files.
6512 ///
6513 /// After copying, opens the destination read-only and runs the CRC section
6514 /// verifier (`verify_snapshot`) to confirm byte-for-byte integrity.
6515 /// `BackupReport::verified` reflects whether both checks passed.
6516 ///
6517 /// Returns `Err` when `self` is not backed by a `RealFs` (e.g. `SimFs`).
6518 pub fn backup_to(&self, dest: &std::path::Path) -> Result<BackupReport> {
6519 // Derive source directory from snapshot_path (RealFs only).
6520 let src_dir = match self.fs.snapshot_path() {
6521 Some(p) => p.parent().map(|d| d.to_path_buf()).ok_or_else(|| {
6522 GraphError::Io(std::io::Error::other("snapshot has no parent dir"))
6523 })?,
6524 None => {
6525 return Err(GraphError::Io(std::io::Error::other(
6526 "backup_to requires a real filesystem (RealFs)",
6527 )))
6528 }
6529 };
6530
6531 std::fs::create_dir_all(dest)?;
6532
6533 let mut files: Vec<String> = Vec::new();
6534 let mut bytes: u64 = 0;
6535
6536 // Helper: copy src_dir/name → dest/name if the file exists.
6537 let mut try_copy = |name: &str| -> std::io::Result<()> {
6538 let src_path = src_dir.join(name);
6539 if src_path.exists() {
6540 let n = std::fs::copy(&src_path, dest.join(name))?;
6541 bytes += n;
6542 files.push(name.to_string());
6543 }
6544 Ok(())
6545 };
6546
6547 try_copy("snapshot.bin")?;
6548 try_copy("snapshot.bin.bak")?;
6549 try_copy("wal.bin")?;
6550 try_copy("wal.floor")?;
6551 try_copy("wal.genesis")?;
6552 try_copy("roles.json")?;
6553
6554 // Copy WAL archives.
6555 let archives = self.fs.list_archives()?;
6556 for n in &archives {
6557 let name = format!("wal.{n}.archive");
6558 let n_bytes = std::fs::copy(src_dir.join(&name), dest.join(&name))?;
6559 bytes += n_bytes;
6560 files.push(name);
6561 }
6562
6563 files.sort();
6564
6565 // Post-copy verification: open dest and run CRC checks.
6566 let snap_in_dest = dest.join("snapshot.bin").exists();
6567 let crc_ok = if snap_in_dest {
6568 crate::verify_snapshot(dest)
6569 .map(|results| results.iter().all(|(_, _, _, r)| r.is_ok()))
6570 .unwrap_or(false)
6571 } else {
6572 true // WAL-only store: nothing to CRC-check in snapshot
6573 };
6574 let opens_ok = GraphDb::<core_storage::fs::RealFs>::open(dest).is_ok();
6575 let verified = crc_ok && opens_ok;
6576
6577 Ok(BackupReport {
6578 files,
6579 bytes,
6580 verified,
6581 })
6582 }
6583
6584 // ── Export helpers ────────────────────────────────────────────────────────
6585
6586 /// All live nodes, sorted by key (deterministic).
6587 ///
6588 /// Reads base + WAL overlay. Tombstoned nodes are excluded.
6589 pub fn all_nodes_for_export(&self) -> Vec<NodeInfo> {
6590 self.ensure_v8_base_sections_loaded();
6591 let pv = self.props_view();
6592 let mut nodes = Vec::new();
6593 for id in 0..self.ids.len() as u32 {
6594 let Some(key) = self.ids.key_of(id) else {
6595 continue;
6596 };
6597 let Some(&sym) = self.labels.get(id as usize) else {
6598 continue;
6599 };
6600 if sym == u32::MAX {
6601 continue; // tombstoned
6602 }
6603 let Some(label) = self.syms.resolve(sym) else {
6604 continue;
6605 };
6606 let mut props = BTreeMap::new();
6607 for field in pv.field_names() {
6608 if let Some(vr) = pv.get(id, &field) {
6609 props.insert(field, vr.into_value());
6610 }
6611 }
6612 nodes.push(NodeInfo {
6613 key: key.to_string(),
6614 label: label.to_string(),
6615 props,
6616 });
6617 }
6618 nodes.sort_by(|a, b| a.key.cmp(&b.key));
6619 nodes
6620 }
6621
6622 /// All directed edges, sorted by `(edge_type, src, dst)`. Each edge appears once.
6623 ///
6624 /// Derived edges carry `derived: true` and the creating rule's name in `rule`.
6625 /// Manual edges carry `derived: false` and `rule: None`.
6626 /// Deterministic across runs on the same store state.
6627 pub fn all_edges_for_export(&self) -> Vec<ExportEdge> {
6628 self.ensure_v8_base_sections_loaded();
6629
6630 // Build (etype_sym, src_id, dst_id) → rule_name for O(1) derivation lookup.
6631 let mut prov: HashMap<(u32, u32, u32), String> = HashMap::new();
6632 for (rule_name, triples) in self.engine.provenance() {
6633 for &(etype, src, dst) in triples {
6634 prov.insert((etype, src, dst), rule_name.clone());
6635 }
6636 }
6637
6638 let tv = self.topo_view();
6639 let mut edges = Vec::new();
6640
6641 for id in 0..self.ids.len() as u32 {
6642 let Some(key) = self.ids.key_of(id) else {
6643 continue;
6644 };
6645 let Some(&lsym) = self.labels.get(id as usize) else {
6646 continue;
6647 };
6648 if lsym == u32::MAX {
6649 continue; // tombstoned
6650 }
6651
6652 for etype_sym in tv.etypes() {
6653 // etype from archived CSR (access_unchecked, no eager CRC).
6654 // Skip edges whose etype is not in the interner; this can only
6655 // occur with a corrupt large TOPOLOGY section (bit-flip on an
6656 // etype field in the archived data). The function returns Vec,
6657 // not Result, so we continue rather than propagate.
6658 let Some(edge_type) = self.syms.resolve(etype_sym) else {
6659 continue;
6660 };
6661 let edge_type = edge_type.to_string();
6662 for &nbr in tv.neighbors(etype_sym, Direction::Out, id).as_ref() {
6663 let Some(dst_key) = self.ids.key_of(nbr) else {
6664 continue; // skip corrupt entries
6665 };
6666 let prov_key = (etype_sym, id, nbr);
6667 let rule = prov.get(&prov_key).cloned();
6668 let derived = rule.is_some();
6669 edges.push(ExportEdge {
6670 edge_type: edge_type.clone(),
6671 src: key.to_string(),
6672 dst: dst_key.to_string(),
6673 derived,
6674 rule,
6675 });
6676 }
6677 }
6678 }
6679
6680 edges.sort_by(|a, b| {
6681 a.edge_type
6682 .cmp(&b.edge_type)
6683 .then(a.src.cmp(&b.src))
6684 .then(a.dst.cmp(&b.dst))
6685 });
6686 edges
6687 }
6688
6689 pub fn nodes_with_label(&self, label: &str) -> Vec<NodeRef<'_, F>> {
6690 self.view()
6691 .nodes_with_label(label)
6692 .into_iter()
6693 .map(|id| NodeRef { db: self, id })
6694 .collect()
6695 }
6696
6697 pub fn find_nodes(&self, label: &str, filter: &Filter) -> Vec<NodeRef<'_, F>> {
6698 let view = self.view();
6699 view.nodes_with_label(label)
6700 .into_iter()
6701 .filter(|&id| {
6702 eval_filter(filter, &|field| {
6703 view.prop(id, field).map(|vr| vr.into_value())
6704 })
6705 })
6706 .map(|id| NodeRef { db: self, id })
6707 .collect()
6708 }
6709
6710 /// Returns `true` if any approximate (HNSW) VectorSimilar rule covers
6711 /// `field`. Use as a capability probe: when `true`, `find_similar_vector`
6712 /// with `label = None` will use the native ANN path rather than the O(n)
6713 /// brute-force scan.
6714 pub fn has_vector_rule(&self, field: &str) -> bool {
6715 self.engine.hnsw_has_rule(field)
6716 }
6717
6718 /// Find nodes whose `field` vector is most similar to `q` (cosine
6719 /// similarity), returning up to `k` results with similarity ≥ `min`,
6720 /// sorted descending.
6721 ///
6722 /// When `label` is `None` the search spans all labels (via
6723 /// `hnsw_search_any_dst` or a full brute-force scan); when `label` is
6724 /// `Some(lbl)` it restricts to nodes with that label.
6725 ///
6726 /// Uses the HNSW index when one is available (fast path); otherwise falls
6727 /// back to an O(n) brute-force scan.
6728 pub fn find_similar_vector(
6729 &self,
6730 field: &str,
6731 label: Option<&str>,
6732 q: &[f64],
6733 k: usize,
6734 min: f64,
6735 ) -> Vec<(String, f64)> {
6736 // Ensure any HNSW blobs retained from the snapshot are deserialized
6737 // before the first ANN query on a clean-open (no-WAL) path.
6738 self.engine.ensure_hnsw_loaded();
6739 // L2-normalise query for cosine via dot product.
6740 let norm: f64 = q.iter().map(|x| x * x).sum::<f64>().sqrt();
6741 if norm == 0.0 {
6742 return vec![];
6743 }
6744 let q_unit: Vec<f64> = q.iter().map(|x| x / norm).collect();
6745
6746 // Try HNSW fast path.
6747 // `None` label searches across all VectorSimilar rules covering `field`
6748 // (merging their results); `Some(lbl)` restricts to rules whose
6749 // dst_label matches. Returns `None` when no populated HNSW index
6750 // covers the request — the O(n) brute-force fallback handles that case.
6751 let hnsw_hits = match label {
6752 Some(lbl) => self.engine.hnsw_search_dst(field, lbl, &q_unit, k),
6753 None => self.engine.hnsw_search_any_dst(field, &q_unit, k),
6754 };
6755 if let Some(hits) = hnsw_hits {
6756 let mut out: Vec<(String, f64)> = hits
6757 .into_iter()
6758 .filter(|&(_, sim)| sim >= min)
6759 .filter_map(|(id, sim)| self.ids.key_of(id).map(|key| (key.to_string(), sim)))
6760 .collect();
6761 out.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
6762 out.truncate(k);
6763 return out;
6764 }
6765
6766 // Brute-force fallback: O(n) scan (only reached when no HNSW index
6767 // covers the request).
6768 let view = self.view();
6769 let candidate_ids: Vec<u32> = match label {
6770 Some(lbl) => view.nodes_with_label(lbl),
6771 None => view.nodes_all(),
6772 };
6773 let mut scored: Vec<(String, f64)> = candidate_ids
6774 .into_iter()
6775 .filter_map(|id| {
6776 let v = view.prop(id, field)?;
6777 let v_owned = v.into_value();
6778 let xs = value_as_float_list(&v_owned)?;
6779 let v_norm: f64 = xs.iter().map(|x| x * x).sum::<f64>().sqrt();
6780 if v_norm == 0.0 {
6781 return None;
6782 }
6783 let dot: f64 = q_unit
6784 .iter()
6785 .zip(xs.iter())
6786 .map(|(a, b)| a * (b / v_norm))
6787 .sum();
6788 if dot < min {
6789 return None;
6790 }
6791 let key = self.ids.key_of(id)?.to_string();
6792 Some((key, dot))
6793 })
6794 .collect();
6795 scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
6796 scored.truncate(k);
6797 scored
6798 }
6799
6800 /// Like [`find_similar_vector`] but restricts results to nodes visible in
6801 /// `mask`. Hidden nodes never appear in results; the mask is applied
6802 /// **before** k-truncation so a caller still receives up to `k` visible
6803 /// hits.
6804 ///
6805 /// # HNSW path (over-fetch policy)
6806 ///
6807 /// When an HNSW index covers the request, this function fetches `4 * k`
6808 /// candidates from the index and discards hidden nodes in the post-filter
6809 /// step. If fewer than `k` visible nodes remain after filtering the caller
6810 /// receives whatever is available — we do not re-query the index. The 4×
6811 /// multiplier is a heuristic suited for sparsely masked graphs; callers
6812 /// operating under a very selective mask should register a VectorSimilar
6813 /// rule with a non-approximate index, or use the brute-force path (no HNSW
6814 /// rule) which exhaustively filters through the masked [`GraphView`].
6815 ///
6816 /// # Brute-force path
6817 ///
6818 /// When no HNSW index covers the request the function builds a masked
6819 /// [`GraphView`] so that `nodes_all` / `nodes_with_label` return only
6820 /// visible nodes, guaranteeing exact `k` results (or all visible nodes if
6821 /// fewer than `k` exist).
6822 pub fn find_similar_vector_masked(
6823 &self,
6824 field: &str,
6825 label: Option<&str>,
6826 q: &[f64],
6827 k: usize,
6828 min: f64,
6829 mask: &crate::mask::NodeMask,
6830 ) -> Vec<(String, f64)> {
6831 self.engine.ensure_hnsw_loaded();
6832 let norm: f64 = q.iter().map(|x| x * x).sum::<f64>().sqrt();
6833 if norm == 0.0 {
6834 return vec![];
6835 }
6836 let q_unit: Vec<f64> = q.iter().map(|x| x / norm).collect();
6837
6838 // HNSW fast path — over-fetch 4×k so post-masking still yields up to k
6839 // visible hits. See doc comment above for the policy rationale.
6840 let over_k = k.saturating_mul(4).max(k + 1);
6841 let hnsw_hits = match label {
6842 Some(lbl) => self.engine.hnsw_search_dst(field, lbl, &q_unit, over_k),
6843 None => self.engine.hnsw_search_any_dst(field, &q_unit, over_k),
6844 };
6845 if let Some(hits) = hnsw_hits {
6846 let mut out: Vec<(String, f64)> = hits
6847 .into_iter()
6848 .filter(|&(id, sim)| sim >= min && mask.visible.contains(&id))
6849 .filter_map(|(id, sim)| self.ids.key_of(id).map(|key| (key.to_string(), sim)))
6850 .collect();
6851 out.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
6852 out.truncate(k);
6853 return out;
6854 }
6855
6856 // Brute-force fallback — masked view ensures only visible nodes are
6857 // enumerated by nodes_all(); nodes_with_label() does not filter by
6858 // mask so we apply view.visible() explicitly for the labeled case.
6859 let view = self.view_masked(mask);
6860 let candidate_ids: Vec<u32> = match label {
6861 Some(lbl) => view
6862 .nodes_with_label(lbl)
6863 .into_iter()
6864 .filter(|&id| view.visible(id))
6865 .collect(),
6866 None => view.nodes_all(),
6867 };
6868 let mut scored: Vec<(String, f64)> = candidate_ids
6869 .into_iter()
6870 .filter_map(|id| {
6871 let v = view.prop(id, field)?;
6872 let v_owned = v.into_value();
6873 let xs = value_as_float_list(&v_owned)?;
6874 let v_norm: f64 = xs.iter().map(|x| x * x).sum::<f64>().sqrt();
6875 if v_norm == 0.0 {
6876 return None;
6877 }
6878 let dot: f64 = q_unit
6879 .iter()
6880 .zip(xs.iter())
6881 .map(|(a, b)| a * (b / v_norm))
6882 .sum();
6883 if dot < min {
6884 return None;
6885 }
6886 let key = self.ids.key_of(id)?.to_string();
6887 Some((key, dot))
6888 })
6889 .collect();
6890 scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
6891 scored.truncate(k);
6892 scored
6893 }
6894
6895 /// Read a single property from an edge.
6896 ///
6897 /// Returns `None` when the edge does not exist, the field is absent, or any
6898 /// of the string keys cannot be resolved to interned ids. Only edge props
6899 /// written by rules (weight fields) are accessible without a `set_edge_prop`
6900 /// binding; topology-only edges (no props set) return `None` for every field.
6901 pub fn get_edge_prop(
6902 &self,
6903 edge_type: &str,
6904 src_key: &str,
6905 dst_key: &str,
6906 field: &str,
6907 ) -> Option<Value> {
6908 let etype = self.syms.get(edge_type)?;
6909 let src = self.ids.get(src_key)?;
6910 let dst = self.ids.get(dst_key)?;
6911 self.edge_props_view().get(etype, src, dst, field)
6912 }
6913
6914 /// Lex → parse → plan → execute `cypher` over a read-only view.
6915 /// Every pipeline `Err(String)` becomes `GraphError::QueryError` with a
6916 /// stage prefix (`lex:` / `parse:` / `plan:` / `execute:`).
6917 pub fn query(&self, cypher: &str, params: &BTreeMap<String, Value>) -> Result<ResultSet> {
6918 let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
6919 detail: format!("lex: {e}"),
6920 })?;
6921 let union = parse_read(&tokens).map_err(|e| GraphError::QueryError {
6922 detail: format!("parse: {e}"),
6923 })?;
6924 let t0 = std::time::Instant::now();
6925 let result = execute_union(&self.view(), &union, &Params(params)).map_err(|e| {
6926 GraphError::QueryError {
6927 detail: format!("execute: {e}"),
6928 }
6929 });
6930 let elapsed_ms = t0.elapsed().as_millis() as u64;
6931 let threshold = self.slow_query_threshold_ms;
6932 if threshold > 0 && elapsed_ms >= threshold {
6933 eprintln!("[mushroomdb] slow query ({elapsed_ms}ms): {cypher}");
6934 let entry = SlowQueryEntry {
6935 ms: elapsed_ms,
6936 query: cypher.to_string(),
6937 at_commit: self.commit_seq,
6938 };
6939 if let Ok(mut log) = self.slow_queries.lock() {
6940 if log.entries.len() == SLOW_QUERY_RING_CAP {
6941 log.entries.pop_front();
6942 }
6943 log.entries.push_back(entry);
6944 log.total += 1;
6945 }
6946 }
6947 result
6948 }
6949
6950 /// Convenience entry-point that accepts a slice of `(name, value)` pairs
6951 /// instead of a pre-built `BTreeMap`. Equivalent to building the map and
6952 /// calling [`GraphDb::query`].
6953 pub fn query_with_params(&self, cypher: &str, params: &[(&str, Value)]) -> Result<ResultSet> {
6954 let map: BTreeMap<String, Value> = params
6955 .iter()
6956 .map(|(k, v)| (k.to_string(), v.clone()))
6957 .collect();
6958 self.query(cypher, &map)
6959 }
6960
6961 /// Execute a Cypher write statement (CREATE / MATCH…SET / MATCH…DELETE / MERGE).
6962 ///
6963 /// All mutations flow through the same `insert_node` / `set_prop` /
6964 /// `delete_edge` / `insert_edge` path as the Rust API so the rule engine
6965 /// fires and the WAL captures everything with one fsync per statement.
6966 ///
6967 /// Returns a one-row [`ResultSet`] with columns `created`, `properties_set`,
6968 /// and `deleted` matching the write-result contract.
6969 ///
6970 /// **Mutation routing**: mutations are collected into a single
6971 /// [`BatchBuilder`] and committed atomically (one WAL `Batch` frame, one
6972 /// fsync). The MATCH phase for SET/DELETE uses a read-only `execute` call
6973 /// over `self.view()` — the borrow is dropped before the batch is opened.
6974 ///
6975 /// **Limitations (v1)**:
6976 /// - SET RHS must be a literal, `$param`, or arithmetic; bare property copy → named error.
6977 /// - `DETACH DELETE n` → calls `delete_node` for each matched node (removes all edges).
6978 /// - Bare `DELETE n` → error if n has any incident edges; succeeds for isolated nodes.
6979 /// - MERGE supports `ON CREATE SET` / `ON MATCH SET` in the same write batch.
6980 /// - Deleting a derived edge → named error "cannot delete derived edge".
6981 pub fn query_write(
6982 &mut self,
6983 cypher: &str,
6984 params: &BTreeMap<String, Value>,
6985 ) -> Result<ResultSet> {
6986 let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
6987 detail: format!("lex: {e}"),
6988 })?;
6989 let stmt = parse_write(&tokens).map_err(|e| GraphError::QueryError {
6990 detail: format!("parse: {e}"),
6991 })?;
6992 self.exec_write_stmt(stmt, params)
6993 }
6994
6995 fn exec_write_stmt(
6996 &mut self,
6997 stmt: WriteStatement,
6998 params: &BTreeMap<String, Value>,
6999 ) -> Result<ResultSet> {
7000 match stmt {
7001 WriteStatement::Create(s) => self.exec_create(s, params),
7002 WriteStatement::MatchSet(s) => self.exec_match_set(s, params),
7003 WriteStatement::MatchDelete(s) => self.exec_match_delete(s, params),
7004 WriteStatement::MatchDeleteNode(s) => self.exec_match_delete_node(s, params),
7005 WriteStatement::Merge(s) => self.exec_merge(s, params),
7006 }
7007 }
7008
7009 fn exec_create(
7010 &mut self,
7011 stmt: core_query::cypher::CreateStmt,
7012 params: &BTreeMap<String, Value>,
7013 ) -> Result<ResultSet> {
7014 // Extract the node key from props: require a string-valued `id` field.
7015 let mut var_to_key: BTreeMap<String, String> = BTreeMap::new();
7016 for node in &stmt.nodes {
7017 let var = node.var.as_deref().unwrap_or("_cn0");
7018 let key = node
7019 .props
7020 .iter()
7021 .find(|(f, _)| f == "id")
7022 .and_then(|(_, v)| {
7023 if let Value::Str(s) = v {
7024 Some(s.clone())
7025 } else {
7026 None
7027 }
7028 })
7029 .ok_or_else(|| GraphError::QueryError {
7030 detail: format!(
7031 "CREATE node ({}:{}) requires a string 'id' property",
7032 var, node.label
7033 ),
7034 })?;
7035 var_to_key.insert(var.to_string(), key);
7036 }
7037
7038 let mut batch = self.batch();
7039 let mut created: usize = 0;
7040 for node in &stmt.nodes {
7041 let var = node.var.as_deref().unwrap_or("_cn0");
7042 let key = &var_to_key[var];
7043 batch.insert_node(&node.label, key, node.props.clone());
7044 created += 1;
7045 }
7046 for edge in &stmt.edges {
7047 let src_key = var_to_key
7048 .get(&edge.src_var)
7049 .ok_or_else(|| GraphError::QueryError {
7050 detail: format!("CREATE edge src variable '{}' is not bound", edge.src_var),
7051 })?;
7052 let dst_key = var_to_key
7053 .get(&edge.dst_var)
7054 .ok_or_else(|| GraphError::QueryError {
7055 detail: format!("CREATE edge dst variable '{}' is not bound", edge.dst_var),
7056 })?;
7057 batch.insert_edge(&edge.etype, src_key, dst_key);
7058 }
7059 batch.commit()?;
7060
7061 // Optional RETURN clause: project created bindings as a read result.
7062 if let Some(returns) = stmt.returns {
7063 // Each created node is looked up by its key via a separate MATCH pattern.
7064 // Multiple single-node patterns cross-join to produce 1 output row with
7065 // all variables bound (each pattern returns exactly 1 row).
7066 let patterns: Vec<Pattern> = stmt
7067 .nodes
7068 .iter()
7069 .map(|node| {
7070 let var = node.var.as_deref().unwrap_or("_cn0");
7071 let key = var_to_key[var].clone();
7072 Pattern {
7073 start: NodePat {
7074 var: Some(var.to_string()),
7075 label: Some(node.label.clone()),
7076 props: vec![("id".to_string(), Operand::Lit(Value::Str(key)))],
7077 },
7078 chain: vec![],
7079 shortest: false,
7080 }
7081 })
7082 .collect();
7083 let q = Query {
7084 matches: patterns,
7085 optional_clauses: vec![],
7086 where_expr: None,
7087 unwinds: vec![],
7088 post_unwind_where: None,
7089 stages: vec![],
7090 returns,
7091 distinct: false,
7092 order_by: vec![],
7093 skip: None,
7094 limit: None,
7095 };
7096 let ops = plan(&q).map_err(|e| GraphError::QueryError {
7097 detail: format!("plan: {e}"),
7098 })?;
7099 return execute(&self.view(), &ops, &Params(params)).map_err(|e| {
7100 GraphError::QueryError {
7101 detail: format!("execute: {e}"),
7102 }
7103 });
7104 }
7105
7106 let mut rs = write_result_set();
7107 rs.push_row(vec![
7108 Some(Value::Int(created as i64)),
7109 Some(Value::Int(0)),
7110 Some(Value::Int(0)),
7111 ]);
7112 Ok(rs)
7113 }
7114
7115 fn exec_match_set(
7116 &mut self,
7117 stmt: core_query::cypher::MatchSetStmt,
7118 params: &BTreeMap<String, Value>,
7119 ) -> Result<ResultSet> {
7120 let project_returns = stmt.returns.clone();
7121 // Collect unique node vars targeted by SET clauses, plus RETURN bindings
7122 // so the post-write projection can look them up by key.
7123 let mut set_vars: Vec<String> = Vec::new();
7124 for s in &stmt.sets {
7125 if !set_vars.contains(&s.var) {
7126 set_vars.push(s.var.clone());
7127 }
7128 }
7129 let rel_vars = pattern_rel_vars(&stmt.matches);
7130 let mut lookup_vars = set_vars.clone();
7131 for v in pattern_node_vars(&stmt.matches) {
7132 add_var(&mut lookup_vars, &v);
7133 }
7134 if let Some(ref returns) = project_returns {
7135 for v in ret_node_vars(returns) {
7136 if !rel_vars.iter().any(|r| r == &v) {
7137 add_var(&mut lookup_vars, &v);
7138 }
7139 }
7140 }
7141
7142 // Synthesize a read query: MATCH … WHERE … RETURN <lookup_vars>, <set_values…>
7143 // SET values are projected as ScalarExpr items so that arithmetic expressions
7144 // (e.g. `SET n.score = n.score * 1.5`) are evaluated in the matched-row context.
7145 let mut set_returns: Vec<RetItem> = lookup_vars
7146 .iter()
7147 .map(|v| RetItem {
7148 value: RetVal::Var(v.clone()),
7149 alias: None,
7150 })
7151 .collect();
7152 // One computed column per SET clause; alias is `__sv_<i>`.
7153 let set_val_cols: Vec<String> = stmt
7154 .sets
7155 .iter()
7156 .enumerate()
7157 .map(|(i, _)| format!("__sv_{i}"))
7158 .collect();
7159 for (sc, col) in stmt.sets.iter().zip(&set_val_cols) {
7160 set_returns.push(RetItem {
7161 value: RetVal::ScalarExpr(sc.value.clone()),
7162 alias: Some(col.clone()),
7163 });
7164 }
7165 // Capture relationship types while r is bound; SET does not change them.
7166 for r in &rel_vars {
7167 set_returns.push(RetItem {
7168 value: RetVal::FuncCall {
7169 name: "type".into(),
7170 args: vec![Operand::Var(r.clone())],
7171 },
7172 alias: Some(rel_type_alias(r)),
7173 });
7174 }
7175
7176 let read_q = Query {
7177 matches: stmt.matches.clone(),
7178 optional_clauses: vec![],
7179 where_expr: stmt.where_expr.clone(),
7180 unwinds: vec![],
7181 post_unwind_where: None,
7182 stages: vec![],
7183 returns: set_returns,
7184 distinct: false,
7185 order_by: vec![],
7186 skip: None,
7187 limit: None,
7188 };
7189 let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
7190 detail: format!("plan: {e}"),
7191 })?;
7192 // MATCH phase is read-only; borrow ends before batch opens.
7193 //
7194 // When a role-scoped write is in flight, run the MATCH read through
7195 // view_masked so hidden nodes are invisible → hidden ≡ absent ≡
7196 // zero-rows (no SetProp ops generated, no existence-oracle 403).
7197 // Full-authority writes (pending_write_authz=None) keep view().
7198 let match_rs = {
7199 let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
7200 if let Some(ref mask) = mask_opt {
7201 execute(&self.view_masked(mask), &ops, &Params(params))
7202 } else {
7203 execute(&self.view(), &ops, &Params(params))
7204 }
7205 }
7206 .map_err(|e| GraphError::QueryError {
7207 detail: format!("execute: {e}"),
7208 })?;
7209
7210 // Collect (key, field, value) for each matched row × each SET clause.
7211 let mut set_ops: Vec<(String, String, Value)> = Vec::new();
7212 for row_i in 0..match_rs.len() {
7213 for (sc, col) in stmt.sets.iter().zip(&set_val_cols) {
7214 let key = match match_rs.get(row_i, &sc.var) {
7215 Some(Value::Str(k)) => k.clone(),
7216 _ => {
7217 return Err(GraphError::QueryError {
7218 detail: format!(
7219 "SET variable '{}' did not resolve to a node key",
7220 sc.var
7221 ),
7222 })
7223 }
7224 };
7225 // The SET value was already evaluated by the executor.
7226 let value = match match_rs.get(row_i, col) {
7227 Some(v) => v.clone(),
7228 None => {
7229 return Err(GraphError::QueryError {
7230 detail: format!(
7231 "SET value for {}.{} evaluated to null",
7232 sc.var, sc.field
7233 ),
7234 })
7235 }
7236 };
7237 set_ops.push((key, sc.field.clone(), value));
7238 }
7239 }
7240
7241 // Apply as one atomic batch.
7242 let props_set = set_ops.len();
7243 let mut batch = self.batch();
7244 for (key, field, value) in set_ops {
7245 batch.set_prop(&key, &field, value);
7246 }
7247 batch.commit()?;
7248
7249 if let Some(returns) = project_returns {
7250 return project_set_return_rows(self, &rel_vars, &match_rs, &returns, params);
7251 }
7252
7253 let mut rs = write_result_set();
7254 rs.push_row(vec![
7255 Some(Value::Int(0)),
7256 Some(Value::Int(props_set as i64)),
7257 Some(Value::Int(0)),
7258 ]);
7259 Ok(rs)
7260 }
7261
7262 fn exec_match_delete(
7263 &mut self,
7264 stmt: core_query::cypher::MatchDeleteStmt,
7265 params: &BTreeMap<String, Value>,
7266 ) -> Result<ResultSet> {
7267 // Collect unique node vars needed to identify edge endpoints.
7268 let mut node_vars: Vec<String> = Vec::new();
7269 for ed in &stmt.deletes {
7270 if !node_vars.contains(&ed.src_var) {
7271 node_vars.push(ed.src_var.clone());
7272 }
7273 if !node_vars.contains(&ed.dst_var) {
7274 node_vars.push(ed.dst_var.clone());
7275 }
7276 }
7277
7278 // Synthesize read query.
7279 let returns: Vec<RetItem> = node_vars
7280 .iter()
7281 .map(|v| RetItem {
7282 value: RetVal::Var(v.clone()),
7283 alias: None,
7284 })
7285 .collect();
7286 let read_q = Query {
7287 matches: stmt.matches,
7288 optional_clauses: vec![],
7289 where_expr: stmt.where_expr,
7290 unwinds: vec![],
7291 post_unwind_where: None,
7292 stages: vec![],
7293 returns,
7294 distinct: false,
7295 order_by: vec![],
7296 skip: None,
7297 limit: None,
7298 };
7299 let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
7300 detail: format!("plan: {e}"),
7301 })?;
7302 // Role-scoped writes: mask the MATCH read phase so hidden nodes are
7303 // invisible → hidden ≡ absent ≡ zero-rows (spec §3.1, hidden ≡ absent).
7304 let match_rs = {
7305 let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
7306 if let Some(ref mask) = mask_opt {
7307 execute(&self.view_masked(mask), &ops, &Params(params))
7308 } else {
7309 execute(&self.view(), &ops, &Params(params))
7310 }
7311 }
7312 .map_err(|e| GraphError::QueryError {
7313 detail: format!("execute: {e}"),
7314 })?;
7315
7316 // Collect (etype, src_key, dst_key) for each row × each delete target.
7317 let mut del_ops: Vec<(String, String, String)> = Vec::new();
7318 for row_i in 0..match_rs.len() {
7319 for ed in &stmt.deletes {
7320 let src_key = match match_rs.get(row_i, &ed.src_var) {
7321 Some(Value::Str(k)) => k.clone(),
7322 _ => {
7323 return Err(GraphError::QueryError {
7324 detail: format!(
7325 "DELETE src variable '{}' did not resolve to a node key",
7326 ed.src_var
7327 ),
7328 })
7329 }
7330 };
7331 let dst_key = match match_rs.get(row_i, &ed.dst_var) {
7332 Some(Value::Str(k)) => k.clone(),
7333 _ => {
7334 return Err(GraphError::QueryError {
7335 detail: format!(
7336 "DELETE dst variable '{}' did not resolve to a node key",
7337 ed.dst_var
7338 ),
7339 })
7340 }
7341 };
7342 del_ops.push((ed.etype.clone(), src_key, dst_key));
7343 }
7344 }
7345
7346 // Apply as one atomic batch.
7347 let deleted = del_ops.len();
7348 let mut batch = self.batch();
7349 for (etype, src_key, dst_key) in del_ops {
7350 batch.delete_edge(&etype, &src_key, &dst_key);
7351 }
7352 batch.commit().map_err(|e| match e {
7353 GraphError::RuleOwned { .. } => GraphError::QueryError {
7354 detail: "cannot delete derived edge; retract via the rule or change the property"
7355 .to_string(),
7356 },
7357 other => other,
7358 })?;
7359
7360 let mut rs = write_result_set();
7361 rs.push_row(vec![
7362 Some(Value::Int(0)),
7363 Some(Value::Int(0)),
7364 Some(Value::Int(deleted as i64)),
7365 ]);
7366 Ok(rs)
7367 }
7368
7369 /// Execute `MATCH … [DETACH] DELETE <node_var> [, …]`.
7370 ///
7371 /// Collects the matching node keys via an ephemeral read query, then calls
7372 /// `delete_node` on each one. When `stmt.detach` is `false` (bare DELETE)
7373 /// the executor first checks that the node has no incident edges; if any
7374 /// remain it returns a named error matching openCypher semantics.
7375 fn exec_match_delete_node(
7376 &mut self,
7377 stmt: MatchDeleteNodeStmt,
7378 params: &BTreeMap<String, Value>,
7379 ) -> Result<ResultSet> {
7380 // Build a read query returning only the node keys we need.
7381 let returns: Vec<RetItem> = stmt
7382 .node_vars
7383 .iter()
7384 .map(|v| RetItem {
7385 value: RetVal::Var(v.clone()),
7386 alias: None,
7387 })
7388 .collect();
7389 let read_q = Query {
7390 matches: stmt.matches,
7391 optional_clauses: vec![],
7392 where_expr: stmt.where_expr,
7393 unwinds: vec![],
7394 post_unwind_where: None,
7395 stages: vec![],
7396 returns,
7397 distinct: false,
7398 order_by: vec![],
7399 skip: None,
7400 limit: None,
7401 };
7402 let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
7403 detail: format!("plan: {e}"),
7404 })?;
7405 // Role-scoped writes: mask the MATCH read phase so hidden nodes are
7406 // invisible → hidden ≡ absent ≡ zero-rows (spec §3.1, hidden ≡ absent).
7407 let match_rs = {
7408 let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
7409 if let Some(ref mask) = mask_opt {
7410 execute(&self.view_masked(mask), &ops, &Params(params))
7411 } else {
7412 execute(&self.view(), &ops, &Params(params))
7413 }
7414 }
7415 .map_err(|e| GraphError::QueryError {
7416 detail: format!("execute: {e}"),
7417 })?;
7418
7419 // Collect unique node keys to delete (deduplicate across rows × vars).
7420 let mut keys: Vec<String> = Vec::new();
7421 for row_i in 0..match_rs.len() {
7422 for var in &stmt.node_vars {
7423 if let Some(Value::Str(k)) = match_rs.get(row_i, var) {
7424 if !keys.contains(k) {
7425 keys.push(k.clone());
7426 }
7427 }
7428 }
7429 }
7430
7431 if !stmt.detach {
7432 // openCypher bare DELETE: error if any matched node has incident edges.
7433 for key in &keys {
7434 if let Some(id) = self.ids.get(key) {
7435 let tv = self.topo_view();
7436 let has_edges = tv.etypes().any(|et| {
7437 !tv.neighbors(et, Direction::Out, id).is_empty()
7438 || !tv.neighbors(et, Direction::In, id).is_empty()
7439 });
7440 if has_edges {
7441 return Err(GraphError::QueryError {
7442 detail: format!(
7443 "Cannot delete node `{key}` because it still has incident edges. \
7444 Use DETACH DELETE to remove the node and all its edges."
7445 ),
7446 });
7447 }
7448 }
7449 }
7450 }
7451
7452 let mut nodes_deleted = 0i64;
7453 let mut edges_deleted = 0i64;
7454 for key in keys {
7455 match self.delete_node(&key) {
7456 Ok(report) => {
7457 nodes_deleted += 1;
7458 edges_deleted += (report.manual_edges + report.derived_edges) as i64;
7459 }
7460 Err(GraphError::KeyNotFound { .. }) => {
7461 // Node may have been deleted by an earlier iteration (e.g., via
7462 // multiple MATCH rows for the same node). Safe to skip.
7463 }
7464 Err(e) => return Err(e),
7465 }
7466 }
7467
7468 let mut rs = write_result_set();
7469 rs.push_row(vec![
7470 Some(Value::Int(0)),
7471 Some(Value::Int(0)),
7472 Some(Value::Int(nodes_deleted + edges_deleted)),
7473 ]);
7474 Ok(rs)
7475 }
7476
7477 fn exec_merge(
7478 &mut self,
7479 stmt: core_query::cypher::MergeStmt,
7480 params: &BTreeMap<String, Value>,
7481 ) -> Result<ResultSet> {
7482 // MERGE: check if a node with the given key already exists.
7483 let key = match &stmt.key_value {
7484 Value::Str(s) => s.clone(),
7485 _ => {
7486 return Err(GraphError::QueryError {
7487 detail: format!(
7488 "MERGE key value must be a string (got {:?})",
7489 stmt.key_value
7490 ),
7491 })
7492 }
7493 };
7494
7495 if let Some(var) = stmt.var.as_deref() {
7496 for sc in stmt.on_create.iter().chain(&stmt.on_match) {
7497 if sc.var != var {
7498 return Err(GraphError::QueryError {
7499 detail: format!(
7500 "SET variable '{}' does not match MERGE variable '{var}'",
7501 sc.var
7502 ),
7503 });
7504 }
7505 }
7506 }
7507
7508 // ── MERGE authz pre-check (when role-scoped) ─────────────────────────
7509 //
7510 // MERGE scope precondition: check create OR update scope for the
7511 // declared label BEFORE calling `has_node` (timing-oracle closure,
7512 // spec §6.2 "MERGE visibility oracle" item: hidden ≡ absent for
7513 // unscoped roles — the scope denial fires without touching the key store).
7514 //
7515 // Clone to avoid holding a borrow on `self.pending_write_authz` while
7516 // also calling `self.ids.get(key)`.
7517 let merge_existed: bool = if let Some(authz) = self.pending_write_authz.clone() {
7518 let has_create = authz.scope.create_labels.contains(&stmt.label);
7519 let has_update = authz.scope.update_labels.contains(&stmt.label);
7520 if !has_create && !has_update {
7521 // Scope-before-lookup: 403 without has_node call (timing oracle
7522 // closure — see test_merge_unscoped_no_key_lookup).
7523 return Err(GraphError::RoleWriteDenied {
7524 reason: format!(
7525 "role-bound token: label '{}' not in write scope (create_labels)",
7526 stmt.label
7527 ),
7528 });
7529 }
7530 // Key lookup under mask.
7531 match self.ids.get(key.as_str()) {
7532 Some(id) if authz.mask.contains_id(id) => {
7533 // Visible: must have update scope to proceed to match arm.
7534 if !has_update {
7535 return Err(GraphError::RoleWriteDenied {
7536 reason: format!(
7537 "role-bound token: label '{}' not in write scope (update_labels)",
7538 stmt.label
7539 ),
7540 });
7541 }
7542 true // existed = true → match arm
7543 }
7544 Some(_) => {
7545 // Hidden: same error as absent to the role (spec §3.1/§3.3).
7546 return Err(GraphError::RoleWriteDenied {
7547 reason: "role-bound token: target node not visible".into(),
7548 });
7549 }
7550 None => {
7551 // Absent: must have create scope to proceed to the create arm.
7552 //
7553 // Update-only roles (create_labels empty, update_labels set):
7554 // return the SAME "not visible" error as the hidden-key branch
7555 // so hidden ≡ absent — no distinguishing oracle (spec §6.1
7556 // "confirm existence of hidden nodes: No").
7557 //
7558 // Create-scoped roles (has_create=true): absent → create arm
7559 // as before. The accepted structural key-existence disclosure
7560 // (§THREAT-MODEL) applies only when the role holds create scope.
7561 if !has_create {
7562 return Err(GraphError::RoleWriteDenied {
7563 reason: "role-bound token: target node not visible".into(),
7564 });
7565 }
7566 false // existed = false → create arm
7567 }
7568 }
7569 } else {
7570 // Full authority: use the existing non-masked has_node check.
7571 self.has_node(&key)
7572 };
7573
7574 let existed = merge_existed;
7575 let mut created = 0i64;
7576 if !existed || !stmt.on_match.is_empty() {
7577 let mut batch = self.batch();
7578 if !existed {
7579 let props = vec![(stmt.key_field.clone(), stmt.key_value.clone())];
7580 batch.insert_node(&stmt.label, &key, props);
7581 for sc in &stmt.on_create {
7582 let value = resolve_merge_set_value(&sc.value, params)?;
7583 batch.set_prop(&key, &sc.field, value);
7584 }
7585 created = 1;
7586 } else {
7587 for sc in &stmt.on_match {
7588 let value = resolve_merge_set_value(&sc.value, params)?;
7589 batch.set_prop(&key, &sc.field, value);
7590 }
7591 }
7592 batch.commit()?;
7593 }
7594
7595 // Refresh the role mask so the just-created node is visible to this
7596 // statement's RETURN (read-after-write). Safe: create_labels ⊆ read labels
7597 // (apply_schema subset rule), so the new node's label is already in the
7598 // role's read scope — this never widens beyond the role's declared labels.
7599 if !existed {
7600 if let Some(role) = self.pending_write_authz.as_ref().map(|a| a.role.clone()) {
7601 let new_mask = self.mask_for_role(&role)?;
7602 if let Some(a) = self.pending_write_authz.as_mut() {
7603 a.mask = new_mask;
7604 }
7605 }
7606 }
7607
7608 // Optional RETURN clause: project the node (created or matched) as a read result.
7609 if let Some(returns) = stmt.returns {
7610 let var = stmt.var.as_deref().unwrap_or("_mn0");
7611 let q = Query {
7612 matches: vec![Pattern {
7613 start: NodePat {
7614 var: Some(var.to_string()),
7615 label: Some(stmt.label.clone()),
7616 props: vec![("id".to_string(), Operand::Lit(stmt.key_value.clone()))],
7617 },
7618 chain: vec![],
7619 shortest: false,
7620 }],
7621 optional_clauses: vec![],
7622 where_expr: None,
7623 unwinds: vec![],
7624 post_unwind_where: None,
7625 stages: vec![],
7626 returns,
7627 distinct: false,
7628 order_by: vec![],
7629 skip: None,
7630 limit: None,
7631 };
7632 let ops = plan(&q).map_err(|e| GraphError::QueryError {
7633 detail: format!("plan: {e}"),
7634 })?;
7635 // Use view_masked when a role-scoped write is in flight so the
7636 // post-merge projection is consistent with the masked read phase.
7637 let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
7638 return (if let Some(ref mask) = mask_opt {
7639 execute(&self.view_masked(mask), &ops, &Params(params))
7640 } else {
7641 execute(&self.view(), &ops, &Params(params))
7642 })
7643 .map_err(|e| GraphError::QueryError {
7644 detail: format!("execute: {e}"),
7645 });
7646 }
7647
7648 let mut rs = write_result_set();
7649 rs.push_row(vec![
7650 Some(Value::Int(created)),
7651 Some(Value::Int(0)),
7652 Some(Value::Int(0)),
7653 ]);
7654 Ok(rs)
7655 }
7656
7657 /// Return all rule-owned edges between `key_a` and `key_b` (either direction),
7658 /// annotated with rule name, edge type, direction, and weight.
7659 /// Results are sorted by (rule, edge_type).
7660 /// Returns `Err(KeyNotFound)` if either key is unknown.
7661 pub fn explain(&self, key_a: &str, key_b: &str) -> Result<Vec<Explanation>> {
7662 self.ensure_v8_base_sections_loaded();
7663 let id_a = self
7664 .ids
7665 .get(key_a)
7666 .ok_or_else(|| GraphError::KeyNotFound { key: key_a.into() })?;
7667 let id_b = self
7668 .ids
7669 .get(key_b)
7670 .ok_or_else(|| GraphError::KeyNotFound { key: key_b.into() })?;
7671
7672 let mut results = Vec::new();
7673
7674 // Walk the smaller incident set so explain is O(min(deg(a), deg(b)))
7675 // rather than O(total provenance).
7676 let scan = if self.engine.provenance_touching_len(id_a)
7677 <= self.engine.provenance_touching_len(id_b)
7678 {
7679 id_a
7680 } else {
7681 id_b
7682 };
7683 for (rule_name, etype, src, dst) in self.engine.provenance_touching(scan) {
7684 if !((src == id_a && dst == id_b) || (src == id_b && dst == id_a)) {
7685 continue;
7686 }
7687 let Some(rule_def) = self.engine.rules().find(|r| r.name == rule_name) else {
7688 continue;
7689 };
7690 let edge_type = match self.syms.resolve(etype) {
7691 Some(s) => s.to_string(),
7692 None => continue,
7693 };
7694 // Provenance (src, dst) ids come from the archived PROVENANCE section
7695 // (large, no eager CRC). A corrupt section can produce ids that are
7696 // out of range; return Corrupt rather than panic.
7697 let src_key = self
7698 .ids
7699 .key_of(src)
7700 .ok_or_else(|| GraphError::Corrupt {
7701 detail: format!("v8: provenance src id {src} not in id table"),
7702 })?
7703 .to_string();
7704 let dst_key = self
7705 .ids
7706 .key_of(dst)
7707 .ok_or_else(|| GraphError::Corrupt {
7708 detail: format!("v8: provenance dst id {dst} not in id table"),
7709 })?
7710 .to_string();
7711 let weight = rule_def.weight_prop.as_deref().and_then(|prop| {
7712 self.edge_props_view()
7713 .get(etype, src, dst, prop)
7714 .and_then(|v| {
7715 if let Value::Float(f) = v {
7716 Some(f)
7717 } else {
7718 None
7719 }
7720 })
7721 });
7722 results.push(Explanation {
7723 rule: rule_name.to_string(),
7724 edge_type,
7725 src_key,
7726 dst_key,
7727 weight,
7728 predicate: PredicateSummary {
7729 approximate: rule_def.approximate,
7730 ..PredicateSummary::from(&rule_def.predicate)
7731 },
7732 });
7733 }
7734
7735 results.sort_by(|a, b| a.rule.cmp(&b.rule).then(a.edge_type.cmp(&b.edge_type)));
7736 Ok(results)
7737 }
7738
7739 pub fn neighbors(&self, key: &str, edge_type: &str, dir: Direction) -> Result<Vec<String>> {
7740 let id = self
7741 .ids
7742 .get(key)
7743 .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
7744 let Some(sym) = self.syms.get(edge_type) else {
7745 return Ok(Vec::new());
7746 };
7747 self.topo_view()
7748 .neighbors(sym, dir, id)
7749 .iter()
7750 .map(|&n| {
7751 self.ids
7752 .key_of(n)
7753 .map(|k| k.to_string())
7754 .ok_or_else(|| GraphError::Corrupt {
7755 detail: format!("topology id {n} has no key"),
7756 })
7757 })
7758 .collect::<Result<Vec<_>>>()
7759 }
7760
7761 /// Return the last-change commit sequence for `key`, or `None` if the node
7762 /// does not exist or has never been mutated since the last V5-V7 snapshot
7763 /// (horizon-bounded for legacy stores).
7764 ///
7765 /// The returned sequence is a monotonically increasing counter that starts
7766 /// at 1 for the first commit after `open` and increments with every
7767 /// successful write. WAL replay at open also assigns sequences (1..N for N
7768 /// replayed frames), so sequences are consistent across snapshot+WAL cycles.
7769 ///
7770 /// For V5-V7 stores opened without a V8 snapshot, nodes that were present
7771 /// in the snapshot but not touched by any WAL frame will return `None`
7772 /// (horizon-bounded: CAS against such nodes is only safe after the first
7773 /// V8 snapshot or after the node is next mutated).
7774 pub fn last_changed(&self, key: &str) -> Option<u64> {
7775 let id = self.ids.get(key)?;
7776 self.last_change.get(&id).copied()
7777 }
7778
7779 /// The current commit sequence (number of successful commits since open,
7780 /// including WAL replay frames). Useful for recording a baseline before
7781 /// a read-modify-write cycle.
7782 pub fn commit_seq(&self) -> u64 {
7783 self.commit_seq
7784 }
7785
7786 /// Check that all `preconds` are satisfied against the current db state.
7787 /// Returns `Err(GraphError::CasConflict)` on the first failing precondition.
7788 pub(crate) fn check_preconditions(&self, preconds: &[Precondition]) -> Result<()> {
7789 for precond in preconds {
7790 match precond {
7791 Precondition::NodeUnchangedSince { key, expected } => {
7792 // Missing entry means the node predates the WAL window or
7793 // does not exist; treat as 0 (before any commit).
7794 let actual = self.last_changed(key).unwrap_or_default();
7795 if actual != *expected {
7796 return Err(GraphError::CasConflict {
7797 key: key.clone(),
7798 expected: *expected,
7799 actual,
7800 });
7801 }
7802 }
7803 Precondition::NodeAbsent { key } => {
7804 // Node must not exist (not live).
7805 if self.ids.get(key).is_some() {
7806 let actual = self.last_changed(key).unwrap_or(0);
7807 return Err(GraphError::CasConflict {
7808 key: key.clone(),
7809 expected: u64::MAX,
7810 actual,
7811 });
7812 }
7813 }
7814 }
7815 }
7816 Ok(())
7817 }
7818
7819 /// Apply a batch of mutations with compare-and-set preconditions.
7820 ///
7821 /// All preconditions are checked atomically before any operation is applied.
7822 /// If any precondition fails, the entire batch is rejected with
7823 /// [`GraphError::CasConflict`] and no WAL frame is written.
7824 ///
7825 /// # Returns
7826 /// `(nodes_inserted, edges_inserted)` on success, same as [`write_batch`].
7827 ///
7828 /// # Errors
7829 /// - [`GraphError::CasConflict`] if any precondition is not satisfied.
7830 /// - Any error that [`write_batch`] would return for the ops themselves.
7831 pub fn write_batch_cas(
7832 &mut self,
7833 preconds: Vec<Precondition>,
7834 ops: Vec<BatchOp>,
7835 ) -> Result<(usize, usize)> {
7836 self.check_preconditions(&preconds)?;
7837 self.commit_logged_batch(ops, None, None)
7838 }
7839
7840 /// Update the per-node last-change map for a WAL record at commit `seq`.
7841 ///
7842 /// Called after a successful apply to record which nodes were touched.
7843 /// For replay, called with the WAL-frame's replayed seq.
7844 ///
7845 /// Touch definition (see [`Precondition`] doc):
7846 /// - InsertNode / InsertNodeId / SetProp / SetPropId / RemoveProp → the node.
7847 /// - InsertEdge / InsertEdgeId / DeleteEdge → both src and dst.
7848 /// - DeleteNode → node tombstoned; last_changed() returns None so no update needed.
7849 /// - DerivedEdge markers, Intern, rule/view records → no-ops.
7850 /// - Batch → recurse into inner records.
7851 fn update_last_change_from_rec(&mut self, rec: &WalRecord, seq: u64) {
7852 match rec {
7853 WalRecord::InsertNode { key, .. }
7854 | WalRecord::SetProp { key, .. }
7855 | WalRecord::RemoveProp { key, .. } => {
7856 if let Some(id) = self.ids.get(key) {
7857 self.last_change.insert(id, seq);
7858 }
7859 }
7860 WalRecord::InsertNodeId { key, .. } => {
7861 if let Some(id) = self.ids.get(key) {
7862 self.last_change.insert(id, seq);
7863 }
7864 }
7865 WalRecord::SetPropId { id, .. } => {
7866 self.last_change.insert(*id, seq);
7867 }
7868 WalRecord::InsertEdge {
7869 src_key, dst_key, ..
7870 }
7871 | WalRecord::DeleteEdge {
7872 src_key, dst_key, ..
7873 } => {
7874 if let Some(src_id) = self.ids.get(src_key) {
7875 self.last_change.insert(src_id, seq);
7876 }
7877 if let Some(dst_id) = self.ids.get(dst_key) {
7878 self.last_change.insert(dst_id, seq);
7879 }
7880 }
7881 WalRecord::InsertEdgeId { src, dst, .. } => {
7882 self.last_change.insert(*src, seq);
7883 self.last_change.insert(*dst, seq);
7884 }
7885 // DeleteNode: node is tombstoned; last_changed(key) returns None for
7886 // deleted keys (ids.get() returns None post-tombstone), so no update needed.
7887 // History markers: state no-ops; the underlying mutation already
7888 // touched the relevant nodes' last_change entries.
7889 WalRecord::DeleteNode { .. }
7890 | WalRecord::DerivedEdgeAdded { .. }
7891 | WalRecord::DerivedEdgeRetracted { .. }
7892 | WalRecord::Intern { .. }
7893 | WalRecord::CreateRule { .. }
7894 | WalRecord::DeleteRule { .. }
7895 | WalRecord::RebuildRule { .. }
7896 | WalRecord::CreateView { .. }
7897 | WalRecord::DeleteView { .. }
7898 | WalRecord::EnableFulltext { .. }
7899 | WalRecord::DisableFulltext { .. }
7900 | WalRecord::EnableIndex { .. }
7901 | WalRecord::DisableIndex { .. } => {}
7902 // RenameNode: node id is stable; update last_change via the new key.
7903 // Called after apply(), so ids already reflects new_key.
7904 WalRecord::RenameNode { new_key, .. } => {
7905 if let Some(id) = self.ids.get(new_key) {
7906 self.last_change.insert(id, seq);
7907 }
7908 }
7909 WalRecord::Batch(inner) => {
7910 for inner_rec in inner {
7911 self.update_last_change_from_rec(inner_rec, seq);
7912 }
7913 }
7914 }
7915 }
7916
7917 pub fn node_count(&self) -> usize {
7918 self.ids.len()
7919 }
7920
7921 /// Configure archive retention: keep the `N` newest WAL archives at each
7922 /// [`snapshot_with`] call when `archive_wal: true`.
7923 ///
7924 /// `Some(N)` where N > 0 → prune oldest archives keeping the newest N.
7925 /// `Some(0)` or `None` → unlimited (no pruning).
7926 ///
7927 /// Pruning only ever happens inside [`snapshot_with`]; this method only
7928 /// stores the policy. Archives below the retention limit are deleted
7929 /// oldest-first. The horizon floor is updated so that
7930 /// [`was_linked`] / history APIs return `CommitOutOfRange` for commits
7931 /// in pruned archives rather than silently returning wrong data.
7932 pub fn set_wal_archive_retention(&mut self, keep: Option<u32>) {
7933 self.wal_archive_retention = keep;
7934 }
7935
7936 /// Delete any WAL archives that are fully below the current horizon floor.
7937 ///
7938 /// Orphaned archives arise when the floor is written first during retention
7939 /// pruning and then a crash interrupts the archive-delete sequence. The
7940 /// opening cleanup ensures no subsequent read path sees stale data.
7941 ///
7942 /// Under the monotonic naming scheme, the archive name N equals the
7943 /// cumulative end-frame index of the archive in global commit space (i.e.
7944 /// the archive covers global frames `[prev_n, N)`). An archive is
7945 /// fully orphaned when `N <= wal_horizon_floor`: all of its frames fall
7946 /// below the floor and have already been counted in it.
7947 fn cleanup_orphaned_archives(&mut self) -> Result<()> {
7948 if self.wal_horizon_floor == 0 {
7949 // Floor at 0 means no pruning has ever occurred; nothing to clean.
7950 return Ok(());
7951 }
7952 let archive_ns = self.fs.list_archives()?;
7953 for n in archive_ns {
7954 if n <= self.wal_horizon_floor {
7955 // Archive N ends at global frame N; all its frames are below
7956 // the floor (floor already accounts for them) → orphaned.
7957 self.fs.delete_archive(n).map_err(GraphError::Io)?;
7958 } else {
7959 // Archives are sorted ascending; first one above floor stops scan.
7960 break;
7961 }
7962 }
7963 Ok(())
7964 }
7965
7966 /// Collect all WAL frames from surviving archives (oldest-first) then the
7967 /// live WAL into one flat list, and return the total along with the number
7968 /// of archive frames at the front of the list.
7969 ///
7970 /// Commit indices into the returned list are LOCAL (0 = first frame of
7971 /// oldest surviving archive). To obtain the GLOBAL index add
7972 /// `self.wal_horizon_floor`.
7973 fn all_frames(&self) -> Result<(Vec<WalRecord>, u64)> {
7974 let archive_ns = self.fs.list_archives()?;
7975 let mut all: Vec<WalRecord> = Vec::new();
7976 for n in archive_ns {
7977 let bytes = self.fs.read_archive(n)?;
7978 let (frames, _) = decode_all(&bytes);
7979 all.extend(frames);
7980 }
7981 let archive_count = all.len() as u64;
7982 let live_bytes = self.fs.read(FileId::Wal)?;
7983 let (live_frames, _) = decode_all(&live_bytes);
7984 all.extend(live_frames);
7985 Ok((all, archive_count))
7986 }
7987
7988 /// Return the total number of committed WAL frames visible in the current
7989 /// horizon window, including frames in surviving WAL archives.
7990 ///
7991 /// This is the exclusive upper bound for valid `at_commit` indices in
7992 /// `was_linked`. Valid indices are `wal_horizon_floor()..wal_total_commits()`.
7993 ///
7994 /// Returns the horizon floor when all surviving history is empty.
7995 pub fn wal_total_commits(&self) -> Result<u64> {
7996 let (frames, _) = self.all_frames()?;
7997 Ok(self.wal_horizon_floor + frames.len() as u64)
7998 }
7999
8000 /// The global frame index of the first commit reachable through surviving
8001 /// archives (0 when no archives have been pruned).
8002 pub fn wal_horizon_floor(&self) -> u64 {
8003 self.wal_horizon_floor
8004 }
8005
8006 /// Return the per-node change history for `key` by scanning the on-disk WAL.
8007 ///
8008 /// ## Horizon
8009 ///
8010 /// History reaches back only to the last WAL-truncating snapshot, exactly like `open_at`.
8011 /// Snapshots written with `keep_wal: true` preserve deeper history. This is the honest,
8012 /// zero-cost contract; a durable history log is out of scope.
8013 ///
8014 /// ## Derived edges
8015 ///
8016 /// Rule-created (derived) edges are **not** in the WAL and therefore do not appear in
8017 /// history. Only edges written directly by the application are recorded.
8018 ///
8019 /// ## Deleted nodes
8020 ///
8021 /// For nodes that have been deleted, dense-id records (SetPropId, InsertEdgeId) that
8022 /// predate the deletion may not resolve (the id is tombstoned in the live map). The
8023 /// string-keyed `DeleteNode` record still matches and produces a `NodeDeleted` entry.
8024 /// Prop/edge history of a deleted node may therefore be partially unresolvable.
8025 ///
8026 /// ## Dense-id edge entries and tombstoned partners
8027 ///
8028 /// Edge entries from dense-id WAL records (`InsertEdgeId`) are omitted when the partner
8029 /// endpoint's dense id is tombstoned. As a result, a live node's history can contain an
8030 /// `EdgeRemoved` (string-keyed, always resolves) without a corresponding `EdgeAdded`.
8031 /// Build commit-bounded alias intervals for `queried_key`.
8032 ///
8033 /// Returns a list of `(key, valid_from_inclusive, valid_until_exclusive)` tuples.
8034 /// A record written under `key` at commit `c` matches the queried identity iff
8035 /// `c >= valid_from && (valid_until.is_none() || c < valid_until)`.
8036 ///
8037 /// Each alias entry carries both a lower and an upper bound so that key-reuse
8038 /// after a rename is handled correctly: if "a" is renamed to "b" at commit 5,
8039 /// then a NEW node is created as "a" at commit 7 and renamed to "c" at commit 10,
8040 /// querying "c" must NOT surface identity-1's events (commits 0–4 under "a");
8041 /// only identity-2's events (commits 7–9 under "a") are in scope.
8042 ///
8043 /// Only **forward aliasing**: querying the *new* key surfaces events written
8044 /// under the *old* key. The reverse direction is not supported.
8045 fn build_key_alias_intervals(
8046 &self,
8047 frames: &[core_storage::wal::WalRecord],
8048 queried_key: &str,
8049 ) -> Vec<(String, u64, Option<u64>)> {
8050 use core_storage::wal::WalRecord;
8051
8052 // Pre-pass: build reverse_rename and key_starts maps.
8053 let mut reverse_rename: HashMap<String, (String, u64)> = HashMap::new();
8054 let mut key_starts: HashMap<String, Vec<u64>> = HashMap::new();
8055
8056 for (local_i, frame) in frames.iter().enumerate() {
8057 let commit = self.wal_horizon_floor + local_i as u64;
8058 let records: &[WalRecord] = match frame {
8059 WalRecord::Batch(inner) => inner.as_slice(),
8060 single => std::slice::from_ref(single),
8061 };
8062 for rec in records {
8063 match rec {
8064 WalRecord::InsertNode { key, .. } | WalRecord::InsertNodeId { key, .. } => {
8065 key_starts.entry(key.clone()).or_default().push(commit);
8066 }
8067 WalRecord::RenameNode { old_key, new_key } => {
8068 // new_key came into existence at this commit.
8069 key_starts.entry(new_key.clone()).or_default().push(commit);
8070 // Record the reverse rename: new_key was introduced by renaming old_key.
8071 reverse_rename.insert(new_key.clone(), (old_key.clone(), commit));
8072 }
8073 _ => {}
8074 }
8075 }
8076 }
8077
8078 // Build alias intervals by following the reverse rename chain.
8079 let mut result: Vec<(String, u64, Option<u64>)> = Vec::new();
8080 let mut current_key = queried_key.to_string();
8081 let mut current_valid_until: Option<u64> = None;
8082
8083 loop {
8084 // valid_from: the most recent commit where current_key was assigned to this
8085 // identity. For aliases (valid_until = Some(vu)), find the last start event
8086 // for the key strictly before vu — this is where the alias's occupancy by
8087 // this identity began, correctly excluding prior identities that reused the key.
8088 let valid_from = if let Some(vu) = current_valid_until {
8089 key_starts
8090 .get(¤t_key)
8091 .and_then(|starts| starts.iter().rev().find(|&&s| s < vu).copied())
8092 .unwrap_or(self.wal_horizon_floor)
8093 } else {
8094 // Queried key — no upper bound; may have been introduced at any commit.
8095 self.wal_horizon_floor
8096 };
8097
8098 result.push((current_key.clone(), valid_from, current_valid_until));
8099
8100 match reverse_rename.get(¤t_key) {
8101 Some((old_key, rename_commit)) => {
8102 current_valid_until = Some(*rename_commit);
8103 current_key = old_key.clone();
8104 }
8105 None => break,
8106 }
8107 }
8108
8109 result
8110 }
8111
8112 /// Returns true if `record_key` matches any alias interval that covers `commit`.
8113 fn aliases_match(
8114 intervals: &[(String, u64, Option<u64>)],
8115 record_key: &str,
8116 commit: u64,
8117 ) -> bool {
8118 intervals
8119 .iter()
8120 .any(|(k, vf, vu)| k == record_key && commit >= *vf && vu.is_none_or(|u| commit < u))
8121 }
8122
8123 pub fn node_history(&self, key: &str) -> Result<Vec<crate::history::HistoryEntry>> {
8124 use crate::history::{HistoryChange, HistoryEntry};
8125 use core_storage::wal::WalRecord;
8126
8127 let (frames, _) = self.all_frames()?;
8128
8129 // Resolve commit-bounded alias intervals for `key` (handles renames in the WAL).
8130 let alias_intervals = self.build_key_alias_intervals(&frames, key);
8131
8132 let mut out: Vec<HistoryEntry> = Vec::new();
8133
8134 for (local_i, frame) in frames.iter().enumerate() {
8135 let commit = self.wal_horizon_floor + local_i as u64;
8136 // Collect the inner records to process — Batch is one commit, single records are one commit.
8137 let records: &[WalRecord] = match frame {
8138 WalRecord::Batch(inner) => inner.as_slice(),
8139 single => std::slice::from_ref(single),
8140 };
8141
8142 for rec in records {
8143 let change = match rec {
8144 WalRecord::InsertNode { label, key: k, .. }
8145 if Self::aliases_match(&alias_intervals, k, commit) =>
8146 {
8147 Some(HistoryChange::NodeInserted {
8148 label: label.clone(),
8149 })
8150 }
8151 WalRecord::InsertNodeId { label, key: k, .. }
8152 if Self::aliases_match(&alias_intervals, k, commit) =>
8153 {
8154 let label_str = match self.syms.resolve(*label) {
8155 Some(s) => s.to_string(),
8156 None => continue,
8157 };
8158 Some(HistoryChange::NodeInserted { label: label_str })
8159 }
8160 WalRecord::SetProp {
8161 key: k,
8162 field,
8163 value,
8164 } if Self::aliases_match(&alias_intervals, k, commit) => {
8165 Some(HistoryChange::PropSet {
8166 field: field.clone(),
8167 value: value.clone(),
8168 })
8169 }
8170 WalRecord::SetPropId { id, field, value } => match self.ids.key_of(*id) {
8171 // key_of returns the current (post-rename) key; compare to queried key.
8172 Some(resolved) if resolved == key => {
8173 let field_str = match self.syms.resolve(*field) {
8174 Some(s) => s.to_string(),
8175 None => continue,
8176 };
8177 Some(HistoryChange::PropSet {
8178 field: field_str,
8179 value: value.clone(),
8180 })
8181 }
8182 _ => None,
8183 },
8184 WalRecord::RemoveProp { key: k, field }
8185 if Self::aliases_match(&alias_intervals, k, commit) =>
8186 {
8187 Some(HistoryChange::PropRemoved {
8188 field: field.clone(),
8189 })
8190 }
8191 WalRecord::InsertEdge {
8192 edge_type,
8193 src_key,
8194 dst_key,
8195 } => {
8196 if Self::aliases_match(&alias_intervals, src_key, commit) {
8197 Some(HistoryChange::EdgeAdded {
8198 edge_type: edge_type.clone(),
8199 other: dst_key.clone(),
8200 outgoing: true,
8201 })
8202 } else if Self::aliases_match(&alias_intervals, dst_key, commit) {
8203 Some(HistoryChange::EdgeAdded {
8204 edge_type: edge_type.clone(),
8205 other: src_key.clone(),
8206 outgoing: false,
8207 })
8208 } else {
8209 None
8210 }
8211 }
8212 WalRecord::InsertEdgeId { etype, src, dst } => {
8213 let etype_str = match self.syms.resolve(*etype) {
8214 Some(s) => s.to_string(),
8215 None => continue,
8216 };
8217 let src_key = self.ids.key_of(*src);
8218 let dst_key = self.ids.key_of(*dst);
8219 if src_key == Some(key) {
8220 let other = match dst_key {
8221 Some(s) => s.to_string(),
8222 None => continue,
8223 };
8224 Some(HistoryChange::EdgeAdded {
8225 edge_type: etype_str,
8226 other,
8227 outgoing: true,
8228 })
8229 } else if dst_key == Some(key) {
8230 let other = match src_key {
8231 Some(s) => s.to_string(),
8232 None => continue,
8233 };
8234 Some(HistoryChange::EdgeAdded {
8235 edge_type: etype_str,
8236 other,
8237 outgoing: false,
8238 })
8239 } else {
8240 None
8241 }
8242 }
8243 WalRecord::DeleteEdge {
8244 edge_type,
8245 src_key,
8246 dst_key,
8247 } => {
8248 if Self::aliases_match(&alias_intervals, src_key, commit) {
8249 Some(HistoryChange::EdgeRemoved {
8250 edge_type: edge_type.clone(),
8251 other: dst_key.clone(),
8252 outgoing: true,
8253 })
8254 } else if Self::aliases_match(&alias_intervals, dst_key, commit) {
8255 Some(HistoryChange::EdgeRemoved {
8256 edge_type: edge_type.clone(),
8257 other: src_key.clone(),
8258 outgoing: false,
8259 })
8260 } else {
8261 None
8262 }
8263 }
8264 WalRecord::DeleteNode { key: k }
8265 if Self::aliases_match(&alias_intervals, k, commit) =>
8266 {
8267 Some(HistoryChange::NodeDeleted)
8268 }
8269 // Skip: rule/view/fulltext/intern metadata; Batch wrapper handled above.
8270 _ => None,
8271 };
8272
8273 if let Some(change) = change {
8274 out.push(HistoryEntry { commit, change });
8275 }
8276 }
8277 }
8278
8279 Ok(out)
8280 }
8281
8282 /// Return the per-edge change history between nodes `a` and `b` by scanning
8283 /// the on-disk WAL.
8284 ///
8285 /// ## Horizon
8286 ///
8287 /// History reaches back only to the last WAL-truncating snapshot, exactly
8288 /// like `node_history` and `open_at`. The returned [`HistoryResult`] carries
8289 /// `total_commits` (= number of WAL frames), which is the exclusive upper
8290 /// bound for valid commit indices.
8291 ///
8292 /// ## Derived edges
8293 ///
8294 /// Rule-derived edges appear via `DerivedEdgeAdded` / `DerivedEdgeRetracted`
8295 /// WAL markers written by `log_then_apply_with` after each rule-firing
8296 /// mutation. The `rule` field of those events carries the rule name.
8297 ///
8298 /// ## DeleteNode
8299 ///
8300 /// When a node is deleted, its manual incident edges are swept inline without
8301 /// individual `DeleteEdge` WAL records. `edge_history` detects `DeleteNode`
8302 /// events for either endpoint and synthesises `Retracted(rule:None)` events
8303 /// for each manual edge that was active at that point. Derived edges active at
8304 /// the time of deletion are handled by the `DerivedEdgeRetracted` marker that
8305 /// the engine appends immediately after the `DeleteNode` record; those events
8306 /// carry correct rule attribution and are emitted by the marker arm, not the
8307 /// synthetic sweep.
8308 ///
8309 /// ## Masks
8310 ///
8311 /// Like `node_history`, this method has no mask parameter and returns WAL
8312 /// history regardless of any role mask. For masked history semantics, apply
8313 /// the mask at the caller level.
8314 pub fn edge_history(
8315 &self,
8316 a: &str,
8317 b: &str,
8318 ) -> Result<crate::history::HistoryResult<crate::history::EdgeHistoryEvent>> {
8319 use crate::history::{EdgeEvent, EdgeHistoryEvent, HistoryResult};
8320 use core_storage::wal::WalRecord;
8321
8322 let (frames, _) = self.all_frames()?;
8323 let total_commits = self.wal_horizon_floor + frames.len() as u64;
8324
8325 // Resolve all historical names for a and b (handles RenameNode in the WAL).
8326 // Intervals are commit-bounded so recycled keys don't contaminate histories.
8327 let alias_a = self.build_key_alias_intervals(&frames, a);
8328 let alias_b = self.build_key_alias_intervals(&frames, b);
8329
8330 // Active edges between a and b tracked as (edge_type, src_key, dst_key, is_derived).
8331 // The is_derived flag is used by the DeleteNode sweep: manual edges are
8332 // swept with a synthetic Retracted(rule:None); derived edges are skipped
8333 // because the engine writes a DerivedEdgeRetracted marker immediately after
8334 // the DeleteNode record, which carries the correct rule attribution.
8335 let mut active: Vec<(String, String, String, bool)> = Vec::new();
8336 let mut out: Vec<EdgeHistoryEvent> = Vec::new();
8337
8338 for (local_i, frame) in frames.iter().enumerate() {
8339 let commit = self.wal_horizon_floor + local_i as u64;
8340 let records: &[WalRecord] = match frame {
8341 WalRecord::Batch(inner) => inner.as_slice(),
8342 single => std::slice::from_ref(single),
8343 };
8344
8345 for rec in records {
8346 match rec {
8347 WalRecord::InsertEdge {
8348 edge_type,
8349 src_key,
8350 dst_key,
8351 } => {
8352 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
8353 && Self::aliases_match(&alias_b, dst_key, commit);
8354 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
8355 && Self::aliases_match(&alias_a, dst_key, commit);
8356 if is_ab || is_ba {
8357 active.push((
8358 edge_type.clone(),
8359 src_key.clone(),
8360 dst_key.clone(),
8361 false,
8362 ));
8363 out.push(EdgeHistoryEvent {
8364 edge_type: edge_type.clone(),
8365 commit,
8366 event: EdgeEvent::Added,
8367 rule: None,
8368 });
8369 }
8370 }
8371 WalRecord::InsertEdgeId { etype, src, dst } => {
8372 let etype_str = match self.syms.resolve(*etype) {
8373 Some(s) => s.to_string(),
8374 None => continue,
8375 };
8376 // Use key_of_historical so tombstoned nodes (deleted
8377 // later in the WAL) still resolve during the scan.
8378 let src_key = self.ids.key_of_historical(*src);
8379 let dst_key = self.ids.key_of_historical(*dst);
8380 let is_ab = src_key == Some(a) && dst_key == Some(b);
8381 let is_ba = src_key == Some(b) && dst_key == Some(a);
8382 if is_ab || is_ba {
8383 let src_str = src_key.unwrap().to_string();
8384 let dst_str = dst_key.unwrap().to_string();
8385 active.push((etype_str.clone(), src_str, dst_str, false));
8386 out.push(EdgeHistoryEvent {
8387 edge_type: etype_str,
8388 commit,
8389 event: EdgeEvent::Added,
8390 rule: None,
8391 });
8392 }
8393 }
8394 WalRecord::DeleteEdge {
8395 edge_type,
8396 src_key,
8397 dst_key,
8398 } => {
8399 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
8400 && Self::aliases_match(&alias_b, dst_key, commit);
8401 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
8402 && Self::aliases_match(&alias_a, dst_key, commit);
8403 if is_ab || is_ba {
8404 // Remove the first matching active entry (flag ignored).
8405 if let Some(pos) = active.iter().position(|(et, s, d, _)| {
8406 et == edge_type && s == src_key && d == dst_key
8407 }) {
8408 active.remove(pos);
8409 }
8410 out.push(EdgeHistoryEvent {
8411 edge_type: edge_type.clone(),
8412 commit,
8413 event: EdgeEvent::Retracted,
8414 rule: None,
8415 });
8416 }
8417 }
8418 WalRecord::DeleteNode { key: k }
8419 if Self::aliases_match(&alias_a, k, commit)
8420 || Self::aliases_match(&alias_b, k, commit) =>
8421 {
8422 // Sweep: implicitly retract only MANUAL active edges.
8423 // Derived active edges are skipped here because the rule
8424 // engine appends a DerivedEdgeRetracted marker immediately
8425 // after this DeleteNode record; that marker produces the
8426 // single correctly-attributed Retracted event. Derived
8427 // entries are dropped from `active` (the marker arm's
8428 // idempotent retain finds nothing to remove).
8429 for (et, _, _, is_derived) in active.drain(..) {
8430 if !is_derived {
8431 out.push(EdgeHistoryEvent {
8432 edge_type: et,
8433 commit,
8434 event: EdgeEvent::Retracted,
8435 rule: None,
8436 });
8437 }
8438 // Derived: drop silently; marker carries the Retracted event.
8439 }
8440 }
8441 WalRecord::DerivedEdgeAdded {
8442 rule,
8443 edge_type: et,
8444 src_key,
8445 dst_key,
8446 } => {
8447 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
8448 && Self::aliases_match(&alias_b, dst_key, commit);
8449 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
8450 && Self::aliases_match(&alias_a, dst_key, commit);
8451 if is_ab || is_ba {
8452 active.push((et.clone(), src_key.clone(), dst_key.clone(), true));
8453 out.push(EdgeHistoryEvent {
8454 edge_type: et.clone(),
8455 commit,
8456 event: EdgeEvent::Added,
8457 rule: Some(rule.clone()),
8458 });
8459 }
8460 }
8461 WalRecord::DerivedEdgeRetracted {
8462 rule,
8463 edge_type: et,
8464 src_key,
8465 dst_key,
8466 } => {
8467 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
8468 && Self::aliases_match(&alias_b, dst_key, commit);
8469 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
8470 && Self::aliases_match(&alias_a, dst_key, commit);
8471 if is_ab || is_ba {
8472 // Push unconditionally: a derived edge whose Added marker
8473 // predates the history horizon has no `active` entry, but
8474 // the retraction is still a real in-window event.
8475 // Remove from active idempotently if present.
8476 active.retain(|(aet, s, d, _)| {
8477 !(aet == et && s == src_key && d == dst_key)
8478 });
8479 out.push(EdgeHistoryEvent {
8480 edge_type: et.clone(),
8481 commit,
8482 event: EdgeEvent::Retracted,
8483 rule: Some(rule.clone()),
8484 });
8485 }
8486 }
8487 // All other records (InsertNode, SetProp, CreateRule, etc.)
8488 // do not affect edges between a and b.
8489 _ => {}
8490 }
8491 }
8492 }
8493
8494 Ok(HistoryResult {
8495 items: out,
8496 total_commits,
8497 })
8498 }
8499
8500 /// Return `true` iff an edge of `edge_type` existed between `a` and `b`
8501 /// (in either direction) at the WAL commit `at_commit`.
8502 ///
8503 /// ## Horizon
8504 ///
8505 /// Valid commit indices are `0..total_commits` where `total_commits` is the
8506 /// number of WAL frames. An `at_commit >= total_commits` is outside the
8507 /// visible horizon and returns [`GraphError::CommitOutOfRange`].
8508 ///
8509 /// ## Derived edges
8510 ///
8511 /// Rule-derived edges are tracked via `DerivedEdgeAdded` / `DerivedEdgeRetracted`
8512 /// WAL markers appended at firing time (Task 1). `was_linked` reads these markers
8513 /// and therefore includes derived edges in its point-in-time evaluation,
8514 /// matching `edge_history`'s fidelity.
8515 pub fn was_linked(&self, a: &str, b: &str, edge_type: &str, at_commit: u64) -> Result<bool> {
8516 use core_storage::wal::WalRecord;
8517
8518 let (frames, _) = self.all_frames()?;
8519 let total_commits = self.wal_horizon_floor + frames.len() as u64;
8520
8521 // Horizon floor: commits in pruned archives are unreachable.
8522 if at_commit < self.wal_horizon_floor {
8523 return Err(GraphError::CommitOutOfRange {
8524 commit: at_commit,
8525 total: total_commits,
8526 });
8527 }
8528 if at_commit >= total_commits {
8529 return Err(GraphError::CommitOutOfRange {
8530 commit: at_commit,
8531 total: total_commits,
8532 });
8533 }
8534
8535 // Resolve all historical names for a and b (handles RenameNode in the WAL).
8536 // Intervals are commit-bounded so recycled keys don't contaminate point-in-time reads.
8537 let alias_a = self.build_key_alias_intervals(&frames, a);
8538 let alias_b = self.build_key_alias_intervals(&frames, b);
8539
8540 // Local index into surviving frames (0 = first frame of oldest archive).
8541 let local_commit = at_commit - self.wal_horizon_floor;
8542
8543 // Replay local frames 0..=local_commit, tracking active edges.
8544 let mut active: BTreeSet<(String, String, String)> = BTreeSet::new();
8545
8546 for (local_i, frame) in frames.iter().enumerate().take((local_commit + 1) as usize) {
8547 let commit = self.wal_horizon_floor + local_i as u64;
8548 let records: &[WalRecord] = match frame {
8549 WalRecord::Batch(inner) => inner.as_slice(),
8550 single => std::slice::from_ref(single),
8551 };
8552
8553 for rec in records {
8554 match rec {
8555 WalRecord::InsertEdge {
8556 edge_type: et,
8557 src_key,
8558 dst_key,
8559 } => {
8560 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
8561 && Self::aliases_match(&alias_b, dst_key, commit);
8562 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
8563 && Self::aliases_match(&alias_a, dst_key, commit);
8564 if is_ab || is_ba {
8565 active.insert((et.clone(), src_key.clone(), dst_key.clone()));
8566 }
8567 }
8568 WalRecord::InsertEdgeId { etype, src, dst } => {
8569 let etype_str = match self.syms.resolve(*etype) {
8570 Some(s) => s.to_string(),
8571 None => continue,
8572 };
8573 // Use key_of_historical so tombstoned nodes resolve.
8574 let src_key = self.ids.key_of_historical(*src);
8575 let dst_key = self.ids.key_of_historical(*dst);
8576 let is_ab = src_key == Some(a) && dst_key == Some(b);
8577 let is_ba = src_key == Some(b) && dst_key == Some(a);
8578 if is_ab || is_ba {
8579 active.insert((
8580 etype_str,
8581 src_key.unwrap().to_string(),
8582 dst_key.unwrap().to_string(),
8583 ));
8584 }
8585 }
8586 WalRecord::DeleteEdge {
8587 edge_type: et,
8588 src_key,
8589 dst_key,
8590 } => {
8591 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
8592 && Self::aliases_match(&alias_b, dst_key, commit);
8593 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
8594 && Self::aliases_match(&alias_a, dst_key, commit);
8595 if is_ab || is_ba {
8596 active.remove(&(et.clone(), src_key.clone(), dst_key.clone()));
8597 }
8598 }
8599 WalRecord::DeleteNode { key: k }
8600 if Self::aliases_match(&alias_a, k, commit)
8601 || Self::aliases_match(&alias_b, k, commit) =>
8602 {
8603 // All edges touching the deleted node are gone.
8604 active.retain(|(_, s, d)| s != k && d != k);
8605 }
8606 WalRecord::DerivedEdgeAdded {
8607 edge_type: et,
8608 src_key,
8609 dst_key,
8610 ..
8611 } => {
8612 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
8613 && Self::aliases_match(&alias_b, dst_key, commit);
8614 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
8615 && Self::aliases_match(&alias_a, dst_key, commit);
8616 if is_ab || is_ba {
8617 active.insert((et.clone(), src_key.clone(), dst_key.clone()));
8618 }
8619 }
8620 WalRecord::DerivedEdgeRetracted {
8621 edge_type: et,
8622 src_key,
8623 dst_key,
8624 ..
8625 } => {
8626 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
8627 && Self::aliases_match(&alias_b, dst_key, commit);
8628 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
8629 && Self::aliases_match(&alias_a, dst_key, commit);
8630 if is_ab || is_ba {
8631 active.remove(&(et.clone(), src_key.clone(), dst_key.clone()));
8632 }
8633 }
8634 _ => {}
8635 }
8636 }
8637 }
8638
8639 Ok(active.iter().any(|(et, _, _)| et == edge_type))
8640 }
8641
8642 pub fn edge_count(&self) -> u64 {
8643 self.topo_view().edge_count()
8644 }
8645
8646 /// Live/tombstone/edge counts plus per-rule provenance size, trip latch,
8647 /// and fire counter (includes rebuild evaluations). Rules are sorted by name.
8648 pub fn stats(&self) -> Stats {
8649 self.ensure_v8_base_sections_loaded();
8650 let rules: Vec<RuleStats> = self
8651 .engine
8652 .rules()
8653 .map(|r| RuleStats {
8654 name: r.name.clone(),
8655 edges: self
8656 .engine
8657 .provenance()
8658 .get(&r.name)
8659 .map(|s| s.len() as u64)
8660 .unwrap_or(0),
8661 tripped: self.engine.is_tripped(&r.name),
8662 fires: self.engine.fire_count(&r.name),
8663 approximate: r.approximate,
8664 })
8665 .collect();
8666 Stats {
8667 nodes_live: self.ids.live_len(),
8668 nodes_tombstoned: self.ids.len() - self.ids.live_len(),
8669 edges: self.topo_view().edge_count(),
8670 rules,
8671 }
8672 }
8673
8674 /// On-disk size of the WAL file in bytes.
8675 ///
8676 /// Reads file metadata without loading WAL contents. Returns `Err` for
8677 /// in-memory (`SimFs`) databases where no WAL file exists on disk.
8678 pub fn wal_size_bytes(&self) -> std::io::Result<u64> {
8679 let path = self.fs.wal_path().ok_or_else(|| {
8680 std::io::Error::new(
8681 std::io::ErrorKind::Unsupported,
8682 "wal_path not available for this Fs implementation",
8683 )
8684 })?;
8685 Ok(std::fs::metadata(path)?.len())
8686 }
8687
8688 /// Set the slow-query threshold. Queries whose execution time equals or
8689 /// exceeds `ms` milliseconds are logged. Pass `0` to disable.
8690 ///
8691 /// Use this setter in tests — the environment variable
8692 /// `MUSHROOMDB_SLOW_QUERY_MS` is process-global and races parallel test
8693 /// threads.
8694 pub fn set_slow_query_threshold_ms(&mut self, ms: u64) {
8695 self.slow_query_threshold_ms = ms;
8696 }
8697
8698 /// Snapshot of the slow-query ring buffer and lifetime counter.
8699 pub fn slow_query_snapshot(&self) -> SlowQuerySnapshot {
8700 let log = self.slow_queries.lock().unwrap_or_else(|e| e.into_inner());
8701 SlowQuerySnapshot {
8702 threshold_ms: self.slow_query_threshold_ms,
8703 count: log.total,
8704 last: log.entries.iter().cloned().collect(),
8705 }
8706 }
8707
8708 /// Instant the database was opened. Used by consumers (e.g. `/metrics`)
8709 /// to compute uptime.
8710 pub fn started_at(&self) -> std::time::Instant {
8711 self.started_at
8712 }
8713
8714 /// On-disk snapshot format version this binary writes and reads.
8715 pub fn format_version() -> u16 {
8716 core_storage::snapshot::VERSION
8717 }
8718
8719 /// Test-support: total bytes appended (SimFs only usage).
8720 pub fn fs_total_appended(&self) -> usize
8721 where
8722 F: FsIntrospect,
8723 {
8724 self.fs.total_appended()
8725 }
8726
8727 /// Test-support: successful `Fs::sync` calls (SimFs / counting fs).
8728 pub fn fs_sync_count(&self) -> usize
8729 where
8730 F: FsIntrospect,
8731 {
8732 self.fs.sync_count()
8733 }
8734
8735 /// Consume the db, returning its fs (for crash simulation).
8736 pub fn into_fs(self) -> F {
8737 self.fs
8738 }
8739
8740 pub fn snapshot(&mut self) -> Result<()> {
8741 self.snapshot_with(SnapshotOptions::default())
8742 }
8743
8744 /// Snapshot with explicit options.
8745 ///
8746 /// # `keep_wal`
8747 ///
8748 /// When `keep_wal` is `false` (the default, same as [`snapshot`]):
8749 /// - The WAL is replaced with a minimal baseline containing one
8750 /// `EnableFulltext` record per active declaration. All pre-snapshot
8751 /// history is discarded; `open_at` can only reach post-snapshot commits.
8752 ///
8753 /// When `keep_wal` is `true`:
8754 /// - The WAL is left intact. All pre-snapshot commits remain reachable
8755 /// via `open_at`. The existing WAL already contains the original
8756 /// `EnableFulltext` records, so no baseline re-write is needed; the
8757 /// recovery guards in `apply()` silently skip any duplicate records on
8758 /// replay.
8759 /// - Crash window: a crash after the snapshot write but before the next
8760 /// WAL write leaves the full pre-snapshot WAL intact. On reopen the
8761 /// snapshot is loaded and the WAL replayed idempotently over it — safe
8762 /// because every `apply()` arm is idempotent when replayed over an
8763 /// already-current snapshot.
8764 pub fn snapshot_with(&mut self, opts: SnapshotOptions) -> Result<()> {
8765 if self.read_only {
8766 return Err(GraphError::ReadOnly);
8767 }
8768 // Capture whether snapshot.bin already existed BEFORE this snapshot write.
8769 // Used by the archive path's conservative genesis-chain check: if a prior
8770 // snapshot exists but wal.truncated does not, we cannot distinguish a
8771 // legacy store (may have been truncated in an older code version) from a
8772 // new store that only used keep_wal=true. Conservative: refuse genesis in
8773 // both cases. Must be sampled here, before the snapshot write below.
8774 let had_prior_snapshot = self.fs.snapshot_path().map(|p| p.exists()).unwrap_or(false);
8775 self.ensure_v8_base_sections_loaded();
8776 // Ensure provenance is decoded before to_persist() clones it.
8777 self.engine.ensure_provenance_loaded_mut();
8778 let (rule_defs_typed, provenance, rule_tripped, rule_fires) = self.engine.to_persist();
8779 let rule_defs = rule_defs_typed
8780 .iter()
8781 .map(|r| bincode::serialize(r).expect("RuleDef serialize cannot fail"))
8782 .collect();
8783 // Collect HNSW state and IVF state. When indexes are not yet
8784 // populated (clean open, no mutation since open), pass the retained
8785 // raw bytes through directly so that migrate/snapshot does not
8786 // silently discard fitted approximate-rule indexes.
8787 let hnsw_state = self.engine.export_hnsw_state_passthrough();
8788 let ivf_bytes = if !self.engine.indexes_populated() {
8789 // Pass retained IVF bytes through unchanged (no re-encode).
8790 self.engine.retained_ivf_bytes_clone().unwrap_or_default()
8791 } else {
8792 // Indexes live: encode from current state.
8793 let raw_ivf = self.engine.export_ivf_state();
8794 let ivf_state_map: BTreeMap<String, core_storage::snapshot::PerRuleIvfState> = raw_ivf
8795 .into_iter()
8796 .map(|(name, ((sc, sa, sd), (dc, da, dd)))| {
8797 (
8798 name,
8799 core_storage::snapshot::PerRuleIvfState {
8800 src: core_storage::snapshot::SideIvfState {
8801 centroids: sc,
8802 clusters: sa,
8803 drift: sd,
8804 },
8805 dst: core_storage::snapshot::SideIvfState {
8806 centroids: dc,
8807 clusters: da,
8808 drift: dd,
8809 },
8810 },
8811 )
8812 })
8813 .collect();
8814 if ivf_state_map.is_empty() {
8815 Vec::new()
8816 } else {
8817 bincode::serialize(&ivf_state_map).expect("IVF state serialize cannot fail")
8818 }
8819 };
8820 let view_defs: Vec<Vec<u8>> = self
8821 .view_store
8822 .views()
8823 .map(|v| bincode::serialize(v).expect("ViewDef serialize cannot fail"))
8824 .collect();
8825 if self.base.is_some() {
8826 // V8 merge-snapshot path: encode base+overlay into a new V8 snapshot,
8827 // write it atomically, remap it as the new base, then clear the overlay.
8828 let meta = V8Meta {
8829 labels: self.labels.clone(),
8830 edge_props: self.edge_props.clone(),
8831 rule_defs,
8832 provenance,
8833 rule_tripped,
8834 rule_fires,
8835 ivf_bytes,
8836 view_defs,
8837 wal_truncated: !opts.keep_wal,
8838 hnsw: hnsw_state,
8839 last_change: self.last_change.clone(),
8840 };
8841 let mut buf: Vec<u8> = Vec::new();
8842 {
8843 // Clone the Arc so the old base stays alive while we encode.
8844 // The borrow of archived_csr (into old_base's mmap) is released
8845 // at the end of this block, before we replace self.base.
8846 let old_base = self.base.clone().expect("is_some checked above");
8847 let archived_csr = old_base.topology().map_err(|e| GraphError::Corrupt {
8848 detail: format!("v8 snapshot: topology section: {e:?}"),
8849 })?;
8850 let archived_cols = old_base.columns().map_err(|e| GraphError::Corrupt {
8851 detail: format!("v8 snapshot: columns section: {e:?}"),
8852 })?;
8853 let archived_edge_props =
8854 old_base
8855 .edge_props_section()
8856 .map_err(|e| GraphError::Corrupt {
8857 detail: format!("v8 snapshot: edge_props section: {e:?}"),
8858 })?;
8859 let edge_props_raw =
8860 old_base
8861 .edge_props_raw_bytes()
8862 .map_err(|e| GraphError::Corrupt {
8863 detail: format!("v8 snapshot: edge_props raw bytes: {e:?}"),
8864 })?;
8865 let prov_raw =
8866 old_base
8867 .provenance_raw_bytes()
8868 .map_err(|e| GraphError::Corrupt {
8869 detail: format!("v8 snapshot: provenance raw bytes: {e:?}"),
8870 })?;
8871 encode_v8(
8872 Some(archived_csr),
8873 Some(archived_cols),
8874 Some((archived_edge_props, edge_props_raw)),
8875 Some(prov_raw),
8876 &self.topo,
8877 &self.props,
8878 &self.ids,
8879 &self.syms,
8880 &meta,
8881 &mut buf,
8882 )?;
8883 }
8884 self.fs.write_atomic(FileId::Snapshot, &buf)?;
8885 // Remap the freshly-written snapshot as the new base.
8886 // C2: use file mmap on RealFs; fall back to from_bytes on SimFs.
8887 let new_base = if let Some(snap_path) = self.fs.snapshot_path() {
8888 core_storage::v8::MappedBase::map(&snap_path)
8889 } else {
8890 core_storage::v8::MappedBase::from_bytes(buf)
8891 }
8892 .map_err(|e| GraphError::Corrupt {
8893 detail: format!("v8 snapshot: remap new base: {e:?}"),
8894 })?;
8895 self.base = Some(Arc::new(new_base));
8896 // Clear the overlay and prop tombstones — all data is now in the new base.
8897 self.topo = Topology::new();
8898 self.props = core_storage::columns::ColumnStore::new();
8899 } else {
8900 // Legacy path (V5–V7 stores without a V8 base).
8901 //
8902 // Memory-diet path: build V8Meta directly from &self — no SnapshotState
8903 // clone and no encode_v8_from_state intermediate clones. The big
8904 // structures (self.topo, self.props) are borrowed, not cloned.
8905 // self.edge_props is moved (not cloned) because we immediately clear it
8906 // when we remap the new V8 snapshot as self.base (see below).
8907 //
8908 // Eliminates from peak RSS vs. the old SnapshotState path:
8909 // • self.topo.clone() (~topology HashMap footprint)
8910 // • self.props.clone() (~column-store footprint)
8911 // • encode_v8_from_state V8Meta secondary clones (labels, edge_props, …)
8912 let meta = V8Meta {
8913 labels: self.labels.clone(),
8914 wal_truncated: !opts.keep_wal,
8915 // Move edge_props out so the large overlay is freed when meta
8916 // drops at end of this block (self.edge_props is now empty; reads
8917 // after base assignment go through the mmap'd base section).
8918 edge_props: std::mem::take(&mut self.edge_props),
8919 rule_defs,
8920 provenance,
8921 rule_tripped,
8922 rule_fires,
8923 ivf_bytes,
8924 view_defs,
8925 hnsw: hnsw_state,
8926 last_change: self.last_change.clone(),
8927 };
8928 let mut buf = Vec::new();
8929 encode_v8(
8930 None,
8931 None,
8932 None,
8933 None,
8934 &self.topo,
8935 &self.props,
8936 &self.ids,
8937 &self.syms,
8938 &meta,
8939 &mut buf,
8940 )?;
8941 // meta (and the moved edge_props inside it) is no longer needed;
8942 // drop it before the write to keep the peak window narrow.
8943 drop(meta);
8944 self.fs.write_atomic(FileId::Snapshot, &buf)?;
8945 // Remap the freshly-written V8 snapshot as self.base.
8946 // On RealFs: drop the encode buffer before mmap to recover ~1.9 GiB.
8947 // On SimFs (tests): pass buf to from_bytes.
8948 let new_base = if let Some(snap_path) = self.fs.snapshot_path() {
8949 drop(buf);
8950 core_storage::v8::MappedBase::map(&snap_path)
8951 } else {
8952 core_storage::v8::MappedBase::from_bytes(buf)
8953 }
8954 .map_err(|e| GraphError::Corrupt {
8955 detail: format!("v8 snapshot: remap new base (legacy path): {e:?}"),
8956 })?;
8957 self.base = Some(Arc::new(new_base));
8958 // Free the large heap-allocated decoded state — all data is now in the
8959 // mmap'd base. Mirrors the V8 merge-snapshot path (see above).
8960 // self.edge_props was already moved into meta and is effectively empty.
8961 self.topo = Topology::new();
8962 self.props = core_storage::columns::ColumnStore::new();
8963 }
8964
8965 if opts.archive_wal {
8966 // History-preserving snapshot (Task 4):
8967 // 1. Snapshot already written above (write_atomic → fsynced).
8968 // 2. Rename WAL → wal.<commit_seq>.archive (atomic, same fs).
8969 // Crash window B: crash here leaves archive present, WAL
8970 // absent. Reopen: snapshot loaded (full state), no WAL
8971 // replay. Archive is NOT replayed into live state — it is
8972 // pre-snapshot by construction. Safe.
8973 // 3. Optionally write genesis marker (first archive only, no
8974 // prior WAL truncation).
8975 // 4. Prune old archives (retention), update horizon floor.
8976 // Pruning invalidates the genesis chain; delete marker.
8977 // 5. Write new minimal baseline WAL (write_atomic).
8978 // Crash window C: crash here leaves new archive plus no live
8979 // WAL. Same as window B — handled above.
8980 //
8981 // Sample existing archives BEFORE the rename so we can detect
8982 // whether this is the first archive.
8983 let existing_archives = self.fs.list_archives()?;
8984 let is_first_archive = existing_archives.is_empty();
8985
8986 // Compute a globally-monotonic archive name: the name equals the
8987 // cumulative end-frame index of the archive in global commit space.
8988 //
8989 // Using `commit_seq` directly is UNSOUND across sessions: on reopen
8990 // commit_seq is seeded from max(last_change), which underestimates
8991 // the WAL depth when trailing commits (e.g. insert_edge) do not
8992 // update last_change. A session-2 archive could then receive a name
8993 // ≤ the session-1 archive, causing incorrect sort order or collision.
8994 //
8995 // Instead: read and decode the live WAL here (before the rename) to
8996 // get its exact frame count, then add it to the last known global
8997 // end-frame index (the name of the most recent existing archive, or
8998 // wal_horizon_floor if no archives exist). This is O(WAL size) but
8999 // snapshot is already serialising the full graph state, so the cost
9000 // is dominated.
9001 let live_wal_bytes_for_name = self.fs.read(FileId::Wal)?;
9002 let (live_frames_for_name, _) = decode_all(&live_wal_bytes_for_name);
9003 let archive_n = existing_archives
9004 .last()
9005 .copied()
9006 .unwrap_or(self.wal_horizon_floor)
9007 + live_frames_for_name.len() as u64;
9008 self.fs.archive_wal(archive_n)?;
9009
9010 // Genesis marker: written once when the first archive is taken
9011 // from a store that has never undergone a WAL-truncating snapshot.
9012 // When present, `open_at` may replay archive-resident commits from
9013 // empty state (the archive chain covers from global index 0).
9014 //
9015 // Two conditions must ALL hold:
9016 // 1. This is the first archive (existing_archives was empty).
9017 // 2. No snapshot.bin existed before this operation (had_prior_snapshot=false).
9018 // A WAL-truncating snapshot (keep_wal=false) always writes snapshot.bin
9019 // before truncating the WAL, so if any prior truncating snapshot was taken
9020 // — even in a previous session — snapshot.bin is present and this condition
9021 // is false. This subsumes the cross-session truncation case without
9022 // requiring a separate wal.truncated sidecar file.
9023 // For legacy stores (snapshot.bin written by an older code version that
9024 // may have truncated the WAL), the same conservative refusal applies:
9025 // we cannot prove the chain is complete, so we refuse genesis (cost =
9026 // no as-of-through-archives; never silent wrong data).
9027 // On SimFs (snapshot_path() == None) had_prior_snapshot is always false,
9028 // so SimFs always passes this check.
9029 if is_first_archive && !had_prior_snapshot {
9030 self.fs.write_genesis_marker()?;
9031 self.archive_genesis_chain = true;
9032 }
9033
9034 // Retention pruning: keep newest `keep` archives; delete oldest.
9035 // Pruning is the ONLY deletion site for archives.
9036 //
9037 // Crash-safety ordering (C1 fix):
9038 // 1. Count frames in surplus archives (reads only — no mutation).
9039 // 2. Advance and PERSIST the horizon floor FIRST via write-then-
9040 // rename (atomic). A crash after this point leaves orphaned
9041 // archives on disk, but the floor is correct. The opening
9042 // cleanup sweep (`cleanup_orphaned_archives`) removes them on
9043 // the next open, so the store is always safe to reopen.
9044 // 3. Delete the genesis marker (floor > 0 already blocks open_at
9045 // via the conjunctive gate; marker cleanup is belt-and-suspenders).
9046 // 4. Delete surplus archives. A crash between any two deletes
9047 // leaves the floor committed and orphaned archives cleaned at
9048 // next open — never a stale floor with a missing archive prefix.
9049 if let Some(keep) = self.wal_archive_retention {
9050 if keep > 0 {
9051 let archives = self.fs.list_archives()?;
9052 // archives is sorted ascending (oldest first)
9053 if archives.len() as u32 > keep {
9054 let surplus = archives.len() - keep as usize;
9055 // Step 1: count pruned frames (reads, no mutation).
9056 let mut pruned_frames = 0u64;
9057 for &n in &archives[..surplus] {
9058 let bytes = self.fs.read_archive(n)?;
9059 let (frames, _) = decode_all(&bytes);
9060 pruned_frames += frames.len() as u64;
9061 }
9062 // Step 2: advance and persist floor FIRST.
9063 self.wal_horizon_floor += pruned_frames;
9064 self.fs.write_horizon_floor(self.wal_horizon_floor)?;
9065 // Step 3: delete genesis marker (floor > 0 already
9066 // blocks open_at; this is belt-and-suspenders cleanup).
9067 if pruned_frames > 0 && self.archive_genesis_chain {
9068 self.fs.delete_genesis_marker()?;
9069 self.archive_genesis_chain = false;
9070 }
9071 // Step 4: delete surplus archives. Crash here →
9072 // orphaned archives; cleaned at next open.
9073 for &n in &archives[..surplus] {
9074 self.fs.delete_archive(n)?;
9075 }
9076 }
9077 }
9078 }
9079
9080 // Write new minimal baseline WAL (mirrors the keep_wal=false path).
9081 let mut baseline_wal: Vec<u8> = Vec::new();
9082 for (label, field) in self.fulltext.enabled_pairs() {
9083 let rec = WalRecord::EnableFulltext {
9084 label: label.clone(),
9085 field: field.clone(),
9086 };
9087 baseline_wal.extend_from_slice(&encode_record(&rec));
9088 }
9089 for (label, field) in self.prop_index.enabled_pairs() {
9090 let rec = WalRecord::EnableIndex {
9091 label: label.clone(),
9092 field: field.clone(),
9093 };
9094 baseline_wal.extend_from_slice(&encode_record(&rec));
9095 }
9096 self.fs.write_atomic(FileId::Wal, &baseline_wal)?;
9097 } else if opts.keep_wal {
9098 // keep_wal=true: WAL is left untouched. The existing WAL already
9099 // contains the EnableFulltext records from the original enable calls;
9100 // replay is idempotent (guards in apply() skip already-live entries).
9101 // No baseline re-write is needed or safe here — the full WAL history
9102 // must remain intact for open_at to reach pre-snapshot commits.
9103 } else {
9104 // keep_wal=false (default): truncate by replacing the WAL with a
9105 // minimal baseline of one EnableFulltext record per active pair.
9106 //
9107 // Crash-ordering: write_atomic is atomic.
9108 // • Crash before snapshot write → WAL unchanged. Safe.
9109 // • Crash after snapshot write but before this WAL write → full
9110 // pre-snapshot WAL still present; open_with replays idempotently.
9111 // • Crash after both writes → normal post-snapshot state.
9112 //
9113 // Genesis chain: a WAL-truncating snapshot breaks the archive chain
9114 // for any archives taken AFTER this point (their WAL slices would
9115 // not start at genesis). Delete any existing genesis marker so that
9116 // open_at refuses archive-resident commits. Future sessions are
9117 // covered by had_prior_snapshot: snapshot.bin written here persists
9118 // across sessions and prevents a later archiving session from
9119 // incorrectly claiming a complete genesis chain.
9120 if self.archive_genesis_chain {
9121 self.fs.delete_genesis_marker()?;
9122 self.archive_genesis_chain = false;
9123 }
9124 let mut baseline_wal: Vec<u8> = Vec::new();
9125 for (label, field) in self.fulltext.enabled_pairs() {
9126 let rec = WalRecord::EnableFulltext {
9127 label: label.clone(),
9128 field: field.clone(),
9129 };
9130 baseline_wal.extend_from_slice(&encode_record(&rec));
9131 }
9132 for (label, field) in self.prop_index.enabled_pairs() {
9133 let rec = WalRecord::EnableIndex {
9134 label: label.clone(),
9135 field: field.clone(),
9136 };
9137 baseline_wal.extend_from_slice(&encode_record(&rec));
9138 }
9139 self.fs.write_atomic(FileId::Wal, &baseline_wal)?;
9140 }
9141 // After snapshot the overlay may have changed (V8 merge path clears
9142 // self.topo and self.props). Refresh the MVCC fold so future readers
9143 // see the post-snapshot state rather than stale overlay data.
9144 self.fold_now();
9145 Ok(())
9146 }
9147}
9148
9149/// Queued mutation for a [`BatchBuilder`] or [`GraphDb::commit_group`].
9150///
9151/// The `submit_batch` / `commit_group` APIs accept `Vec<BatchOp>` so that
9152/// callers can build a set of mutations without holding `&mut GraphDb` and
9153/// hand them off to the group-committing writer for durable, batched I/O.
9154pub enum BatchOp {
9155 InsertNode {
9156 label: String,
9157 key: String,
9158 props: Vec<(String, Value)>,
9159 },
9160 InsertEdge {
9161 edge_type: String,
9162 src_key: String,
9163 dst_key: String,
9164 },
9165 SetProp {
9166 key: String,
9167 field: String,
9168 value: Value,
9169 },
9170 RemoveProp {
9171 key: String,
9172 field: String,
9173 },
9174 DeleteEdge {
9175 edge_type: String,
9176 src_key: String,
9177 dst_key: String,
9178 },
9179 DeleteNode {
9180 key: String,
9181 },
9182 CreateRule(RuleDef),
9183 DeleteRule {
9184 name: String,
9185 },
9186 /// Rename a node's key. Validated: old must exist, new must not.
9187 RenameNode {
9188 old_key: String,
9189 new_key: String,
9190 },
9191 /// Insert an edge, auto-creating any missing endpoint as a plain node with
9192 /// `placeholder_label` and no props. Rules fire and last-change is updated
9193 /// for each created endpoint (normal InsertNode semantics in the batch frame).
9194 InsertEdgeUpsert {
9195 edge_type: String,
9196 src_key: String,
9197 dst_key: String,
9198 placeholder_label: String,
9199 },
9200}
9201
9202/// Three-way node visibility status used by `check_single_op_authz`.
9203enum NodeAuthzStatus {
9204 /// Node exists in the store and is in the role's read mask.
9205 Visible(String), // carries the node's label
9206 /// Node exists in the store but is NOT in the role's read mask.
9207 Hidden,
9208 /// Node does not exist in the store.
9209 Absent,
9210}
9211
9212/// Overlay of ops already accepted earlier in the same batch. Never written
9213/// back to the database — validation only.
9214#[derive(Default)]
9215struct Overlay {
9216 extra_keys: BTreeSet<String>,
9217 deleted_keys: BTreeSet<String>,
9218 extra_props: BTreeMap<(String, String), Value>,
9219 removed_props: BTreeSet<(String, String)>,
9220 extra_edges: BTreeSet<(String, String, String)>,
9221 deleted_edges: BTreeSet<(String, String, String)>,
9222 extra_rules: BTreeSet<String>,
9223 deleted_rules: BTreeSet<String>,
9224}
9225
9226/// Read-only view of live db state plus a batch overlay. Shared by single-op
9227/// public methods (empty overlay) and `commit_batch`.
9228struct MutPreview<'a, F: Fs> {
9229 db: &'a GraphDb<F>,
9230 overlay: Overlay,
9231}
9232
9233impl<'a, F: Fs> MutPreview<'a, F> {
9234 fn new(db: &'a GraphDb<F>) -> Self {
9235 Self {
9236 db,
9237 overlay: Overlay::default(),
9238 }
9239 }
9240
9241 fn has_key(&self, key: &str) -> bool {
9242 if self.overlay.extra_keys.contains(key) {
9243 return true;
9244 }
9245 if self.overlay.deleted_keys.contains(key) {
9246 return false;
9247 }
9248 self.db.ids.get(key).is_some()
9249 }
9250
9251 fn has_prop(&self, key: &str, field: &str) -> bool {
9252 if !self.has_key(key) {
9253 return false;
9254 }
9255 let k = (key.to_string(), field.to_string());
9256 if self.overlay.removed_props.contains(&k) {
9257 return false;
9258 }
9259 if self.overlay.extra_props.contains_key(&k) {
9260 return true;
9261 }
9262 // Fresh identity (first insert in this batch, or delete+reinsert):
9263 // ignore props still sitting on the soon-to-be-tombstoned slot.
9264 if self.overlay.extra_keys.contains(key) {
9265 return false;
9266 }
9267 self.db.get_prop(key, field).is_some()
9268 }
9269
9270 fn has_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
9271 let k = (
9272 edge_type.to_string(),
9273 src_key.to_string(),
9274 dst_key.to_string(),
9275 );
9276 if self.overlay.deleted_edges.contains(&k) {
9277 return false;
9278 }
9279 if self.overlay.extra_edges.contains(&k) {
9280 return true;
9281 }
9282 // A key created in this batch (including reinsert) has no db edges.
9283 if self.overlay.extra_keys.contains(src_key) || self.overlay.extra_keys.contains(dst_key) {
9284 return false;
9285 }
9286 if self.overlay.deleted_keys.contains(src_key)
9287 || self.overlay.deleted_keys.contains(dst_key)
9288 {
9289 return false;
9290 }
9291 let Some(src) = self.db.ids.get(src_key) else {
9292 return false;
9293 };
9294 let Some(dst) = self.db.ids.get(dst_key) else {
9295 return false;
9296 };
9297 let Some(sym) = self.db.syms.get(edge_type) else {
9298 return false;
9299 };
9300 self.db
9301 .topo_view()
9302 .neighbors(sym, Direction::Out, src)
9303 .binary_search(&dst)
9304 .is_ok()
9305 }
9306
9307 fn has_rule(&self, name: &str) -> bool {
9308 if self.overlay.extra_rules.contains(name) {
9309 return true;
9310 }
9311 if self.overlay.deleted_rules.contains(name) {
9312 return false;
9313 }
9314 self.db.engine.rules().any(|r| r.name == name)
9315 }
9316
9317 fn is_rule_owned(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
9318 if self.overlay.extra_keys.contains(src_key) || self.overlay.extra_keys.contains(dst_key) {
9319 return false;
9320 }
9321 if self.overlay.deleted_keys.contains(src_key)
9322 || self.overlay.deleted_keys.contains(dst_key)
9323 {
9324 return false;
9325 }
9326 let Some(src) = self.db.ids.get(src_key) else {
9327 return false;
9328 };
9329 let Some(dst) = self.db.ids.get(dst_key) else {
9330 return false;
9331 };
9332 let Some(et) = self.db.syms.get(edge_type) else {
9333 return false;
9334 };
9335 // extra_rules is deliberately not consulted: a CreateRule earlier in
9336 // this batch has not fired, so it contributes no provenance. That is
9337 // the documented rule-window gap (see GraphDb::batch).
9338 if self.overlay.deleted_rules.is_empty() {
9339 return self.db.engine.is_owned(et, src, dst);
9340 }
9341 for (rule, triples) in self.db.engine.provenance() {
9342 if self.overlay.deleted_rules.contains(rule) {
9343 continue;
9344 }
9345 if triples.contains(&(et, src, dst)) {
9346 return true;
9347 }
9348 }
9349 false
9350 }
9351
9352 fn check_insert_node(&self, key: &str) -> Result<()> {
9353 if self.has_key(key) {
9354 Err(GraphError::DuplicateKey { key: key.into() })
9355 } else {
9356 Ok(())
9357 }
9358 }
9359
9360 fn check_live_key(&self, key: &str) -> Result<()> {
9361 if self.has_key(key) {
9362 Ok(())
9363 } else {
9364 Err(GraphError::KeyNotFound { key: key.into() })
9365 }
9366 }
9367
9368 fn prepare_insert_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
9369 for k in [src_key, dst_key] {
9370 if !self.has_key(k) {
9371 return Err(GraphError::KeyNotFound { key: k.into() });
9372 }
9373 }
9374 if self.is_rule_owned(edge_type, src_key, dst_key) {
9375 return Err(GraphError::RuleOwned {
9376 detail: format!("edge {edge_type} {src_key}→{dst_key} is rule-owned"),
9377 });
9378 }
9379 Ok(!self.has_edge(edge_type, src_key, dst_key))
9380 }
9381
9382 fn prepare_remove_prop(&self, key: &str, field: &str) -> Result<bool> {
9383 self.check_live_key(key)?;
9384 Ok(self.has_prop(key, field))
9385 }
9386
9387 fn prepare_delete_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
9388 for k in [src_key, dst_key] {
9389 if !self.has_key(k) {
9390 return Err(GraphError::KeyNotFound { key: k.into() });
9391 }
9392 }
9393 // Provenance-owned OR a live rule would derive this pair. User-first
9394 // edges that a later rule matches are not in `owned`, but deleting
9395 // them would leave a hole `rebuild_rule` immediately fills.
9396 if self.is_rule_owned(edge_type, src_key, dst_key) {
9397 return Err(GraphError::RuleOwned {
9398 detail: format!(
9399 "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
9400 delete or change the owning rule"
9401 ),
9402 });
9403 }
9404 if self.would_derive(edge_type, src_key, dst_key) {
9405 return Err(GraphError::RuleOwned {
9406 detail: format!(
9407 "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
9408 delete or change the owning rule, or a live rule would re-derive it"
9409 ),
9410 });
9411 }
9412 Ok(self.has_edge(edge_type, src_key, dst_key))
9413 }
9414
9415 /// True if any live rule (minus overlay-deleted names) would derive
9416 /// `(edge_type, src, dst)` from current overlay-visible props/labels.
9417 /// CreateRule names in `extra_rules` are ignored — same documented
9418 /// same-batch rule-window as [`Self::is_rule_owned`].
9419 fn would_derive(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
9420 if src_key == dst_key {
9421 return false;
9422 }
9423 let Some(src_label) = self.label_of(src_key) else {
9424 return false;
9425 };
9426 let Some(dst_label) = self.label_of(dst_key) else {
9427 return false;
9428 };
9429 for rule in self.db.engine.rules() {
9430 if self.overlay.deleted_rules.contains(&rule.name) {
9431 continue;
9432 }
9433 if rule.edge_type != edge_type {
9434 continue;
9435 }
9436 if rule.src_label != src_label || rule.dst_label != dst_label {
9437 continue;
9438 }
9439 let src_props = |f: &str| self.prop_value(src_key, f);
9440 let dst_props = |f: &str| self.prop_value(dst_key, f);
9441 let src_view = NodeView {
9442 key: src_key,
9443 props: &src_props,
9444 };
9445 let dst_view = NodeView {
9446 key: dst_key,
9447 props: &dst_props,
9448 };
9449 if evaluate(&rule.predicate, &src_view, &dst_view).is_some() {
9450 return true;
9451 }
9452 }
9453 false
9454 }
9455
9456 fn label_of(&self, key: &str) -> Option<String> {
9457 if self.overlay.deleted_keys.contains(key) {
9458 return None;
9459 }
9460 // Fresh identities created in this batch have no stored label in the
9461 // overlay; they cannot be provenance-owned yet either.
9462 let id = self.db.ids.get(key)?;
9463 let sym = self.db.labels.get(id as usize).copied()?;
9464 if sym == u32::MAX {
9465 return None;
9466 }
9467 self.db.syms.resolve(sym).map(str::to_string)
9468 }
9469
9470 fn prop_value(&self, key: &str, field: &str) -> Option<Value> {
9471 if !self.has_key(key) {
9472 return None;
9473 }
9474 let k = (key.to_string(), field.to_string());
9475 if self.overlay.removed_props.contains(&k) {
9476 return None;
9477 }
9478 if let Some(v) = self.overlay.extra_props.get(&k) {
9479 return Some(v.clone());
9480 }
9481 if self.overlay.extra_keys.contains(key) {
9482 return None;
9483 }
9484 self.db.get_prop(key, field)
9485 }
9486
9487 fn check_create_rule(&self, def: &RuleDef) -> Result<()> {
9488 def.validate()
9489 .map_err(|e| GraphError::RuleInvalid { detail: e })?;
9490 if self.has_rule(&def.name) {
9491 return Err(GraphError::RuleInvalid {
9492 detail: format!("rule {:?} already exists", def.name),
9493 });
9494 }
9495 Ok(())
9496 }
9497
9498 fn check_delete_rule(&self, name: &str) -> Result<()> {
9499 if self.has_rule(name) {
9500 Ok(())
9501 } else {
9502 Err(GraphError::RuleNotFound { name: name.into() })
9503 }
9504 }
9505
9506 fn note_insert_node(&mut self, key: &str, props: &[(String, Value)]) {
9507 self.overlay.deleted_keys.remove(key);
9508 self.overlay.extra_keys.insert(key.to_string());
9509 self.overlay.extra_props.retain(|(k, _), _| k != key);
9510 self.overlay.removed_props.retain(|(k, _)| k != key);
9511 for (field, value) in props {
9512 self.overlay
9513 .extra_props
9514 .insert((key.to_string(), field.clone()), value.clone());
9515 }
9516 }
9517
9518 fn note_insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) {
9519 let k = (
9520 edge_type.to_string(),
9521 src_key.to_string(),
9522 dst_key.to_string(),
9523 );
9524 self.overlay.deleted_edges.remove(&k);
9525 self.overlay.extra_edges.insert(k);
9526 }
9527
9528 fn note_set_prop(&mut self, key: &str, field: &str, value: &Value) {
9529 let k = (key.to_string(), field.to_string());
9530 self.overlay.removed_props.remove(&k);
9531 self.overlay.extra_props.insert(k, value.clone());
9532 }
9533
9534 fn note_remove_prop(&mut self, key: &str, field: &str) {
9535 let k = (key.to_string(), field.to_string());
9536 self.overlay.extra_props.remove(&k);
9537 self.overlay.removed_props.insert(k);
9538 }
9539
9540 fn note_delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) {
9541 let k = (
9542 edge_type.to_string(),
9543 src_key.to_string(),
9544 dst_key.to_string(),
9545 );
9546 self.overlay.extra_edges.remove(&k);
9547 self.overlay.deleted_edges.insert(k);
9548 }
9549
9550 fn note_delete_node(&mut self, key: &str) {
9551 self.overlay.extra_keys.remove(key);
9552 self.overlay.deleted_keys.insert(key.to_string());
9553 self.overlay.extra_props.retain(|(k, _), _| k != key);
9554 self.overlay.removed_props.retain(|(k, _)| k != key);
9555 self.overlay
9556 .extra_edges
9557 .retain(|(_, s, d)| s != key && d != key);
9558 self.overlay
9559 .deleted_edges
9560 .retain(|(_, s, d)| s != key && d != key);
9561 }
9562
9563 fn note_create_rule(&mut self, name: &str) {
9564 self.overlay.deleted_rules.remove(name);
9565 self.overlay.extra_rules.insert(name.to_string());
9566 }
9567
9568 fn check_rename_node(&self, old: &str, new: &str) -> Result<()> {
9569 if !self.has_key(old) {
9570 return Err(GraphError::KeyNotFound { key: old.into() });
9571 }
9572 if self.has_key(new) {
9573 return Err(GraphError::DuplicateKey { key: new.into() });
9574 }
9575 Ok(())
9576 }
9577
9578 fn note_rename_node(&mut self, old: &str, new: &str) {
9579 // Mark old as deleted so subsequent batch ops cannot reference it.
9580 self.overlay.extra_keys.remove(old);
9581 self.overlay.deleted_keys.insert(old.to_string());
9582 // Mark new as extra so subsequent batch ops can reference it.
9583 self.overlay.deleted_keys.remove(new);
9584 self.overlay.extra_keys.insert(new.to_string());
9585 // Migrate any overlay props from old key to new key.
9586 let new_str = new.to_string();
9587 let transferred: Vec<((String, String), Value)> = self
9588 .overlay
9589 .extra_props
9590 .iter()
9591 .filter(|((k, _), _)| k.as_str() == old)
9592 .map(|((_, f), v)| ((new_str.clone(), f.clone()), v.clone()))
9593 .collect();
9594 self.overlay
9595 .extra_props
9596 .retain(|(k, _), _| k.as_str() != old);
9597 for (k, v) in transferred {
9598 self.overlay.extra_props.insert(k, v);
9599 }
9600 // Migrate removed_props.
9601 let transferred_removed: Vec<(String, String)> = self
9602 .overlay
9603 .removed_props
9604 .iter()
9605 .filter(|(k, _)| k.as_str() == old)
9606 .map(|(_, f)| (new_str.clone(), f.clone()))
9607 .collect();
9608 self.overlay
9609 .removed_props
9610 .retain(|(k, _)| k.as_str() != old);
9611 for k in transferred_removed {
9612 self.overlay.removed_props.insert(k);
9613 }
9614 }
9615
9616 fn note_delete_rule(&mut self, name: &str) {
9617 self.overlay.extra_rules.remove(name);
9618 self.overlay.deleted_rules.insert(name.to_string());
9619 // Treat the deleted rule's current provenance as gone so a later
9620 // delete_edge of those triples is a no-op (matches sequential).
9621 if let Some(triples) = self.db.engine.provenance().get(name) {
9622 for &(et, s, d) in triples {
9623 let Some(etype) = self.db.syms.resolve(et) else {
9624 continue;
9625 };
9626 let Some(src) = self.db.ids.key_of(s) else {
9627 continue;
9628 };
9629 let Some(dst) = self.db.ids.key_of(d) else {
9630 continue;
9631 };
9632 let k = (etype.to_string(), src.to_string(), dst.to_string());
9633 self.overlay.extra_edges.remove(&k);
9634 self.overlay.deleted_edges.insert(k);
9635 }
9636 }
9637 }
9638}
9639
9640/// Collects mutations and commits them as one WAL `Batch` frame.
9641///
9642/// Holds `&mut GraphDb` for its lifetime. Queue with the same method names
9643/// as [`GraphDb`]; call [`commit`](Self::commit) to validate, log, and apply.
9644/// See [`GraphDb::batch`] for validation and atomicity rules.
9645pub struct BatchBuilder<'a, F: Fs> {
9646 db: &'a mut GraphDb<F>,
9647 ops: Vec<BatchOp>,
9648}
9649
9650impl<'a, F: Fs> BatchBuilder<'a, F> {
9651 pub fn insert_node(
9652 &mut self,
9653 label: &str,
9654 key: &str,
9655 props: Vec<(String, Value)>,
9656 ) -> &mut Self {
9657 self.ops.push(BatchOp::InsertNode {
9658 label: label.into(),
9659 key: key.into(),
9660 props,
9661 });
9662 self
9663 }
9664
9665 pub fn insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> &mut Self {
9666 self.ops.push(BatchOp::InsertEdge {
9667 edge_type: edge_type.into(),
9668 src_key: src_key.into(),
9669 dst_key: dst_key.into(),
9670 });
9671 self
9672 }
9673
9674 pub fn set_prop(&mut self, key: &str, field: &str, value: Value) -> &mut Self {
9675 self.ops.push(BatchOp::SetProp {
9676 key: key.into(),
9677 field: field.into(),
9678 value,
9679 });
9680 self
9681 }
9682
9683 pub fn remove_prop(&mut self, key: &str, field: &str) -> &mut Self {
9684 self.ops.push(BatchOp::RemoveProp {
9685 key: key.into(),
9686 field: field.into(),
9687 });
9688 self
9689 }
9690
9691 pub fn delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> &mut Self {
9692 self.ops.push(BatchOp::DeleteEdge {
9693 edge_type: edge_type.into(),
9694 src_key: src_key.into(),
9695 dst_key: dst_key.into(),
9696 });
9697 self
9698 }
9699
9700 pub fn delete_node(&mut self, key: &str) -> &mut Self {
9701 self.ops.push(BatchOp::DeleteNode { key: key.into() });
9702 self
9703 }
9704
9705 pub fn create_rule(&mut self, def: RuleDef) -> &mut Self {
9706 self.ops.push(BatchOp::CreateRule(def));
9707 self
9708 }
9709
9710 pub fn delete_rule(&mut self, name: &str) -> &mut Self {
9711 self.ops.push(BatchOp::DeleteRule { name: name.into() });
9712 self
9713 }
9714
9715 /// Queue a node-rename in this batch.
9716 ///
9717 /// Validation (old exists, new not taken) runs at commit time.
9718 pub fn rename_node(&mut self, old_key: &str, new_key: &str) -> &mut Self {
9719 self.ops.push(BatchOp::RenameNode {
9720 old_key: old_key.into(),
9721 new_key: new_key.into(),
9722 });
9723 self
9724 }
9725
9726 /// Queue an edge insert with endpoint auto-creation.
9727 ///
9728 /// Any missing endpoint is created as a plain node `{key, label:
9729 /// placeholder_label, no props}` inside this batch frame. Rules fire and
9730 /// last-change is updated for each auto-created node.
9731 pub fn insert_edge_upsert(
9732 &mut self,
9733 edge_type: &str,
9734 src_key: &str,
9735 dst_key: &str,
9736 placeholder_label: &str,
9737 ) -> &mut Self {
9738 self.ops.push(BatchOp::InsertEdgeUpsert {
9739 edge_type: edge_type.into(),
9740 src_key: src_key.into(),
9741 dst_key: dst_key.into(),
9742 placeholder_label: placeholder_label.into(),
9743 });
9744 self
9745 }
9746
9747 /// Validate every queued op, then log one `Batch` frame and apply.
9748 /// Empty / all-noop batches return `Ok(())` without writing the WAL.
9749 /// A second `commit()` after a successful one is an empty-batch no-op
9750 /// (queued ops were taken).
9751 /// Takes `&mut self` so it chains after the queue methods (`b.insert_node(..).commit()`)
9752 /// and also works as `let mut b = db.batch(); b.insert_node(..); b.commit()`.
9753 ///
9754 /// **Rule-window limitation:** batch validation cannot see edges that a
9755 /// rule created earlier in the *same* batch will derive at apply time, so
9756 /// a `delete_edge` / `insert_edge` in that window is silently no-oped
9757 /// where sequential calls would return `Err(RuleOwned)`. State integrity
9758 /// is unaffected (idempotent apply, provenance intact). Create rules in
9759 /// their own batch, or sequentially, when later ops may touch derived
9760 /// edges.
9761 /// Validate every queued op and commit atomically.
9762 ///
9763 /// Returns `(nodes_inserted, edges_inserted)` — the counts of node and edge
9764 /// WAL records actually written (duplicate edges are silent no-ops and are
9765 /// NOT counted). Both are 0 when the batch is empty or all-noop.
9766 pub fn commit(&mut self) -> Result<(usize, usize)> {
9767 let ops = std::mem::take(&mut self.ops);
9768 self.db.commit_batch(ops)
9769 }
9770
9771 /// Same as [`commit`](Self::commit) but tail the inner events with
9772 /// [`MutationEvent::Ingested`] instead of [`MutationEvent::BatchApplied`].
9773 pub(crate) fn commit_ingest(&mut self, label: &str, inserted: usize) -> Result<(usize, usize)> {
9774 let ops = std::mem::take(&mut self.ops);
9775 self.db
9776 .commit_logged_batch(ops, Some((label.to_string(), inserted)), None)
9777 }
9778}
9779
9780pub struct NodeRef<'a, F: Fs> {
9781 db: &'a GraphDb<F>,
9782 id: u32,
9783}
9784
9785impl<'a, F: Fs> NodeRef<'a, F> {
9786 pub fn key(&self) -> &str {
9787 self.db.ids.key_of(self.id).expect("dense ids")
9788 }
9789
9790 pub fn label(&self) -> &str {
9791 let sym = self
9792 .db
9793 .labels
9794 .get(self.id as usize)
9795 .copied()
9796 .filter(|&s| s != u32::MAX)
9797 .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
9798 self.db.syms.resolve(sym).expect("interned label symbol")
9799 }
9800
9801 pub fn prop(&self, field: &str) -> Option<Value> {
9802 self.db
9803 .props_view()
9804 .get(self.id, field)
9805 .map(|vr| vr.into_value())
9806 }
9807
9808 /// All stored fields for this node, sorted by field name.
9809 ///
9810 /// Reads from the full base+overlay view so that props stored only in the
9811 /// V8 snapshot base (i.e. before any post-snapshot WAL writes) are visible.
9812 pub fn props(&self) -> BTreeMap<String, Value> {
9813 let mut out = BTreeMap::new();
9814 let pv = self.db.props_view();
9815 for field in pv.field_names() {
9816 if let Some(vr) = pv.get(self.id, &field) {
9817 out.insert(field, vr.into_value());
9818 }
9819 }
9820 out
9821 }
9822
9823 /// depth-N BFS as a ResultSet: columns ["key","label","depth"], BFS order.
9824 pub fn neighborhood(&self, depth: u32, edge_types: Option<&[&str]>, dir: Dir) -> ResultSet {
9825 let view = self.db.view();
9826 let resolved: Option<Vec<u32>> = edge_types.map(|names| {
9827 names
9828 .iter()
9829 .filter_map(|name| view.syms.get(name))
9830 .collect()
9831 });
9832 let nb = neighborhood(&view, self.id, depth, resolved.as_deref(), dir);
9833 let mut rs = ResultSet::new(vec!["key".into(), "label".into(), "depth".into()]);
9834 for (nid, d) in nb.nodes {
9835 let key = view.key_of(nid);
9836 let label = view
9837 .label_of(nid)
9838 .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
9839 rs.push_row(vec![
9840 Some(Value::Str(key.to_string())),
9841 Some(Value::Str(label.to_string())),
9842 Some(Value::Int(d as i64)),
9843 ]);
9844 }
9845 rs
9846 }
9847
9848 /// 1-hop, Both directions: edge-type name → sorted unique neighbor keys.
9849 pub fn grouped_by_edge_type(&self) -> BTreeMap<String, Vec<String>> {
9850 let view = self.db.view();
9851 let mut groups: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
9852 for e in expand(&view, self.id, None, Dir::Both) {
9853 // Skip edges with unknown etypes (only possible from corrupt large
9854 // TOPOLOGY section; function returns BTreeMap not Result).
9855 let Some(etype) = view.syms.resolve(e.etype) else {
9856 continue;
9857 };
9858 let etype = etype.to_string();
9859 let nbr = if e.src == self.id { e.dst } else { e.src };
9860 groups
9861 .entry(etype)
9862 .or_default()
9863 .insert(view.key_of(nbr).to_string());
9864 }
9865 groups
9866 .into_iter()
9867 .map(|(k, v)| (k, v.into_iter().collect()))
9868 .collect()
9869 }
9870}
9871
9872#[cfg(test)]
9873mod tests {
9874 use super::*;
9875 use core_rules::Predicate;
9876
9877 fn tmp_dir(name: &str) -> std::path::PathBuf {
9878 let d =
9879 std::env::temp_dir().join(format!("graphdb-db-unit-{}-{}", name, std::process::id()));
9880 let _ = std::fs::remove_dir_all(&d);
9881 d
9882 }
9883
9884 fn fk_rule() -> RuleDef {
9885 RuleDef {
9886 name: "works_at".into(),
9887 src_label: "Person".into(),
9888 dst_label: "Org".into(),
9889 predicate: Predicate::KeyMatch {
9890 field: "org_id".into(),
9891 },
9892 edge_type: "WORKS_AT".into(),
9893 weight_prop: None,
9894 max_edges: None,
9895 approximate: false,
9896 via_label: None,
9897 via_edge: None,
9898 via_dir: None,
9899 }
9900 }
9901
9902 /// Regression guard for the no-views delta-copy fast path.
9903 ///
9904 /// When no views are defined, `pending_deltas_since().to_vec()` must never
9905 /// be called — even during a large CreateRule backfill. The DELTA_COPY_COUNT
9906 /// thread-local is incremented inside every `if !view_store.is_empty()` block;
9907 /// a count of 0 after the entire sequence proves the guard fires correctly.
9908 #[test]
9909 fn no_delta_copy_when_no_views() {
9910 DELTA_COPY_COUNT.with(|c| c.set(0));
9911 let dir = tmp_dir("no-delta-copy");
9912 {
9913 let mut db = GraphDb::open(&dir).unwrap();
9914 // Insert 50 Org + 50 Person nodes with FK links.
9915 for i in 0..50u32 {
9916 db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
9917 }
9918 for i in 0..50u32 {
9919 db.insert_node(
9920 "Person",
9921 &format!("p{i}"),
9922 vec![("org_id".into(), Value::Str(format!("o{i}")))],
9923 )
9924 .unwrap();
9925 }
9926 // CreateRule backfill should NOT invoke to_vec() when no views are defined.
9927 db.create_rule(fk_rule()).unwrap();
9928
9929 // Counter must stay 0 — no views, no copies.
9930 let copies = DELTA_COPY_COUNT.with(|c| c.get());
9931 assert_eq!(
9932 copies, 0,
9933 "pending_deltas_since().to_vec() called despite no views"
9934 );
9935
9936 // Derived edges must still be correct (the guard skips only the
9937 // empty delta propagation loop, not the rule application itself).
9938 let nbrs = db.neighbors("p0", "WORKS_AT", Direction::Out).unwrap();
9939 assert_eq!(
9940 nbrs,
9941 vec!["o0"],
9942 "rule must derive edges even with no views"
9943 );
9944 }
9945 let _ = std::fs::remove_dir_all(&dir);
9946 }
9947
9948 /// Gating regression: subscribe AFTER a backfill must see no stale events.
9949 /// subscribe BEFORE a backfill must see every edge-fire event.
9950 #[test]
9951 fn subscribe_after_backfill_no_stale_events() {
9952 let dir = tmp_dir("sub-after-backfill");
9953 {
9954 let mut db = GraphDb::open(&dir).unwrap();
9955 for i in 0..10u32 {
9956 db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
9957 db.insert_node(
9958 "Person",
9959 &format!("p{i}"),
9960 vec![("org_id".into(), Value::Str(format!("o{i}")))],
9961 )
9962 .unwrap();
9963 }
9964 // Create rule BEFORE subscribing — emit_deltas is false during backfill.
9965 db.create_rule(fk_rule()).unwrap();
9966
9967 // Subscribe AFTER the backfill — queue must be empty (no stale events).
9968 let sub = db.subscribe_all_rules().unwrap();
9969 // No events should have queued for the prior backfill.
9970 assert!(
9971 sub.try_recv().is_none(),
9972 "subscribe after backfill must see no stale events"
9973 );
9974
9975 // Inserting a new node now should fire an event (emit_deltas is now true).
9976 db.insert_node("Org", "o_new", vec![]).unwrap();
9977 db.insert_node(
9978 "Person",
9979 "p_new",
9980 vec![("org_id".into(), Value::Str("o_new".into()))],
9981 )
9982 .unwrap();
9983 let ev = sub.recv_timeout(std::time::Duration::from_millis(200));
9984 assert!(
9985 ev.is_some(),
9986 "edge-fire event must arrive after subscribe (emit_deltas=true)"
9987 );
9988 }
9989 let _ = std::fs::remove_dir_all(&dir);
9990 }
9991
9992 /// Gating regression: subscribe BEFORE a backfill → events flow.
9993 #[test]
9994 fn subscribe_before_backfill_events_flow() {
9995 let dir = tmp_dir("sub-before-backfill");
9996 {
9997 let mut db = GraphDb::open(&dir).unwrap();
9998 // Subscribe FIRST — emit_deltas becomes true.
9999 let sub = db.subscribe_all_rules().unwrap();
10000
10001 for i in 0..5u32 {
10002 db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
10003 db.insert_node(
10004 "Person",
10005 &format!("p{i}"),
10006 vec![("org_id".into(), Value::Str(format!("o{i}")))],
10007 )
10008 .unwrap();
10009 }
10010 // Backfill fires with emit_deltas=true → events queued.
10011 db.create_rule(fk_rule()).unwrap();
10012
10013 // Should receive at least one edge-fired event from the backfill.
10014 let mut received = 0usize;
10015 while sub.try_recv().is_some() {
10016 received += 1;
10017 }
10018 assert!(
10019 received > 0,
10020 "subscribe before backfill must receive edge-fire events (got 0)"
10021 );
10022 }
10023 let _ = std::fs::remove_dir_all(&dir);
10024 }
10025
10026 /// Companion: when a view IS defined, the delta path fires and view values update.
10027 #[test]
10028 fn delta_copy_fires_when_view_exists() {
10029 use core_rules::ViewSource;
10030 DELTA_COPY_COUNT.with(|c| c.set(0));
10031 let dir = tmp_dir("delta-copy-with-view");
10032 {
10033 let mut db = GraphDb::open(&dir).unwrap();
10034 db.insert_node("Org", "o1", vec![]).unwrap();
10035 db.insert_node(
10036 "Person",
10037 "p1",
10038 vec![("org_id".into(), Value::Str("o1".into()))],
10039 )
10040 .unwrap();
10041 // Declare a Degree view so is_empty() returns false.
10042 db.create_view(ViewDef {
10043 name: "degree_out".into(),
10044 label: "Person".into(),
10045 view_prop: "degree_out".into(),
10046 source: ViewSource::Degree {
10047 edge_type: "WORKS_AT".into(),
10048 direction: Direction::Out,
10049 },
10050 })
10051 .unwrap();
10052 db.create_rule(fk_rule()).unwrap();
10053
10054 // At least one delta copy should have happened (CreateRule backfill).
10055 let copies = DELTA_COPY_COUNT.with(|c| c.get());
10056 assert!(
10057 copies > 0,
10058 "expected delta copy to fire when a view is defined"
10059 );
10060
10061 // View value should be computed: p1 has one WORKS_AT out-edge.
10062 let info = db.node_info("p1").unwrap();
10063 let degree = info.props.get("degree_out");
10064 assert!(
10065 degree.is_some(),
10066 "view prop should be written to node props"
10067 );
10068 }
10069 let _ = std::fs::remove_dir_all(&dir);
10070 }
10071
10072 /// Regression: `open_at_with` must call `rebuild_all` after WAL replay so
10073 /// derived-edge-driven view values reflect the as-of state rather than just
10074 /// the initial backfill written at `CreateView` time.
10075 ///
10076 /// Base WAL frames (indices 0..=5 before history markers):
10077 /// 0: insert Org "o1"
10078 /// 1: create_view "employee_count" (Degree / WORKS_AT / In) on Org
10079 /// 2: create_rule fk_rule (WORKS_AT, Person→Org via org_id)
10080 /// 3: insert Person "p1" → rule fires WORKS_AT p1→o1 (degree = 1) ← mid
10081 /// 4: insert Person "p2" → rule fires WORKS_AT p2→o1 (degree = 2)
10082 /// 5: insert Person "p3" → rule fires WORKS_AT p3→o1 (degree = 3) ← latest
10083 ///
10084 /// Each rule-fire also appends a DerivedEdgeAdded history-marker frame (state
10085 /// no-op), so the total commit count is higher than the base frame count.
10086 /// The "latest" open_at commit is computed dynamically via `wal_commit_count_at`.
10087 ///
10088 /// Without `rebuild_all`, the as-of instance's "emp" view stays at the
10089 /// initial backfill value (0) instead of reflecting the replayed derived edges.
10090 #[test]
10091 fn open_at_derived_edge_view_values_correct() {
10092 use core_rules::ViewSource;
10093 let dir = tmp_dir("open-at-view-rebuild");
10094 {
10095 let mut db = GraphDb::open(&dir).unwrap();
10096 // frame 0
10097 db.insert_node("Org", "o1", vec![]).unwrap();
10098 // frame 1: create view — initial backfill sees 0 derived edges (none fired yet)
10099 db.create_view(ViewDef {
10100 name: "employee_count".into(),
10101 label: "Org".into(),
10102 view_prop: "emp".into(),
10103 source: ViewSource::Degree {
10104 edge_type: "WORKS_AT".into(),
10105 direction: Direction::In,
10106 },
10107 })
10108 .unwrap();
10109 // frame 2: create rule — no Persons yet; backfill is a no-op
10110 db.create_rule(fk_rule()).unwrap();
10111 // frame 3: p1 — rule fires WORKS_AT p1→o1; degree = 1
10112 db.insert_node(
10113 "Person",
10114 "p1",
10115 vec![("org_id".into(), Value::Str("o1".into()))],
10116 )
10117 .unwrap();
10118 // frame 4: p2 — degree = 2
10119 db.insert_node(
10120 "Person",
10121 "p2",
10122 vec![("org_id".into(), Value::Str("o1".into()))],
10123 )
10124 .unwrap();
10125 // frame 5: p3 — degree = 3
10126 db.insert_node(
10127 "Person",
10128 "p3",
10129 vec![("org_id".into(), Value::Str("o1".into()))],
10130 )
10131 .unwrap();
10132 // Sanity: normal open sees degree = 3.
10133 assert_eq!(
10134 db.get_view_prop("o1", "emp"),
10135 Some(Value::Int(3)),
10136 "normal db must show degree 3 after 3 derived edges"
10137 );
10138 } // WAL flushed
10139
10140 // Re-open normally to get the authoritative reference value.
10141 let normal_db = GraphDb::open(&dir).unwrap();
10142 let normal_emp = normal_db.get_view_prop("o1", "emp");
10143 assert_eq!(
10144 normal_emp,
10145 Some(Value::Int(3)),
10146 "re-opened normal db must show degree 3"
10147 );
10148
10149 // Latest as-of (last WAL commit): must match the normal open.
10150 // History-marker frames are appended after each rule-fire, so the total
10151 // commit count is computed dynamically rather than hardcoded.
10152 let total = crate::wal_commit_count_at(&dir).unwrap();
10153 let aof_latest = GraphDb::open_at(&dir, total - 1).unwrap();
10154 assert_eq!(
10155 aof_latest.get_view_prop("o1", "emp"),
10156 normal_emp,
10157 "open_at latest: derived-edge view must equal normal open (rebuild_all required)"
10158 );
10159
10160 // Mid-history as-of (commit 3 = p1 insert Batch frame): only p1; degree = 1.
10161 // The DerivedEdgeAdded marker for p1 is at frame 4 (state no-op on replay),
10162 // so replaying 0..=3 correctly re-derives only the p1→o1 edge.
10163 let aof_mid = GraphDb::open_at(&dir, 3).unwrap();
10164 assert_eq!(
10165 aof_mid.get_view_prop("o1", "emp"),
10166 Some(Value::Int(1)),
10167 "open_at mid-history: only p1 exists at frame 3, degree must be 1"
10168 );
10169
10170 let _ = std::fs::remove_dir_all(&dir);
10171 }
10172
10173 /// Pin: subscribe_* on an as-of instance must return Err(ReadOnly) —
10174 /// as-of instances never commit, so distribute_events never runs and any
10175 /// subscription would wait forever.
10176 #[test]
10177 fn subscribe_on_as_of_returns_read_only_error() {
10178 let dir = tmp_dir("sub-as-of-read-only");
10179 {
10180 let mut db = GraphDb::open(&dir).unwrap();
10181 db.insert_node("Org", "o1", vec![]).unwrap();
10182 db.create_rule(fk_rule()).unwrap();
10183 }
10184 let mut aof = GraphDb::open_at(&dir, 0).unwrap();
10185
10186 assert!(
10187 matches!(
10188 aof.subscribe_all_rules(),
10189 Err(core_storage::GraphError::ReadOnly)
10190 ),
10191 "subscribe_all_rules on as-of must return ReadOnly"
10192 );
10193 assert!(
10194 matches!(
10195 aof.subscribe_writes(),
10196 Err(core_storage::GraphError::ReadOnly)
10197 ),
10198 "subscribe_writes on as-of must return ReadOnly"
10199 );
10200 assert!(
10201 matches!(
10202 aof.subscribe_rule("works_at"),
10203 Err(core_storage::GraphError::ReadOnly)
10204 ),
10205 "subscribe_rule on as-of must return ReadOnly"
10206 );
10207 let _ = std::fs::remove_dir_all(&dir);
10208 }
10209
10210 /// Regression: a failed dense WAL rewrite must not leave speculative
10211 /// interns in `syms`. If it does, the next successful mutation logs an
10212 /// `Intern` record with an inflated id; replay (which never saw the
10213 /// orphans) assigns a smaller id and the WAL becomes unreplayable.
10214 #[test]
10215 fn dense_rewrite_error_rolls_back_speculative_interns() {
10216 let dir = tmp_dir("dense-rewrite-rollback");
10217 {
10218 let mut db = GraphDb::open(&dir).unwrap();
10219 db.insert_node("Person", "a", vec![]).unwrap();
10220
10221 // Bypass MutPreview validation to hit the rewrite's own error path
10222 // (same shape as an id-exhaustion failure mid-rewrite). The
10223 // InsertEdge arm interns the edge type before it resolves keys.
10224 let err = db.rewrite_wal_dense(vec![WalRecord::InsertEdge {
10225 edge_type: "ORPHAN_TYPE".into(),
10226 src_key: "missing".into(),
10227 dst_key: "a".into(),
10228 }]);
10229 assert!(err.is_err(), "rewrite of a missing src key must fail");
10230 assert_eq!(
10231 db.syms.get("ORPHAN_TYPE"),
10232 None,
10233 "failed rewrite must roll back speculative interns"
10234 );
10235
10236 // A later successful mutation must produce a replayable WAL.
10237 db.set_prop("a", "later_field", Value::Int(2)).unwrap();
10238 }
10239 let db = GraphDb::open(&dir).expect("WAL must replay after failed rewrite");
10240 assert_eq!(db.get_prop("a", "later_field"), Some(Value::Int(2)));
10241 let _ = std::fs::remove_dir_all(&dir);
10242 }
10243}