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 /// How many writes hit the rule-chaining depth cap with work still pending,
273 /// since this handle was opened. Non-zero means some derived edges beyond
274 /// the cap are stale and no single later write will repair them: split the
275 /// rule chain or shorten it. Never persisted, so it resets on reopen.
276 #[serde(default)]
277 pub chain_truncations: u64,
278}
279
280/// One rule's provenance size, trip latch, and fire counter.
281///
282/// `tripped` is a one-way latch: once set, the engine adds no new edges for
283/// that rule until [`GraphDb::rebuild_rule`] (and only if the full desired
284/// set then fits). `fires` counts `on_node_changed` evaluations plus
285/// backfill/rebuild participant ticks (rebuild counts even when it is a
286/// provenance no-op).
287#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
288pub struct RuleStats {
289 pub name: String,
290 pub edges: u64,
291 pub tripped: bool,
292 pub fires: u64,
293 /// Whether this rule uses the approximate IVF-Flat candidate path.
294 pub approximate: bool,
295}
296
297/// One entry in the slow-query ring buffer.
298#[derive(Debug, Clone, Serialize)]
299pub struct SlowQueryEntry {
300 /// Execution time in whole milliseconds.
301 pub ms: u64,
302 /// The Cypher query string that was slow.
303 pub query: String,
304 /// The commit sequence number at the time the query ran.
305 pub at_commit: u64,
306}
307
308/// Snapshot of the slow-query log returned by [`GraphDb::slow_query_snapshot`].
309#[derive(Debug, Clone, Serialize)]
310pub struct SlowQuerySnapshot {
311 /// Current threshold in milliseconds (0 = disabled).
312 pub threshold_ms: u64,
313 /// Total number of slow queries ever recorded (not capped by ring size).
314 pub count: u64,
315 /// Most-recent slow queries (up to 16), oldest first.
316 pub last: Vec<SlowQueryEntry>,
317}
318
319/// Internal ring-buffer state protected by a `Mutex` so `query(&self)` can
320/// write to it without a mutable borrow.
321struct SlowQueryLog {
322 entries: std::collections::VecDeque<SlowQueryEntry>,
323 total: u64,
324}
325
326/// Maximum number of entries kept in the slow-query ring buffer.
327const SLOW_QUERY_RING_CAP: usize = 16;
328
329/// Wire summary of a [`Predicate`]. JSON only — `Explanation` is never
330/// bincode-persisted (WAL/snapshots store `RuleDef` bytes, not this type).
331#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
332pub struct PredicateSummary {
333 pub kind: String,
334 pub fields: Vec<String>,
335 pub min: Option<f64>,
336 pub tolerance: Option<f64>,
337 pub km: Option<f64>,
338 pub parts: Option<Vec<PredicateSummary>>,
339 /// True when the owning rule has `approximate=true` (IVF-Flat candidate path).
340 /// Always false for predicates reported without rule context (sub-predicates in `parts`).
341 #[serde(default)]
342 pub approximate: bool,
343}
344
345impl From<&Predicate> for PredicateSummary {
346 fn from(p: &Predicate) -> Self {
347 match p {
348 Predicate::KeyMatch { field } => PredicateSummary {
349 kind: "key_match".into(),
350 fields: vec![field.clone()],
351 min: None,
352 tolerance: None,
353 km: None,
354 parts: None,
355 approximate: false,
356 },
357 Predicate::FieldEqual { field } => PredicateSummary {
358 kind: "field_equal".into(),
359 fields: vec![field.clone()],
360 min: None,
361 tolerance: None,
362 km: None,
363 parts: None,
364 approximate: false,
365 },
366 Predicate::Overlap { field, min } => PredicateSummary {
367 kind: "overlap".into(),
368 fields: vec![field.clone()],
369 min: Some(*min),
370 tolerance: None,
371 km: None,
372 parts: None,
373 approximate: false,
374 },
375 Predicate::NumericWithin { field, tolerance } => PredicateSummary {
376 kind: "numeric_within".into(),
377 fields: vec![field.clone()],
378 min: None,
379 tolerance: Some(*tolerance),
380 km: None,
381 parts: None,
382 approximate: false,
383 },
384 Predicate::GeoRadius { field, km } => PredicateSummary {
385 kind: "geo_radius".into(),
386 fields: vec![field.clone()],
387 min: None,
388 tolerance: None,
389 km: Some(*km),
390 parts: None,
391 approximate: false,
392 },
393 Predicate::VectorSimilar { field, min } => PredicateSummary {
394 kind: "vector_similar".into(),
395 fields: vec![field.clone()],
396 min: Some(*min),
397 tolerance: None,
398 km: None,
399 parts: None,
400 approximate: false,
401 },
402 Predicate::All(inner) => {
403 let parts: Vec<PredicateSummary> = inner.iter().map(Self::from).collect();
404 let mut fields = Vec::new();
405 for part in &parts {
406 for f in &part.fields {
407 if !fields.contains(f) {
408 fields.push(f.clone());
409 }
410 }
411 }
412 PredicateSummary {
413 kind: "all".into(),
414 fields,
415 min: None,
416 tolerance: None,
417 km: None,
418 parts: Some(parts),
419 approximate: false,
420 }
421 }
422 Predicate::Any(inner) => {
423 let parts: Vec<PredicateSummary> = inner.iter().map(Self::from).collect();
424 let mut fields = Vec::new();
425 for part in &parts {
426 for f in &part.fields {
427 if !fields.contains(f) {
428 fields.push(f.clone());
429 }
430 }
431 }
432 PredicateSummary {
433 kind: "any".into(),
434 fields,
435 min: None,
436 tolerance: None,
437 km: None,
438 parts: Some(parts),
439 approximate: false,
440 }
441 }
442 }
443 }
444}
445
446/// Snapshot of a live node's key, label, and columnar properties.
447///
448/// `props` is a [`BTreeMap`] so field order is deterministic (sorted by name)
449/// regardless of insert order or the columnar store's `HashMap` iteration.
450///
451/// Deliberately does not derive `Serialize`: `Value`'s serde form is
452/// internally tagged. Wire JSON is built by `value_to_json` in the server.
453#[derive(Debug, Clone, PartialEq)]
454pub struct NodeInfo {
455 pub key: String,
456 pub label: String,
457 pub props: BTreeMap<String, Value>,
458}
459
460/// Counts returned by [`GraphDb::delete_node`].
461#[derive(Debug, Clone, PartialEq, Eq, Default)]
462pub struct DeleteReport {
463 /// Number of manual (user-inserted) edges removed.
464 pub manual_edges: u64,
465 /// Number of derived (rule-owned) edges retracted.
466 pub derived_edges: u64,
467}
468
469/// One directed edge incident on a node, with provenance membership.
470///
471/// `derived` is true iff `(edge_type, src, dst)` is in the rule engine's
472/// Plan-8 `by_node` provenance index.
473#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
474pub struct EdgeInfo {
475 pub edge_type: String,
476 pub src_key: String,
477 pub dst_key: String,
478 pub derived: bool,
479}
480
481/// An edge with mask-aware endpoint visibility.
482///
483/// Returned by [`GraphDb::node_edges_masked`] in [`crate::mask::MaskMode::Stub`]
484/// mode — hidden endpoints carry `*_restricted: true`.
485#[derive(Debug, Clone, PartialEq, Eq)]
486pub struct MaskedEdge {
487 pub edge_type: String,
488 pub src_key: String,
489 /// `true` when `src_key` is in the DB but hidden from the mask.
490 pub src_restricted: bool,
491 pub dst_key: String,
492 /// `true` when `dst_key` is in the DB but hidden from the mask.
493 pub dst_restricted: bool,
494 pub derived: bool,
495}
496
497/// Result of a mask-aware node lookup via [`GraphDb::node_info_masked`].
498///
499/// `None` from that method means the key does not exist (→ 404).
500/// `Some(Restricted)` is only produced when `mask.mode() == MaskMode::Stub`.
501#[derive(Debug, PartialEq)]
502pub enum MaskedNodeResult {
503 Visible(NodeInfo),
504 /// Node exists in the DB but is hidden from this mask.
505 Restricted,
506}
507
508/// One rule-owned edge between two nodes, with the rule name, edge type,
509/// direction (src_key → dst_key), and weight if the rule stores one.
510#[derive(Debug, Clone, PartialEq, Serialize)]
511pub struct Explanation {
512 pub rule: String,
513 pub edge_type: String,
514 pub src_key: String,
515 pub dst_key: String,
516 pub weight: Option<f64>,
517 pub predicate: PredicateSummary,
518 /// For a via-hop rule, the edge type the rule hops over to reach its
519 /// candidates. `None` for a plain two-node rule. A via-hop rule whose
520 /// `via_edge` is itself rule-derived is the chaining case: the hop edge
521 /// was written by another rule in the same commit.
522 #[serde(default)]
523 pub via_edge: Option<String>,
524}
525
526/// Report returned by [`GraphDb::backup_to`].
527#[derive(Debug, Clone)]
528pub struct BackupReport {
529 /// Filenames copied into the destination directory (sorted ascending).
530 pub files: Vec<String>,
531 /// Total bytes written across all copied files.
532 pub bytes: u64,
533 /// `true` when the destination opened cleanly and passed post-copy checks.
534 ///
535 /// For stores that have a `snapshot.bin` this means: all V8 section CRCs
536 /// matched **and** the destination opened without error.
537 ///
538 /// For WAL-only stores (no `snapshot.bin`) there is no snapshot to
539 /// CRC-check; `verified` is `true` when the destination opened and
540 /// replayed the WAL without error (record-level checksums in the WAL
541 /// provide the integrity signal, not section CRCs).
542 pub verified: bool,
543}
544
545/// One directed edge in export form, with optional rule attribution for derived edges.
546///
547/// Returned by [`GraphDb::all_edges_for_export`].
548///
549/// Does not derive `Eq`/`Ord`: `weight` is an `f64` and NaN breaks a total
550/// order. Callers that need a stable edge ordering already sort by
551/// `(edge_type, src, dst)` explicitly (see `all_edges_for_export`).
552#[derive(Debug, Clone, PartialEq, PartialOrd)]
553pub struct ExportEdge {
554 pub edge_type: String,
555 pub src: String,
556 pub dst: String,
557 pub derived: bool,
558 /// Rule name that created this edge, if derived. `None` for manual edges.
559 pub rule: Option<String>,
560 /// The creating rule's declared `weight_prop`, read off this edge, when
561 /// derived and numeric (`Int`/`Float`). `None` for manual edges, derived
562 /// edges whose rule declares no `weight_prop`, or a non-numeric value.
563 pub weight: Option<f64>,
564}
565
566/// Construct the standard write-query result set (columns: created, properties_set, deleted).
567fn write_result_set() -> ResultSet {
568 ResultSet::new(vec![
569 "created".into(),
570 "properties_set".into(),
571 "deleted".into(),
572 ])
573}
574
575fn resolve_merge_set_value(op: &Operand, params: &BTreeMap<String, Value>) -> Result<Value> {
576 match op {
577 Operand::Lit(v) => Ok(v.clone()),
578 Operand::Param(name) => params
579 .get(name)
580 .cloned()
581 .ok_or_else(|| GraphError::QueryError {
582 detail: format!("missing parameter `{name}`"),
583 }),
584 _ => Err(GraphError::QueryError {
585 detail: "ON CREATE/ON MATCH SET value must be a literal or $parameter".into(),
586 }),
587 }
588}
589
590fn operand_node_vars(op: &Operand, out: &mut Vec<String>) {
591 match op {
592 Operand::Prop { var, .. } | Operand::Var(var) => {
593 if !out.contains(var) {
594 out.push(var.clone());
595 }
596 }
597 Operand::FuncCall { args, .. } => {
598 for arg in args {
599 operand_node_vars(arg, out);
600 }
601 }
602 Operand::BinArith { left, right, .. } => {
603 operand_node_vars(left, out);
604 operand_node_vars(right, out);
605 }
606 Operand::Case { branches, default } => {
607 // Branch conditions reference vars already bound (and mask-filtered)
608 // by the MATCH phase, so collecting from the value operands + ELSE
609 // is sufficient for RETURN-projection var discovery.
610 for (_, value) in branches {
611 operand_node_vars(value, out);
612 }
613 if let Some(d) = default {
614 operand_node_vars(d, out);
615 }
616 }
617 Operand::Lit(_) | Operand::Param(_) => {}
618 }
619}
620
621fn ret_node_vars(items: &[RetItem]) -> Vec<String> {
622 let mut out = Vec::new();
623 for item in items {
624 match &item.value {
625 RetVal::Var(v) | RetVal::Prop { var: v, .. } => {
626 if !out.contains(v) {
627 out.push(v.clone());
628 }
629 }
630 RetVal::FuncCall { args, .. } => {
631 for arg in args {
632 operand_node_vars(arg, &mut out);
633 }
634 }
635 RetVal::ScalarExpr(op) => operand_node_vars(op, &mut out),
636 RetVal::Agg { .. } => {}
637 }
638 }
639 out
640}
641
642fn add_var(out: &mut Vec<String>, v: &str) {
643 if !out.iter().any(|x| x == v) {
644 out.push(v.to_string());
645 }
646}
647
648fn pattern_node_vars(pats: &[Pattern]) -> Vec<String> {
649 let mut out = Vec::new();
650 for p in pats {
651 if let Some(v) = &p.start.var {
652 add_var(&mut out, v);
653 }
654 for (_, dest) in &p.chain {
655 if let Some(v) = &dest.var {
656 add_var(&mut out, v);
657 }
658 }
659 }
660 out
661}
662
663fn pattern_rel_vars(pats: &[Pattern]) -> Vec<String> {
664 let mut out = Vec::new();
665 for p in pats {
666 for (rel, _) in &p.chain {
667 if rel.hops.is_none() {
668 if let Some(v) = &rel.var {
669 add_var(&mut out, v);
670 }
671 }
672 }
673 }
674 out
675}
676
677fn rel_type_alias(var: &str) -> String {
678 format!("__rt_{var}")
679}
680
681fn ret_column_name(item: &RetItem) -> String {
682 if let Some(alias) = &item.alias {
683 return alias.clone();
684 }
685 match &item.value {
686 RetVal::Var(v) => v.clone(),
687 RetVal::Prop { var, field } => format!("{var}.{field}"),
688 RetVal::FuncCall { name, args } => {
689 let arg_strs: Vec<String> = args
690 .iter()
691 .map(|a| match a {
692 Operand::Var(v) => v.clone(),
693 Operand::Prop { var, field } => format!("{var}.{field}"),
694 Operand::Lit(_) => "<lit>".to_string(),
695 Operand::Param(p) => format!("${p}"),
696 Operand::FuncCall { name: n, .. } => format!("{n}(...)"),
697 Operand::BinArith { .. } => "<arith>".to_string(),
698 Operand::Case { .. } => "<case>".to_string(),
699 })
700 .collect();
701 format!("{name}({})", arg_strs.join(", "))
702 }
703 RetVal::ScalarExpr(_) => "<expr>".to_string(),
704 RetVal::Agg { .. } => "<agg>".to_string(),
705 }
706}
707
708fn eval_set_return_operand<F: Fs>(
709 db: &GraphDb<F>,
710 match_rs: &ResultSet,
711 row: usize,
712 rel_vars: &[String],
713 op: &Operand,
714 params: &BTreeMap<String, Value>,
715) -> Result<Option<Value>> {
716 match op {
717 Operand::Lit(v) => Ok(Some(v.clone())),
718 Operand::Param(name) => params.get(name).cloned().ok_or_else(|| GraphError::QueryError {
719 detail: format!("missing parameter `{name}`"),
720 }).map(Some),
721 Operand::Var(name) if rel_vars.iter().any(|r| r == name) => Err(GraphError::QueryError {
722 detail: format!(
723 "cannot return relationship variable '{name}' bare; return its properties ({name}.field) instead"
724 ),
725 }),
726 Operand::Var(name) => Ok(match_rs.get(row, name).cloned()),
727 Operand::Prop { var, field } => {
728 if rel_vars.iter().any(|r| r == var) {
729 return Ok(None);
730 }
731 let Some(Value::Str(key)) = match_rs.get(row, var) else {
732 return Ok(None);
733 };
734 Ok(db.get_prop(key, field))
735 }
736 Operand::FuncCall { name, args } => {
737 eval_set_return_func(db, match_rs, row, rel_vars, name, args, params)
738 }
739 Operand::BinArith { op, left, right } => {
740 let lv = eval_set_return_operand(db, match_rs, row, rel_vars, left, params)?;
741 let rv = eval_set_return_operand(db, match_rs, row, rel_vars, right, params)?;
742 eval_set_return_arith(op, lv, rv)
743 }
744 // CASE is supported in read-query RETURN; in a write-statement RETURN
745 // projection (CREATE/MERGE/SET … RETURN) it is not yet wired.
746 Operand::Case { .. } => Err(GraphError::QueryError {
747 detail: "CASE is not supported in a write-statement RETURN projection; \
748 use a read query"
749 .into(),
750 }),
751 }
752}
753
754fn eval_set_return_arith(
755 op: &ArithOp,
756 lv: Option<Value>,
757 rv: Option<Value>,
758) -> Result<Option<Value>> {
759 match (lv, rv) {
760 (None, _) | (_, None) => Ok(None),
761 (Some(Value::Int(a)), Some(Value::Int(b))) => {
762 let result = match op {
763 ArithOp::Sub => a.saturating_sub(b),
764 ArithOp::Mul => a.saturating_mul(b),
765 ArithOp::Add => a.saturating_add(b),
766 ArithOp::Div => {
767 if b == 0 {
768 return Err(GraphError::QueryError {
769 detail: "division by zero".into(),
770 });
771 }
772 a.checked_div(b).unwrap_or(i64::MAX)
773 }
774 };
775 Ok(Some(Value::Int(result)))
776 }
777 (Some(lv), Some(rv)) => {
778 let a = match &lv {
779 Value::Float(f) => *f,
780 Value::Int(i) => *i as f64,
781 _ => {
782 return Err(GraphError::QueryError {
783 detail: format!("arithmetic operand must be numeric, got {lv:?}"),
784 })
785 }
786 };
787 let b = match &rv {
788 Value::Float(f) => *f,
789 Value::Int(i) => *i as f64,
790 _ => {
791 return Err(GraphError::QueryError {
792 detail: format!("arithmetic operand must be numeric, got {rv:?}"),
793 })
794 }
795 };
796 let result = match op {
797 ArithOp::Sub => a - b,
798 ArithOp::Mul => a * b,
799 ArithOp::Add => a + b,
800 ArithOp::Div => {
801 if b == 0.0 {
802 return Err(GraphError::QueryError {
803 detail: "division by zero".into(),
804 });
805 }
806 a / b
807 }
808 };
809 Ok(Some(Value::Float(result)))
810 }
811 }
812}
813
814fn eval_set_return_func<F: Fs>(
815 db: &GraphDb<F>,
816 match_rs: &ResultSet,
817 row: usize,
818 rel_vars: &[String],
819 name: &str,
820 args: &[Operand],
821 params: &BTreeMap<String, Value>,
822) -> Result<Option<Value>> {
823 let norm = name.to_ascii_lowercase();
824 if norm == "type" {
825 if args.len() != 1 {
826 return Err(GraphError::QueryError {
827 detail: format!("type() requires exactly 1 argument, got {}", args.len()),
828 });
829 }
830 let Operand::Var(rel) = &args[0] else {
831 return Err(GraphError::QueryError {
832 detail: "type() argument must be a relationship variable (e.g. type(r))".into(),
833 });
834 };
835 return Ok(match_rs.get(row, &rel_type_alias(rel)).cloned());
836 }
837 if norm == "key" {
838 if args.len() != 1 {
839 return Err(GraphError::QueryError {
840 detail: format!("key() requires exactly 1 argument, got {}", args.len()),
841 });
842 }
843 let Operand::Var(var) = &args[0] else {
844 return Err(GraphError::QueryError {
845 detail: "key() argument must be a node variable (e.g. key(n))".into(),
846 });
847 };
848 if rel_vars.iter().any(|r| r == var) {
849 return Err(GraphError::QueryError {
850 detail: format!("key() argument `{var}` is a relationship, not a node"),
851 });
852 }
853 // MATCH rows bind node variables to their key string, so the column
854 // value *is* the key.
855 return Ok(match_rs.get(row, var).cloned());
856 }
857 let mut vals = Vec::with_capacity(args.len());
858 for arg in args {
859 vals.push(eval_set_return_operand(
860 db, match_rs, row, rel_vars, arg, params,
861 )?);
862 }
863 match norm.as_str() {
864 "tolower" => {
865 if vals.len() != 1 {
866 return Err(GraphError::QueryError {
867 detail: format!("toLower() requires exactly 1 argument, got {}", vals.len()),
868 });
869 }
870 Ok(vals[0].clone().map(|val| match val {
871 Value::Str(s) => Value::Str(s.to_ascii_lowercase()),
872 other => other,
873 }))
874 }
875 "toupper" => {
876 if vals.len() != 1 {
877 return Err(GraphError::QueryError {
878 detail: format!("toUpper() requires exactly 1 argument, got {}", vals.len()),
879 });
880 }
881 Ok(vals[0].clone().map(|val| match val {
882 Value::Str(s) => Value::Str(s.to_ascii_uppercase()),
883 other => other,
884 }))
885 }
886 "size" => match vals.first().cloned().flatten() {
887 None => Ok(None),
888 Some(Value::Str(s)) => Ok(Some(Value::Int(s.len() as i64))),
889 Some(Value::List(items)) => Ok(Some(Value::Int(items.len() as i64))),
890 Some(_) => Ok(None),
891 },
892 "coalesce" => Ok(vals.into_iter().flatten().next()),
893 "abs" => match vals.first().cloned().flatten() {
894 None => Ok(None),
895 Some(Value::Int(n)) => Ok(Some(Value::Int(n.saturating_abs()))),
896 Some(Value::Float(f)) => Ok(Some(Value::Float(f.abs()))),
897 Some(_) => Ok(None),
898 },
899 "round" => match vals.first().cloned().flatten() {
900 None => Ok(None),
901 Some(Value::Float(f)) => Ok(Some(Value::Float(f.round()))),
902 Some(Value::Int(n)) => Ok(Some(Value::Int(n))),
903 Some(_) => Ok(None),
904 },
905 "decay" => {
906 if vals.len() != 3 {
907 return Err(GraphError::QueryError {
908 detail: format!("decay() requires exactly 3 arguments, got {}", vals.len()),
909 });
910 }
911 match (vals[0].clone(), vals[1].clone(), vals[2].clone()) {
912 (None, _, _) | (_, None, _) | (_, _, None) => Ok(None),
913 (Some(b), Some(a), Some(h)) => {
914 let numeric = |v: Value| -> Result<f64> {
915 match v {
916 Value::Int(n) => Ok(n as f64),
917 Value::Float(f) => Ok(f),
918 other => Err(GraphError::QueryError {
919 detail: format!(
920 "decay() requires numeric arguments, got {other:?}"
921 ),
922 }),
923 }
924 };
925 let b = numeric(b)?;
926 let a = numeric(a)?;
927 let h = numeric(h)?;
928 if h <= 0.0 {
929 return Err(GraphError::QueryError {
930 detail: "decay() requires halflife > 0".into(),
931 });
932 }
933 Ok(Some(Value::Float(b * 0.5f64.powf(a / h))))
934 }
935 }
936 }
937 _ => Err(GraphError::QueryError {
938 detail: format!(
939 "unknown function `{name}`; supported: toLower, toUpper, size, coalesce, type, abs, round, decay, key"
940 ),
941 }),
942 }
943}
944
945fn eval_set_return_item<F: Fs>(
946 db: &GraphDb<F>,
947 match_rs: &ResultSet,
948 row: usize,
949 rel_vars: &[String],
950 item: &RetItem,
951 params: &BTreeMap<String, Value>,
952) -> Result<Option<Value>> {
953 match &item.value {
954 RetVal::Var(v) => eval_set_return_operand(
955 db,
956 match_rs,
957 row,
958 rel_vars,
959 &Operand::Var(v.clone()),
960 params,
961 ),
962 RetVal::Prop { var, field } => eval_set_return_operand(
963 db,
964 match_rs,
965 row,
966 rel_vars,
967 &Operand::Prop {
968 var: var.clone(),
969 field: field.clone(),
970 },
971 params,
972 ),
973 RetVal::FuncCall { name, args } => {
974 eval_set_return_func(db, match_rs, row, rel_vars, name, args, params)
975 }
976 RetVal::ScalarExpr(op) => eval_set_return_operand(db, match_rs, row, rel_vars, op, params),
977 RetVal::Agg { .. } => Err(GraphError::QueryError {
978 detail: "aggregates are not supported in MATCH … SET … RETURN".into(),
979 }),
980 }
981}
982
983/// Project user RETURN from original MATCH rows after SET. No rematch.
984fn project_set_return_rows<F: Fs>(
985 db: &GraphDb<F>,
986 rel_vars: &[String],
987 match_rs: &ResultSet,
988 returns: &[RetItem],
989 params: &BTreeMap<String, Value>,
990) -> Result<ResultSet> {
991 let columns: Vec<String> = returns.iter().map(ret_column_name).collect();
992 let mut out = ResultSet::new(columns);
993 for row in 0..match_rs.len() {
994 let mut cells = Vec::with_capacity(returns.len());
995 for item in returns {
996 cells.push(eval_set_return_item(
997 db, match_rs, row, rel_vars, item, params,
998 )?);
999 }
1000 out.push_row(cells);
1001 }
1002 Ok(out)
1003}
1004
1005/// Single construction point for a `GraphMut` view over the split-borrowed graph fields.
1006/// Callers use `std::mem::take` on the engine before calling this, then restore it after.
1007/// Extract a `Vec<f64>` from a `Value::List` whose items are all numeric.
1008/// Returns `None` for non-list values or lists with non-numeric elements.
1009fn value_as_float_list(v: &Value) -> Option<Vec<f64>> {
1010 match v {
1011 Value::List(items) => items
1012 .iter()
1013 .map(|item| match item {
1014 Value::Float(f) => Some(*f),
1015 Value::Int(i) => Some(*i as f64),
1016 _ => None,
1017 })
1018 .collect(),
1019 _ => None,
1020 }
1021}
1022
1023fn make_graph_mut<'a>(
1024 ids: &'a IdMap,
1025 syms: &'a mut Interner,
1026 labels: &'a [u32],
1027 props: core_storage::v8::seam::ColumnsView<'a>,
1028 topo: &'a mut Topology,
1029 base: &'a Option<std::sync::Arc<core_storage::v8::MappedBase>>,
1030 edge_props: &'a mut EdgeProps,
1031) -> GraphMut<'a> {
1032 GraphMut {
1033 ids,
1034 syms,
1035 labels,
1036 props,
1037 topo,
1038 base_topo: base_csr(base),
1039 edge_props,
1040 }
1041}
1042
1043/// The archived CSR of an open V8 snapshot, for the rule engine's graph reads.
1044///
1045/// A store opened from a snapshot keeps its edges in the mapping and its
1046/// overlay empty, so a rule that reads the graph's shape has to see both.
1047fn base_csr(
1048 base: &Option<std::sync::Arc<core_storage::v8::MappedBase>>,
1049) -> Option<&core_storage::v8::layout::ArchivedCsr> {
1050 base.as_ref().map(|b| {
1051 b.topology()
1052 .expect("base topology section bounds validated at open")
1053 })
1054}
1055
1056/// Build a `ColumnsView` from the disjoint `props` overlay and optional V8 base.
1057///
1058/// Takes explicit field references rather than `&self` so the caller can hold
1059/// simultaneous mutable borrows of other fields (e.g. `syms`, `topo`).
1060fn build_props_view<'a>(
1061 props: &'a ColumnStore,
1062 base: &'a Option<std::sync::Arc<core_storage::v8::MappedBase>>,
1063) -> core_storage::v8::seam::ColumnsView<'a> {
1064 match base {
1065 None => core_storage::v8::seam::ColumnsView::owned(props),
1066 Some(b) => {
1067 let archived = b
1068 .columns()
1069 .expect("base columns section bounds validated at open");
1070 core_storage::v8::seam::ColumnsView::with_base_cached(props, archived, b.mixed_cache())
1071 }
1072 }
1073}
1074
1075fn build_topo_view<'a>(
1076 overlay: &'a Topology,
1077 base: &'a Option<std::sync::Arc<core_storage::v8::MappedBase>>,
1078) -> core_storage::v8::seam::TopologyView<'a> {
1079 match base {
1080 None => core_storage::v8::seam::TopologyView::owned(overlay),
1081 Some(b) => {
1082 let archived_csr = b
1083 .topology()
1084 .expect("base topology section bounds validated at open");
1085 core_storage::v8::seam::TopologyView::with_base(overlay, archived_csr)
1086 }
1087 }
1088}
1089
1090/// When [`GraphDb`] calls `Fs::sync` after a WAL append.
1091///
1092/// Default is [`Strict`](FsyncPolicy::Strict): every `log_then_apply_with`
1093/// fsyncs (single `insert_node` / `set_prop`). Ingest and `write_batch`
1094/// emit one `WalRecord::Batch` and fsync once at that frame (Batched).
1095/// [`Relaxed`](FsyncPolicy::Relaxed) skips WAL sync; [`GraphDb::snapshot`]
1096/// is still durable via `write_atomic`. Crash-recovery DST stays Strict.
1097#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
1098pub enum FsyncPolicy {
1099 /// Every WAL commit calls `fs.sync` (today's behavior).
1100 #[default]
1101 Strict,
1102 /// Sync only at a `Batch` frame end. Single-op path stays Strict unless
1103 /// this policy is set on the database.
1104 Batched,
1105 /// Never call `fs.sync`. [`GraphDb::snapshot`] still syncs via `write_atomic`.
1106 Relaxed,
1107}
1108
1109/// A precondition for a compare-and-set batch write.
1110///
1111/// All preconditions in a [`GraphDb::write_batch_cas`] or
1112/// [`crate::SharedDb::submit_batch_cas`] call are checked atomically before
1113/// any operation in the batch is applied. If any precondition fails, the
1114/// entire batch is rejected with [`GraphError::CasConflict`] and no WAL frame
1115/// is written.
1116///
1117/// # Touch definition
1118///
1119/// A node's last-change commit (`last_changed`) is updated when any of the
1120/// following state-changing WAL records touch it:
1121///
1122/// - `InsertNode` / `InsertNodeId` — the newly-inserted node.
1123/// - `SetProp` / `SetPropId` / `RemoveProp` — the property-bearing node.
1124/// - `InsertEdge` / `InsertEdgeId` / `DeleteEdge` — **both** src and dst
1125/// endpoints (an edge change touches both sides).
1126/// - `DeleteNode` — the node is tombstoned; `last_changed` returns `None`
1127/// for deleted keys so the pre-deletion entry is never observed.
1128///
1129/// History markers (`DerivedEdgeAdded` / `DerivedEdgeRetracted`) are
1130/// state no-ops. The underlying mutation that triggered rule firing already
1131/// updated the relevant nodes' last-change entries. Rule-management records
1132/// (`CreateRule`, `DeleteRule`, `RebuildRule`) and view/full-text declarations
1133/// do not touch any node's last-change.
1134#[derive(Debug, Clone, PartialEq, Eq)]
1135pub enum Precondition {
1136 /// The node's last-change commit must equal `expected`.
1137 ///
1138 /// Fails with [`GraphError::CasConflict`] when:
1139 /// - The node does not exist (`last_changed` returns `None`), or
1140 /// - The recorded commit seq does not match `expected`.
1141 NodeUnchangedSince { key: String, expected: u64 },
1142 /// The node must not exist (not inserted, or already deleted).
1143 ///
1144 /// Fails with [`GraphError::CasConflict`] (expected=`u64::MAX`,
1145 /// actual=`last_changed(key).unwrap_or(0)`) when the node is live.
1146 NodeAbsent { key: String },
1147}
1148
1149pub struct GraphDb<F: Fs> {
1150 fs: F,
1151 ids: IdMap,
1152 syms: Interner,
1153 topo: Topology,
1154 props: ColumnStore,
1155 labels: Vec<u32>, // node id -> label symbol
1156 edge_props: EdgeProps,
1157 engine: RuleEngine,
1158 view_store: ViewStore,
1159 /// Incremental inverted index for full-text-lite search.
1160 /// Rebuild-on-open: populated from WAL replay + rebuild_all at open end.
1161 fulltext: FulltextIndex,
1162 /// Opt-in equality index over scalar node properties.
1163 /// Rebuild-on-open: declarations replay from the WAL, postings rebuild at
1164 /// open end (mirrors `fulltext`).
1165 prop_index: PropertyIndex,
1166 event_sink: Option<Box<dyn Fn(MutationEvent) + Send + Sync>>,
1167 /// WAL fsync cadence. Default [`FsyncPolicy::Strict`].
1168 fsync: FsyncPolicy,
1169 /// Monotonically increasing per-commit counter. A single `log_then_apply_with`
1170 /// call increments this once; all events emitted from that call share the same
1171 /// `commit_seq` value.
1172 commit_seq: u64,
1173 /// RBAC role definitions loaded from `roles.json` at open.
1174 ///
1175 /// `Some(roles)` — loaded successfully (may be empty when no roles are defined).
1176 /// `None` — `roles.json` was present but corrupt; `mask_for_role` returns
1177 /// `Err` for any request (fail-loud, never silently grant empty visibility).
1178 roles: Option<Vec<RoleDef>>,
1179 /// Live subscriptions. Entries with a dead `Weak` are pruned on the next
1180 /// distribute_events call.
1181 subscriptions: Vec<SubEntry>,
1182 /// Live query subscriptions. Re-executed on every commit when non-empty.
1183 /// Dead `Weak` entries are pruned inside `distribute_events`.
1184 query_subscriptions: Vec<QuerySubEntry>,
1185 /// Queue capacity for new subscriptions created by this db. Default is
1186 /// [`DEFAULT_SUB_CAPACITY`]; can be overridden via [`set_sub_capacity`]
1187 /// to test Lagged behaviour with small queues.
1188 sub_capacity: usize,
1189 /// True for as-of instances opened via [`GraphDb::open_at`].
1190 /// Every mutation method and `snapshot()` returns [`GraphError::ReadOnly`]
1191 /// when this flag is set.
1192 read_only: bool,
1193 /// Total WAL commit count at the time [`open_at`] was called.
1194 /// 0 for normal (non-as-of) instances.
1195 total_wal_commits: u64,
1196 /// Immutable mmap-backed base snapshot (V8). When `Some`, `self.topo` is
1197 /// the WAL-replay overlay (empty at open time, populated by apply()) and
1198 /// reads go through a merged `TopologyView`. `self.props` is always
1199 /// fully materialized (base + WAL replay) for HNSW/IVF and view compat.
1200 base: Option<Arc<core_storage::v8::MappedBase>>,
1201 // ── MVCC epoch reader state ───────────────────────────────────────────────
1202 /// Most-recent full overlay clone. Initialized at end of `open_with` /
1203 /// `open_at_with`; refreshed every `FOLD_EVERY_K` commits.
1204 /// `None` only between struct creation and the first fold.
1205 fold_overlay: Option<Arc<crate::reader::FrozenOverlay>>,
1206 /// Per-commit deltas accumulated since the last fold.
1207 delta_tail: Vec<Arc<crate::reader::CommitDelta>>,
1208 /// How many commits have occurred since the last fold.
1209 commits_since_fold: usize,
1210 /// When true, `log_then_apply_with` buffers event notifications instead of
1211 /// firing them immediately. Used by the group-commit drain thread to defer
1212 /// events until after the group fsync (R2: durability before notification).
1213 /// Cleared to false once the drain thread flushes or discards the buffer.
1214 defer_events: bool,
1215 /// Buffered events accumulated while `defer_events` is true.
1216 deferred_events: Vec<DeferredEvent>,
1217 /// Set to true by the group-commit drain thread when a group fsync fails
1218 /// after WAL truncation. All subsequent mutation attempts return an IO
1219 /// error until the database is reopened.
1220 degraded: bool,
1221 /// Set to `true` after `ensure_v8_base_sections_loaded` has read provenance,
1222 /// HNSW, and IVF sections from the mmap base into the engine's retained
1223 /// fields. `false` on all opens until first use; always `true` for non-V8
1224 /// opens (base is None, fast-path sets flag immediately).
1225 v8_sections_loaded: std::sync::atomic::AtomicBool,
1226 /// Serializes the one-time section population in `ensure_v8_base_sections_loaded`.
1227 v8_sections_mutex: std::sync::Mutex<()>,
1228 /// Per-node last-change commit sequence. `last_change[node_id] = seq` means
1229 /// the node was last modified by commit `seq`.
1230 ///
1231 /// Loaded from V8 section 11 at open; updated on every state-changing commit
1232 /// and WAL replay frame. V5-V7 stores start with an empty map; pre-WAL-horizon
1233 /// nodes return `None` from `last_changed` until they are next mutated.
1234 ///
1235 /// See [`Precondition`] for the full touch definition.
1236 last_change: HashMap<u32, u64>,
1237 /// WAL archive retention policy set by [`set_wal_archive_retention`].
1238 /// `None` = unlimited (keep all archives); `Some(N)` = keep N newest archives,
1239 /// pruning older ones at snapshot time. 0 is treated as unlimited.
1240 wal_archive_retention: Option<u32>,
1241 /// Global frame index of the first commit that is still reachable through
1242 /// surviving archives. Persisted to `wal.floor` sidecar when pruning occurs.
1243 /// Default 0 = all history reachable.
1244 wal_horizon_floor: u64,
1245 /// True when the surviving archive chain forms a continuous WAL history
1246 /// starting from the store's first commit (the genesis chain).
1247 ///
1248 /// `open_at` may replay archive-resident commits from empty state only when
1249 /// this flag is true AND `wal_horizon_floor == 0`. Cleared whenever:
1250 /// - a WAL-truncating snapshot (`keep_wal=false`) is taken after archives
1251 /// already exist (breaks the chain for subsequent archives), or
1252 /// - any archive is pruned (floor advances past zero).
1253 ///
1254 /// Persisted via the `wal.genesis` marker file; loaded from it at open.
1255 archive_genesis_chain: bool,
1256 /// Transient write-authz context set by `write_batch_authz` /
1257 /// `query_write_authz` for the duration of ONE mutation call.
1258 /// Always `None` at rest. Never serialized, never WAL-replayed.
1259 pending_write_authz: Option<WriteAuthz>,
1260 /// Slow-query threshold in milliseconds. 0 = disabled.
1261 /// Seeded from `MUSHROOMDB_SLOW_QUERY_MS` at open; override via
1262 /// [`GraphDb::set_slow_query_threshold_ms`] (tests must use the setter
1263 /// — env vars are process-global and race parallel test threads).
1264 slow_query_threshold_ms: u64,
1265 /// Ring buffer of recent slow queries (interior-mutable so `query(&self)`
1266 /// can record entries without requiring `&mut self`).
1267 slow_queries: std::sync::Mutex<SlowQueryLog>,
1268 /// Instant at which the database was opened (used by `/metrics` uptime).
1269 started_at: std::time::Instant,
1270 // ── Multi-process state (cross-process lock + WAL tailing) ────────────────
1271 /// Byte offset of the WAL prefix already applied to in-memory state.
1272 ///
1273 /// Advanced by exactly the encoded length of every frame this handle
1274 /// appends, and by the decoded byte count of every tail
1275 /// [`refresh`](GraphDb::refresh) absorbs. Rewound by
1276 /// [`set_wal_consumed`](GraphDb::set_wal_consumed) when the group-commit
1277 /// drain thread truncates a failed group. Compared against the WAL's
1278 /// on-disk length to decide staleness.
1279 wal_consumed: u64,
1280 /// Identity of the snapshot this handle's base state came from, as
1281 /// `(len, mtime_nanos)`. A different value means another process replaced
1282 /// the snapshot and the WAL no longer continues our state: refresh reloads.
1283 snapshot_ident: Option<(u64, u64)>,
1284 /// The options this handle was opened with. Replayed verbatim when
1285 /// `refresh` has to rebuild from disk.
1286 open_opts: OpenOptions,
1287 /// True when this handle holds the cross-process write lock for its whole
1288 /// lifetime (a plain read-write open). Per-write lock acquisition is a
1289 /// no-op on such a handle, and never releases the lock.
1290 holds_lifetime_lock: bool,
1291 /// True between a failed lock acquisition and the end of the write scope
1292 /// that failed. Makes every WAL-appending mutation in that scope return
1293 /// [`GraphError::Busy`] instead of writing.
1294 lock_denied: bool,
1295 /// True for an as-of view opened via [`GraphDb::open_at`]. Such a view is
1296 /// pinned to one commit, so it is never stale and never refreshes — later
1297 /// commits by any process are deliberately invisible to it.
1298 pinned: bool,
1299}
1300
1301/// One group of deferred event notifications, held until the group fsync
1302/// completes. Replayed by [`GraphDb::flush_deferred_events`].
1303struct DeferredEvent {
1304 rec: core_storage::WalRecord,
1305 engine_deltas: Vec<EngineEdgeDelta>,
1306 seq: u64,
1307 ingest: Option<(String, usize)>,
1308}
1309
1310/// Options for [`GraphDb::open_with_options`].
1311#[derive(Clone, Copy, Debug)]
1312pub struct OpenOptions {
1313 /// Rewrite an old-format snapshot to the current VERSION after a
1314 /// successful load (default `true`). The old snapshot is kept as
1315 /// `snapshot.bin.bak` until the next clean open at the current version,
1316 /// at which point the `.bak` is deleted.
1317 ///
1318 /// Set to `false` to open a store without touching any on-disk files
1319 /// (useful for read-only inspection of a store at an older format).
1320 pub auto_migrate: bool,
1321
1322 /// Write the valid WAL prefix back over a torn tail on open (default
1323 /// `true`). Truncating a genuinely torn tail is correct crash recovery.
1324 ///
1325 /// Set to `false` for an unattended reader. The valid prefix is still
1326 /// decoded and replayed in memory, but nothing is written: a reader that
1327 /// opens while another process is mid-append would otherwise discard a
1328 /// frame that writer believes durable. `mushroomdb recall`, which runs on
1329 /// every prompt, passes `false` for exactly this reason.
1330 pub repair_wal: bool,
1331
1332 /// Open without ever writing to the store (default `false`).
1333 ///
1334 /// A read-only handle:
1335 /// - returns [`GraphError::ReadOnly`] from every mutation and from
1336 /// `snapshot()`;
1337 /// - performs no disk write at open — no WAL repair write-back and no
1338 /// auto-migration rewrite, whatever the other two flags say;
1339 /// - never takes the cross-process write lock, so it opens immediately even
1340 /// while another process is writing, and never makes a writer wait.
1341 ///
1342 /// [`refresh`](GraphDb::refresh) and [`is_stale`](GraphDb::is_stale) work
1343 /// normally, so a read-only handle can follow another process's commits.
1344 pub read_only: bool,
1345}
1346
1347impl Default for OpenOptions {
1348 fn default() -> Self {
1349 Self {
1350 auto_migrate: true,
1351 repair_wal: true,
1352 read_only: false,
1353 }
1354 }
1355}
1356
1357/// How long a writer polls for the cross-process write lock before giving up
1358/// with [`GraphError::Busy`].
1359///
1360/// Long enough to ride out another process's commit (a batch apply plus one
1361/// fsync), short enough that a stuck peer surfaces as an error rather than a
1362/// hang.
1363pub const WRITE_LOCK_WAIT: std::time::Duration = std::time::Duration::from_secs(2);
1364
1365/// Interval between poll attempts while waiting for the cross-process lock.
1366pub(crate) const LOCK_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(10);
1367
1368/// Why `load_from_disk` is running, which decides whether it may repair.
1369#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1370enum LoadOrigin {
1371 /// A fresh open. Crash recovery is this handle's job: a torn WAL tail is
1372 /// the signature of a crash and truncating it is correct, and archives
1373 /// orphaned by an interrupted prune can be swept.
1374 Open,
1375 /// A reload driven by [`GraphDb::refresh`], because another process
1376 /// replaced the snapshot. Nothing here is crash recovery — the store is
1377 /// live and someone else is writing it — so this origin writes nothing.
1378 Reload,
1379}
1380
1381/// Authorization context carried by `write_batch_authz` / `query_write_authz`.
1382///
1383/// `None` at the call site = full authority (today's zero-cost behavior).
1384/// `Some(WriteAuthz)` = role-scoped: the decision table (plan §"authz decision
1385/// table") is evaluated per-op inside `commit_logged_batch` BEFORE any WAL
1386/// record is built. A denial returns an error with no WAL frame written.
1387///
1388/// The mask is ALWAYS `Omit`-mode: role-token paths must never acknowledge
1389/// hidden-node existence to callers.
1390#[derive(Clone, Debug)]
1391pub struct WriteAuthz {
1392 pub role: String,
1393 pub scope: WriteScope,
1394 /// Resolved by `mask_for_role` under the same write guard as the mutation.
1395 /// Always `Omit`-mode — never `Stub`.
1396 pub mask: crate::mask::NodeMask,
1397}
1398
1399/// Write `bytes` to `snapshot.bin.bak` atomically with full fsync.
1400///
1401/// Uses [`RealFs::write_atomic`] which applies `F_FULLFSYNC` on macOS and
1402/// `sync_all` on other platforms, then renames the `.tmp` file into place and
1403/// syncs the directory entry. This is the only correct path for writing the
1404/// `.bak` — plain `std::fs::write + sync_all` misses both `F_FULLFSYNC` and
1405/// the directory sync.
1406pub fn write_snapshot_bak(dir: &std::path::Path, bytes: &[u8]) -> crate::Result<()> {
1407 use core_storage::fs::{FileId, Fs as _};
1408 RealFs::new(dir)
1409 .map_err(core_storage::GraphError::Io)?
1410 .write_atomic(FileId::SnapshotBak, bytes)
1411 .map_err(core_storage::GraphError::Io)
1412}
1413
1414/// Return the on-disk snapshot format version without decoding the full snapshot.
1415///
1416/// Reads only the 6-byte header (magic + version LE). Returns `None` when no
1417/// snapshot file exists (WAL-only store). Returns an error if the header is
1418/// malformed.
1419pub fn snapshot_version_at(dir: &std::path::Path) -> crate::Result<Option<u16>> {
1420 use std::io::Read as _;
1421 let path = dir.join("snapshot.bin");
1422 let mut header = [0u8; 6];
1423 let n = match std::fs::File::open(&path) {
1424 Ok(mut f) => f.read(&mut header).map_err(core_storage::GraphError::Io)?,
1425 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1426 Err(e) => return Err(core_storage::GraphError::Io(e)),
1427 };
1428 core_storage::snapshot::peek_version(&header[..n])
1429}
1430
1431/// Options for [`GraphDb::snapshot_with`].
1432#[derive(Debug, Clone, Default)]
1433pub struct SnapshotOptions {
1434 /// When `true`, the WAL is preserved after the snapshot write.
1435 /// Pre-snapshot commits remain reachable via [`GraphDb::open_at`].
1436 /// When `false` (the default), the WAL is truncated to a minimal
1437 /// baseline so cold-start replay stays fast.
1438 pub keep_wal: bool,
1439 /// When `true`, the current WAL is renamed to `wal.<commit_seq>.archive`
1440 /// before a fresh WAL baseline is written (history-preserving snapshot).
1441 ///
1442 /// This is the feature opt-in: `false` (the default) leaves the existing
1443 /// truncation / keep-wal behaviour byte-identical. `archive_wal` takes
1444 /// precedence over `keep_wal` when both are set.
1445 ///
1446 /// Archives can be scanned by [`GraphDb::node_history`],
1447 /// [`GraphDb::edge_history`], [`GraphDb::was_linked`], and
1448 /// [`GraphDb::open_at`], extending the reachable history horizon across
1449 /// snapshot boundaries.
1450 pub archive_wal: bool,
1451}
1452
1453/// Derive the scan-label sym for the commit-skip fast-path.
1454///
1455/// Walks `ops` to find the plan's leading scan op (`ScanLabel`, `IndexScan`,
1456/// or `IndexIntersect`) with a concrete label string, then interns it.
1457///
1458/// Returns `None` in all cases where skipping is unsafe:
1459/// - Any `Expand` op is present (edge traversal; edges change results regardless
1460/// of node labels).
1461/// - The leading scan has no label (`ScanLabel { label: None }` — full scan).
1462/// - No recognizable leading scan op is found.
1463///
1464/// This is the conservative v0.4.3 boundary. The caller stores the result in
1465/// [`QuerySubEntry::scan_label`] at subscribe time; `None` means always execute.
1466fn extract_scan_label(ops: &[PlanOp], syms: &mut Interner) -> Option<u32> {
1467 // Any Expand → must always re-execute (edges can change join results).
1468 if ops.iter().any(|op| matches!(op, PlanOp::Expand { .. })) {
1469 return None;
1470 }
1471 for op in ops {
1472 match op {
1473 PlanOp::ScanLabel {
1474 label: Some(label), ..
1475 } => return Some(syms.intern(label)),
1476 PlanOp::IndexScan {
1477 label: Some(label), ..
1478 } => return Some(syms.intern(label)),
1479 PlanOp::IndexIntersect {
1480 label: Some(label), ..
1481 } => return Some(syms.intern(label)),
1482 _ => {}
1483 }
1484 }
1485 None
1486}
1487
1488impl GraphDb<RealFs> {
1489 /// Open the database at `dir` with default options.
1490 ///
1491 /// Equivalent to `open_with_options(dir, OpenOptions::default())`.
1492 /// Old-format snapshots (V5, V6) are automatically migrated to the
1493 /// current version on a successful load (see [`OpenOptions::auto_migrate`]).
1494 pub fn open(dir: &std::path::Path) -> Result<Self> {
1495 Self::open_with_options(dir, OpenOptions::default())
1496 }
1497
1498 /// Open the database at `dir` with explicit options.
1499 ///
1500 /// When `opts.auto_migrate` is `true` (the default) and the on-disk
1501 /// snapshot is an older format version, this function:
1502 /// 1. Copies the current `snapshot.bin` to `snapshot.bin.bak` (atomic
1503 /// + fsynced) before any modification.
1504 /// 2. Rewrites `snapshot.bin` at the current format version via
1505 /// [`GraphDb::snapshot_with`] with `keep_wal: true` (WAL preserved).
1506 ///
1507 /// If migration fails the error is returned and the original files are
1508 /// intact (the `.bak` was written before the new snapshot was attempted).
1509 ///
1510 /// A clean open that finds the snapshot already at the current version
1511 /// deletes any leftover `.bak` file.
1512 ///
1513 /// WAL-only stores (no snapshot) are never auto-migrated on open.
1514 ///
1515 /// `opts.repair_wal` controls the other write this function can make; see
1516 /// [`OpenOptions::repair_wal`]. With both flags `false` the open touches
1517 /// no file on disk.
1518 pub fn open_with_options(dir: &std::path::Path, opts: OpenOptions) -> Result<Self> {
1519 Self::open_dir(dir, opts, true)
1520 }
1521
1522 /// Open without taking the cross-process write lock for the handle's
1523 /// lifetime.
1524 ///
1525 /// Only [`SharedDb`](crate::SharedDb) uses this: a long-lived server holds
1526 /// its handle open indefinitely, so it takes the lock per write instead of
1527 /// keeping every other process out of the store for as long as it runs.
1528 pub(crate) fn open_unlocked(dir: &std::path::Path) -> Result<Self> {
1529 Self::open_dir(dir, OpenOptions::default(), false)
1530 }
1531
1532 fn open_dir(dir: &std::path::Path, opts: OpenOptions, hold_lock: bool) -> Result<Self> {
1533 // Header-only peek — 6 bytes, no full decode.
1534 let snap_version = snapshot_version_at(dir)?;
1535
1536 // Full load: decode snapshot + replay WAL + rebuild indexes.
1537 let mut db = Self::open_generic(RealFs::new(dir)?, opts, hold_lock)?;
1538
1539 // A read-only handle writes nothing at open, so it never migrates —
1540 // the old-format snapshot is loaded and left exactly as it is.
1541 if opts.auto_migrate && !opts.read_only {
1542 match snap_version {
1543 Some(ver) if ver < core_storage::snapshot::VERSION => {
1544 let _tm = std::time::Instant::now();
1545 // Copy the original snapshot to .bak at OS level — no in-memory
1546 // buffer required for a 2+ GiB file.
1547 //
1548 // Crash-safety: snapshot.bin remains intact (write_atomic inside
1549 // snapshot_with uses a .tmp+rename) until the V8 write succeeds.
1550 // A torn .bak on crash is acceptable because the original
1551 // snapshot.bin is the authoritative source until after the rename.
1552 std::fs::copy(dir.join("snapshot.bin"), dir.join("snapshot.bin.bak"))
1553 .map_err(core_storage::GraphError::Io)?;
1554 trace_migrate!("bak copy done", _tm);
1555 // Rewrite snapshot at current version; keep WAL intact.
1556 db.snapshot_with(SnapshotOptions {
1557 keep_wal: true,
1558 ..SnapshotOptions::default()
1559 })?;
1560 trace_migrate!("snapshot_with done", _tm);
1561 }
1562 Some(_) => {
1563 // Already current version: remove any leftover .bak.
1564 let bak = dir.join("snapshot.bin.bak");
1565 if bak.exists() {
1566 std::fs::remove_file(&bak).map_err(core_storage::GraphError::Io)?;
1567 }
1568 }
1569 None => {
1570 // WAL-only store — nothing to migrate on open.
1571 }
1572 }
1573 }
1574
1575 Ok(db)
1576 }
1577
1578 /// Open a read-only view of the database as it existed after `commit`.
1579 ///
1580 /// Commit indices are 0-based over the current WAL: commit 0 is the state
1581 /// after the first WAL frame, commit N-1 is the state after the N-th (most
1582 /// recent) frame. Call [`GraphDb::open`] to read the full current state.
1583 ///
1584 /// **Replay base.** [`GraphDb::snapshot`] truncates the WAL when it runs,
1585 /// so as-of can only reach commits recorded in the current WAL (those
1586 /// written after the most recent snapshot, or all commits if no snapshot
1587 /// was ever taken). Commit 0 in `open_at` always refers to the first
1588 /// frame in the WAL that exists on disk, not the first ever write to the
1589 /// database. When the on-disk snapshot recorded that it truncated the
1590 /// WAL (V7, default `keep_wal: false`), it is loaded as the base state
1591 /// before frame replay, so the as-of view includes all pre-snapshot data.
1592 /// Snapshots written with `keep_wal: true` (and legacy V5/V6 snapshots)
1593 /// are ignored and replay is WAL-only, as before.
1594 ///
1595 /// **Read-only.** Every mutation method and `snapshot()` on the returned
1596 /// instance returns [`GraphError::ReadOnly`]. Queries, `explain()`, and
1597 /// `stats()` work normally.
1598 ///
1599 /// # Errors
1600 /// - [`GraphError::CommitOutOfRange`] if `commit >= wal_commit_count` (including
1601 /// when the WAL is empty after a snapshot).
1602 pub fn open_at(dir: &std::path::Path, commit: u64) -> Result<Self> {
1603 Self::open_at_with(RealFs::new(dir)?, commit)
1604 }
1605
1606 /// Run a **read-only** Cypher query against the graph as it existed at
1607 /// `commit` — the "time-travel" / agent-replay query. Opens a temporal view
1608 /// of this store's directory at that commit and executes the read there.
1609 ///
1610 /// The current instance is unaffected. Write statements are rejected (the
1611 /// temporal view is read-only). `commit` is a 0-based WAL commit index;
1612 /// `commit == wal_commit_count` (or `open_at`'s range) yields the newest
1613 /// state. Prefer this over holding many historical instances open.
1614 ///
1615 /// # Errors
1616 /// - [`GraphError::CommitOutOfRange`] if `commit` is past the WAL horizon.
1617 /// - A query error for a malformed or write query.
1618 pub fn query_at(
1619 &self,
1620 commit: u64,
1621 cypher: &str,
1622 params: &std::collections::BTreeMap<String, Value>,
1623 ) -> Result<ResultSet> {
1624 let dir = self.fs.dir().to_path_buf();
1625 let temporal = Self::open_at(&dir, commit)?;
1626 if is_write_tokens(&lex(cypher).map_err(|e| GraphError::QueryError {
1627 detail: format!("lex: {e}"),
1628 })?) {
1629 return Err(GraphError::QueryError {
1630 detail: "query_at is read-only: write statements are not permitted in a \
1631 time-travel query"
1632 .into(),
1633 });
1634 }
1635 temporal.query(cypher, params)
1636 }
1637}
1638
1639impl<F: Fs> GraphDb<F> {
1640 /// Open over an arbitrary [`Fs`], repairing a torn WAL tail as usual.
1641 pub fn open_with(fs: F) -> Result<Self> {
1642 Self::open_with_repair(fs, true)
1643 }
1644
1645 /// As [`GraphDb::open_with`], but `repair_wal: false` decodes the valid WAL
1646 /// prefix without writing the truncation back. See
1647 /// [`OpenOptions::repair_wal`].
1648 pub fn open_with_repair(fs: F, repair_wal: bool) -> Result<Self> {
1649 Self::open_generic(
1650 fs,
1651 OpenOptions {
1652 repair_wal,
1653 ..OpenOptions::default()
1654 },
1655 true,
1656 )
1657 }
1658
1659 /// Shared open path.
1660 ///
1661 /// `hold_lock` requests the cross-process write lock for the whole handle
1662 /// lifetime — the right behaviour for a plain read-write `GraphDb`, whose
1663 /// owner writes through it directly. [`SharedDb`](crate::SharedDb) passes
1664 /// `false` and takes the lock per write instead, so that a long-lived
1665 /// server does not keep every other process out of the store.
1666 ///
1667 /// A read-only open never takes the lock regardless of `hold_lock`.
1668 fn open_generic(fs: F, opts: OpenOptions, hold_lock: bool) -> Result<Self> {
1669 let mut db = Self::new_empty(fs, opts);
1670 db.read_only = opts.read_only;
1671 if hold_lock && !opts.read_only {
1672 if !db.poll_lock(WRITE_LOCK_WAIT)? {
1673 return Err(GraphError::Busy { holder: None });
1674 }
1675 db.holds_lifetime_lock = true;
1676 }
1677 db.load_from_disk(LoadOrigin::Open)?;
1678 Ok(db)
1679 }
1680
1681 /// A handle with no state loaded: every field at its empty value, the
1682 /// filesystem and options in place. Only [`load_from_disk`] makes it
1683 /// usable.
1684 fn new_empty(fs: F, opts: OpenOptions) -> Self {
1685 Self {
1686 fs,
1687 ids: IdMap::new(),
1688 syms: Interner::new(),
1689 topo: Topology::new(),
1690 props: ColumnStore::new(),
1691 labels: Vec::new(),
1692 edge_props: EdgeProps::new(),
1693 engine: RuleEngine::new(),
1694 view_store: ViewStore::new(),
1695 fulltext: FulltextIndex::new(),
1696 prop_index: PropertyIndex::new(),
1697 event_sink: None,
1698 fsync: FsyncPolicy::Strict,
1699 commit_seq: 0,
1700 roles: Some(vec![]),
1701 subscriptions: Vec::new(),
1702 query_subscriptions: Vec::new(),
1703 sub_capacity: DEFAULT_SUB_CAPACITY,
1704 read_only: false,
1705 total_wal_commits: 0,
1706 base: None,
1707 fold_overlay: None,
1708 delta_tail: Vec::new(),
1709 commits_since_fold: 0,
1710 defer_events: false,
1711 deferred_events: Vec::new(),
1712 degraded: false,
1713 v8_sections_loaded: std::sync::atomic::AtomicBool::new(false),
1714 v8_sections_mutex: std::sync::Mutex::new(()),
1715 last_change: HashMap::new(),
1716 wal_archive_retention: None,
1717 wal_horizon_floor: 0,
1718 archive_genesis_chain: false,
1719 pending_write_authz: None,
1720 slow_query_threshold_ms: std::env::var("MUSHROOMDB_SLOW_QUERY_MS")
1721 .ok()
1722 .and_then(|v| v.parse().ok())
1723 .unwrap_or(100),
1724 slow_queries: std::sync::Mutex::new(SlowQueryLog {
1725 entries: std::collections::VecDeque::new(),
1726 total: 0,
1727 }),
1728 started_at: std::time::Instant::now(),
1729 wal_consumed: 0,
1730 snapshot_ident: None,
1731 open_opts: opts,
1732 holds_lifetime_lock: false,
1733 lock_denied: false,
1734 pinned: false,
1735 }
1736 }
1737
1738 /// Return every field describing stored graph state to its empty value,
1739 /// leaving this handle's own identity alone.
1740 ///
1741 /// Preserved on purpose: the filesystem, open options, lock ownership, the
1742 /// event sink and subscriptions, fsync policy, degraded flag, and the
1743 /// slow-query configuration and log. A caller that registered a sink or a
1744 /// subscription keeps it across a reload.
1745 fn reset_for_reload(&mut self) {
1746 self.ids = IdMap::new();
1747 self.syms = Interner::new();
1748 self.topo = Topology::new();
1749 self.props = ColumnStore::new();
1750 self.labels = Vec::new();
1751 self.edge_props = EdgeProps::new();
1752 self.engine = RuleEngine::new();
1753 self.view_store = ViewStore::new();
1754 self.fulltext = FulltextIndex::new();
1755 self.prop_index = PropertyIndex::new();
1756 self.commit_seq = 0;
1757 self.roles = Some(vec![]);
1758 self.total_wal_commits = 0;
1759 self.base = None;
1760 self.fold_overlay = None;
1761 self.delta_tail = Vec::new();
1762 self.commits_since_fold = 0;
1763 self.deferred_events = Vec::new();
1764 self.v8_sections_loaded
1765 .store(false, std::sync::atomic::Ordering::Release);
1766 self.last_change = HashMap::new();
1767 self.wal_horizon_floor = 0;
1768 self.archive_genesis_chain = false;
1769 self.pending_write_authz = None;
1770 self.wal_consumed = 0;
1771 self.snapshot_ident = None;
1772 }
1773
1774 /// Load the snapshot base and replay the WAL into an empty handle — the
1775 /// whole of what opening a store does after the struct exists.
1776 ///
1777 /// Split out of the open path so that [`refresh`](GraphDb::refresh) can
1778 /// rebuild a handle in place, without ownership of `F`, when another
1779 /// process replaces the snapshot underneath it.
1780 ///
1781 /// `origin` decides whether the two repair writes this function can make
1782 /// are appropriate; see [`LoadOrigin`].
1783 fn load_from_disk(&mut self, origin: LoadOrigin) -> Result<usize> {
1784 // Both writes below are crash recovery, and only an open is entitled to
1785 // perform them. A read-only handle promises to touch nothing, and a
1786 // reload driven by `refresh` is looking at a store another process is
1787 // actively writing: what looks like a torn tail there is a peer
1788 // mid-append, and what looks like an orphaned archive may be one that
1789 // peer is about to reference.
1790 let may_repair = origin == LoadOrigin::Open && !self.open_opts.read_only;
1791 let repair_wal = self.open_opts.repair_wal && may_repair;
1792 let db = self;
1793 db.wal_horizon_floor = db.fs.read_horizon_floor()?;
1794 db.archive_genesis_chain = db.fs.has_genesis_marker();
1795 // Opening cleanup: remove orphaned archives — archives whose frames all
1796 // fall below the horizon floor. Orphans arise when a crash interrupted
1797 // the retention-prune sequence after the floor was written but before
1798 // all surplus archives were deleted. Safe to delete: floor already
1799 // accounts for their frames.
1800 if may_repair {
1801 db.cleanup_orphaned_archives()?;
1802 }
1803 let _t0 = std::time::Instant::now();
1804 // Peek 6 bytes to determine snapshot version without reading the full
1805 // file. For RealFs this is a true partial read (O(1)); for SimFs the
1806 // default impl reads all bytes and truncates (still correct).
1807 let snap_header = db.fs.read_prefix(FileId::Snapshot, 6)?;
1808 let is_v8 = snap_header.len() >= 6
1809 && &snap_header[0..4] == b"GDB1"
1810 && u16::from_le_bytes([snap_header[4], snap_header[5]])
1811 == core_storage::snapshot::VERSION_8;
1812 if is_v8 {
1813 // V8: map the file zero-copy (RealFs) or read full bytes (SimFs).
1814 // No 2.4GB heap Vec is allocated on RealFs.
1815 let mapped = Arc::new(
1816 if let Some(snap_path) = db.fs.snapshot_path() {
1817 core_storage::v8::MappedBase::map(&snap_path)
1818 } else {
1819 let snap_bytes = db.fs.read(FileId::Snapshot)?;
1820 core_storage::v8::MappedBase::from_bytes(snap_bytes)
1821 }
1822 .map_err(|e| GraphError::Corrupt {
1823 detail: format!("v8: mmap open: {e:?}"),
1824 })?,
1825 );
1826 db.restore_v8_base(Arc::clone(&mapped))?;
1827 trace_open!("restore_v8_base", _t0);
1828 db.base = Some(mapped);
1829 trace_open!("base assigned", _t0);
1830 } else if !snap_header.is_empty() {
1831 // Legacy V5-V7: full read required for decode.
1832 let snap_bytes = db.fs.read(FileId::Snapshot)?;
1833 if let Some(state) = core_storage::snapshot::decode(&snap_bytes)? {
1834 db.restore_snapshot_state(state)?;
1835 }
1836 }
1837 // else: snap_header is empty = no snapshot file, fresh store.
1838 //
1839 // Seed commit_seq from the highest seq persisted in last_change so that
1840 // WAL-replay frames (which start at commit_seq+1) always exceed any seq
1841 // already stored in the snapshot. Without this, a db with one snapshot
1842 // commit would save last_change["a"]=1, then on reopen the first WAL
1843 // frame would replay at seq=1 again — colliding and making WAL-tail
1844 // mutations indistinguishable from the snapshot baseline.
1845 //
1846 // Safety invariant (seq-recycling):
1847 // Recycled seqs (those below the seeded baseline) were NEVER stored in
1848 // last_change because they belonged to a previous db lifetime — a new
1849 // db starts at commit_seq=0 with an empty last_change. Therefore no
1850 // CAS precondition can carry a recycled seq as its `expected` value
1851 // and accidentally match a live node's last_change entry.
1852 //
1853 // `expected:0` on a deleted-then-reinserted node:
1854 // After deletion, last_changed() returns None; callers that call
1855 // last_changed() and then use NodeUnchangedSince get None.unwrap_or(0)
1856 // = 0. The reinserted node gets seq > 0, so a subsequent CAS with
1857 // expected=0 correctly conflicts. The only way to observe actual=0 in
1858 // a CasConflict would be a caller that invented expected=0 without ever
1859 // calling last_changed() — unreachable via the documented API contract.
1860 if let Some(&max_seq) = db.last_change.values().max() {
1861 db.commit_seq = db.commit_seq.max(max_seq);
1862 }
1863 let bytes = db.fs.read(FileId::Wal)?;
1864 let (records, valid_len) = decode_all(&bytes);
1865 // The valid prefix is replayed either way; `repair_wal` only decides
1866 // whether the truncation is written back. A reader that races a live
1867 // appender must not persist a truncation the writer never asked for.
1868 if valid_len < bytes.len() && repair_wal {
1869 db.fs.write_atomic(FileId::Wal, &bytes[..valid_len])?;
1870 }
1871 // WAL-present path: build indexes eagerly BEFORE replay so that the
1872 // first replayed record does not trigger the lazy-init guard (which
1873 // would call reindex_all_load_ivf on an empty graph, defeating the
1874 // point of restoring IVF/HNSW blobs from the snapshot).
1875 if !records.is_empty() {
1876 db.ensure_v8_base_sections_loaded();
1877 trace_open!("lazy sections loaded (WAL path)", _t0);
1878 }
1879 let replayed = db.apply_frames(records)?;
1880 // The cursor sits at the end of the valid prefix, not the end of the
1881 // file: a torn or still-being-written tail is unconsumed by definition
1882 // and stays visible to `is_stale` until it decodes.
1883 db.wal_consumed = valid_len as u64;
1884 db.snapshot_ident = db.fs.snapshot_ident().map_err(GraphError::Io)?;
1885 trace_open!("wal replay done", _t0);
1886 // Rebuild view values after WAL replay only when there is no V8 base.
1887 // With a V8 base, view values are correct in the snapshot and are updated
1888 // incrementally during WAL replay (on_edge_changed / on_prop_changed).
1889 // A full rebuild would read overlay-only props (empty after restore_v8_base)
1890 // and overwrite correct base values with wrong results (e.g. NeighborAgg
1891 // Sum reads no "score" in overlay → writes 0.0, shadowing the correct
1892 // base value).
1893 if db.base.is_none() {
1894 let topo_view = TopologyView::owned(&db.topo);
1895 db.view_store
1896 .rebuild_all(&mut db.props, &topo_view, &db.ids, &db.syms, &db.labels);
1897 }
1898 // Rebuild full-text index after WAL replay. Corrects drift from
1899 // per-record incremental apply during replay.
1900 db.fulltext.rebuild_all(
1901 &db.ids,
1902 &db.labels,
1903 &db.syms,
1904 build_props_view(&db.props, &db.base),
1905 );
1906 db.prop_index.rebuild_all(
1907 &db.ids,
1908 &db.labels,
1909 &db.syms,
1910 build_props_view(&db.props, &db.base),
1911 );
1912 // Load roles sidecar. Missing file = no roles (Some(vec![])).
1913 // Corrupt/unparseable = poisoned (None); mask_for_role will fail-loud.
1914 db.roles = Self::load_roles_from_fs(&db.fs)?;
1915 // Capture the initial MVCC fold so reader() is ready immediately.
1916 db.fold_now();
1917 trace_open!("open_with complete", _t0);
1918 Ok(replayed)
1919 }
1920
1921 /// Apply decoded WAL frames to in-memory state, exactly as the open-path
1922 /// replay does — same `apply` calls, same per-frame delta drain, same
1923 /// commit-seq and last-change bookkeeping. Rules therefore fire and derived
1924 /// edges appear identically whether a frame arrives at open, from a local
1925 /// commit, or from another process by way of [`refresh`](GraphDb::refresh).
1926 ///
1927 /// Returns the number of frames applied.
1928 ///
1929 /// Deltas are drained and discarded per frame: replayed frames are already
1930 /// reflected on disk, so they are not news to a subscriber, and draining
1931 /// inside the loop keeps `pending_deltas` O(1) over a large WAL (I-2).
1932 fn apply_frames(&mut self, records: Vec<WalRecord>) -> Result<usize> {
1933 if records.is_empty() {
1934 return Ok(0);
1935 }
1936 // Materialize any state retained in the mmap base before the first
1937 // frame lands, so a replayed record cannot trip the lazy-init guard and
1938 // rebuild indexes from an empty graph. Both calls are idempotent.
1939 self.ensure_v8_base_sections_loaded();
1940 self.engine.consume_retained_state_eager(
1941 &self.ids,
1942 &self.syms,
1943 &self.labels,
1944 build_props_view(&self.props, &self.base),
1945 );
1946 let applied = records.len();
1947 for rec in records {
1948 self.apply(&rec)?;
1949 let _ = self.engine.drain_deltas();
1950 // Track commit_seq during replay so last_change entries are
1951 // consistent with the seqs assigned by log_then_apply_with on
1952 // subsequent live commits. After N replayed frames, commit_seq=N;
1953 // live commits begin at N+1.
1954 self.commit_seq += 1;
1955 let replay_seq = self.commit_seq;
1956 self.update_last_change_from_rec(&rec, replay_seq);
1957 }
1958 // Enforce I-2: if the per-frame drain above is ever removed or skipped,
1959 // this assert catches the regression in debug builds immediately.
1960 debug_assert_eq!(
1961 self.engine.pending_delta_count(),
1962 0,
1963 "pending_deltas non-empty after replay — \
1964 per-frame drain must run inside the loop to keep memory O(1)"
1965 );
1966 // T2 note: the per-frame drain IS the suppression seam for replay.
1967 // Any future as-of replay path (Plan-15 T2) must drain here to feed
1968 // replaying subscribers; the mechanism is already in place.
1969 let _ = self.engine.drain_deltas(); // belt-and-braces no-op after loop drain
1970 Ok(applied)
1971 }
1972
1973 // ── Multi-process safety: cross-process write lock + WAL tailing ──────────
1974 //
1975 // mushroomdb is many-readers / one-writer across processes. Writers take an
1976 // advisory exclusive lock on the store's `LOCK` file; readers never do.
1977 // Every handle tracks how much of the WAL it has consumed, so it can pick
1978 // up another process's commits by decoding only the new tail rather than
1979 // reopening. See `docs/site/concurrency.md`.
1980
1981 /// Whether the store on disk has moved ahead of (or out from under) this
1982 /// handle's in-memory state.
1983 ///
1984 /// True when the WAL's length differs from this handle's cursor — another
1985 /// process committed, or is mid-append — or when the snapshot file's
1986 /// identity changed. Costs two metadata lookups and reads no file contents,
1987 /// so it is cheap enough for a read path to call.
1988 ///
1989 /// Always false for an as-of view from [`GraphDb::open_at`]: such a view is
1990 /// pinned to one commit and later commits are deliberately invisible to it.
1991 pub fn is_stale(&self) -> Result<bool> {
1992 if self.pinned {
1993 return Ok(false);
1994 }
1995 if self.fs.wal_len().map_err(GraphError::Io)? != self.wal_consumed {
1996 return Ok(true);
1997 }
1998 Ok(self.fs.snapshot_ident().map_err(GraphError::Io)? != self.snapshot_ident)
1999 }
2000
2001 /// Bring this handle up to date with every commit other processes have made,
2002 /// and return how many frames were applied.
2003 ///
2004 /// The WAL tail is decoded from this handle's cursor and applied through the
2005 /// same path the open replay uses, so rules fire and derived edges appear
2006 /// exactly as they would on a fresh open. Interners, id maps and indexes
2007 /// stay valid for the same reason.
2008 ///
2009 /// A frame another process is still writing is left alone: a trailing
2010 /// partial frame is a wait, not a corruption, and the handle stays stale
2011 /// until that frame is complete. Nothing is written to disk, so a read-only
2012 /// handle can refresh freely.
2013 ///
2014 /// When the snapshot file's identity changed, or the WAL is shorter than
2015 /// this handle's cursor, the WAL no longer continues our state — another
2016 /// process snapshotted or archived. The handle is then rebuilt from disk
2017 /// with the options it was opened with, and the return value is the number
2018 /// of frames in the new WAL.
2019 ///
2020 /// Returns 0 for an as-of view, which never follows later commits.
2021 ///
2022 /// # Errors
2023 ///
2024 /// An error here leaves the handle **degraded**: it got partway through
2025 /// applying the tail, or partway through a reload, so its in-memory state
2026 /// no longer matches any point on disk. Further mutations are refused and
2027 /// the handle must be reopened. Nothing on disk was damaged — the store
2028 /// itself is fine, and a fresh open recovers it.
2029 pub fn refresh(&mut self) -> Result<u64> {
2030 if self.pinned {
2031 return Ok(0);
2032 }
2033 let disk_ident = self.fs.snapshot_ident().map_err(GraphError::Io)?;
2034 let wal_len = self.fs.wal_len().map_err(GraphError::Io)?;
2035 if disk_ident != self.snapshot_ident || wal_len < self.wal_consumed {
2036 // The WAL no longer continues our state: rebuild from disk. State
2037 // is cleared first, so a failed load leaves an empty handle — mark
2038 // it degraded rather than let a caller read an empty graph as if
2039 // it were the store's contents.
2040 self.reset_for_reload();
2041 return match self.load_from_disk(LoadOrigin::Reload) {
2042 Ok(frames) => Ok(frames as u64),
2043 Err(e) => {
2044 self.degraded = true;
2045 Err(e)
2046 }
2047 };
2048 }
2049 if wal_len == self.wal_consumed {
2050 return Ok(0);
2051 }
2052 let tail = self
2053 .fs
2054 .read_range(FileId::Wal, self.wal_consumed)
2055 .map_err(GraphError::Io)?;
2056 let (records, valid_len) = decode_all(&tail);
2057 let applied = match self.apply_frames(records) {
2058 Ok(n) => n,
2059 Err(e) => {
2060 // Some frames landed and some did not, and the cursor cannot
2061 // say how many. Advancing it would skip the rest; leaving it
2062 // would replay what already applied. Neither is recoverable in
2063 // place, so refuse further writes and require a reopen.
2064 self.degraded = true;
2065 return Err(e);
2066 }
2067 };
2068 // Advance by the bytes actually decoded, never by the file length: an
2069 // incomplete trailing frame stays unconsumed for the next refresh.
2070 self.wal_consumed += valid_len as u64;
2071 if applied > 0 {
2072 // Peer commits must reach `reader()` snapshots taken from here on.
2073 // A full fold is what open does; refresh does not build per-commit
2074 // deltas, so there is nothing cheaper that stays correct.
2075 self.fold_now();
2076 }
2077 Ok(applied as u64)
2078 }
2079
2080 /// Byte offset of the WAL prefix this handle has applied.
2081 ///
2082 /// Exposed for tests that assert the cursor tracks appended bytes exactly.
2083 #[doc(hidden)]
2084 pub fn wal_consumed(&self) -> u64 {
2085 self.wal_consumed
2086 }
2087
2088 /// Rewind the WAL cursor after the group-commit drain thread truncated a
2089 /// failed group off the tail, so the cursor still describes the file.
2090 pub(crate) fn set_wal_consumed(&mut self, len: u64) {
2091 self.wal_consumed = len;
2092 }
2093
2094 /// One non-blocking attempt at the cross-process write lock.
2095 ///
2096 /// Takes `&self` so a caller can poll for the lock *before* it acquires the
2097 /// in-process write guard. That ordering is what keeps a busy peer in
2098 /// another process from stalling this process's readers.
2099 ///
2100 /// A handle that owns the lock for its lifetime always succeeds.
2101 pub(crate) fn try_cross_process_lock(&self) -> Result<bool> {
2102 if self.holds_lifetime_lock {
2103 return Ok(true);
2104 }
2105 self.fs.try_lock_exclusive().map_err(GraphError::Io)
2106 }
2107
2108 /// Poll for the cross-process write lock until `wait` elapses.
2109 ///
2110 /// One attempt is always made, so a zero wait is a single try. Returns
2111 /// `false` when the lock is still held elsewhere at the deadline; nothing
2112 /// has been written and retrying later is safe.
2113 ///
2114 /// Only the plain-`GraphDb` open path uses this, where the caller owns the
2115 /// handle outright. [`SharedDb`](crate::SharedDb) polls
2116 /// [`try_cross_process_lock`](GraphDb::try_cross_process_lock) itself so
2117 /// that it holds no in-process guard while it waits.
2118 fn poll_lock(&self, wait: std::time::Duration) -> Result<bool> {
2119 let deadline = std::time::Instant::now() + wait;
2120 loop {
2121 if self.try_cross_process_lock()? {
2122 return Ok(true);
2123 }
2124 let now = std::time::Instant::now();
2125 if now >= deadline {
2126 return Ok(false);
2127 }
2128 std::thread::sleep(LOCK_POLL_INTERVAL.min(deadline.saturating_duration_since(now)));
2129 }
2130 }
2131
2132 /// Open a cross-process write scope, given the outcome of an already-made
2133 /// lock attempt.
2134 ///
2135 /// The caller polls for the lock first — outside any in-process guard — and
2136 /// passes what it got. On success this refreshes, so the writes about to
2137 /// happen land on top of every other process's commits. On failure the
2138 /// handle refuses WAL-appending mutations and `snapshot()` with
2139 /// [`GraphError::Busy`] until [`end_write_lock`](GraphDb::end_write_lock)
2140 /// closes the scope, so a caller holding a guard cannot write behind
2141 /// another process's back.
2142 ///
2143 /// A handle that already owns the lock for its lifetime skips the refresh:
2144 /// no other process can have written, so there is nothing to pick up.
2145 pub(crate) fn enter_write_scope(&mut self, acquired: bool) -> Result<()> {
2146 self.lock_denied = !acquired;
2147 if !acquired || self.holds_lifetime_lock {
2148 return Ok(());
2149 }
2150 if let Err(e) = self.refresh() {
2151 // Do not hold a lock we cannot use: release it and let the caller
2152 // see the underlying failure.
2153 let _ = self.fs.unlock();
2154 self.lock_denied = true;
2155 return Err(e);
2156 }
2157 Ok(())
2158 }
2159
2160 /// Close a cross-process write scope opened by
2161 /// [`enter_write_scope`](GraphDb::enter_write_scope): release the lock and
2162 /// clear the Busy latch. Safe to call when the lock was never taken.
2163 pub(crate) fn end_write_lock(&mut self) {
2164 self.lock_denied = false;
2165 if !self.holds_lifetime_lock {
2166 // Releasing a lock we do not hold is a no-op; a failure to release
2167 // is reported by the OS closing the descriptor at handle drop.
2168 let _ = self.fs.unlock();
2169 }
2170 }
2171
2172 /// As-of replay for [`GraphDb::open_at`]: snapshot base (only when the
2173 /// snapshot truncated the WAL) plus the first `commit + 1` WAL frames;
2174 /// see [`GraphDb::open_at`] for the semantics. The per-frame drain
2175 /// mirrors `open_with` exactly so pending_delta_count is 0 on exit.
2176 /// Restore all persisted state from a decoded snapshot. Shared by
2177 /// `open_with` and (when the snapshot truncated the WAL) `open_at_with`.
2178 fn restore_snapshot_state(
2179 &mut self,
2180 state: core_storage::snapshot::SnapshotState,
2181 ) -> Result<()> {
2182 self.ids = state.ids;
2183 self.syms = state.syms;
2184 self.topo = state.topo;
2185 self.props = state.props;
2186 self.labels = state.labels;
2187 self.edge_props = state.edge_props;
2188 // Cross-section label integrity for V5/V7 snapshots: same invariants as
2189 // restore_v8_base. A crafted bincode snapshot with a short `labels` vec,
2190 // out-of-range sym ids, or a sentinel label on a live node would otherwise
2191 // open successfully and panic later in `NodeRef::label()` or
2192 // `neighborhood_masked()`. Catching it here turns those into typed
2193 // `GraphError::Corrupt` at open time.
2194 {
2195 let ids_len = self.ids.len();
2196 if self.labels.len() != ids_len {
2197 return Err(GraphError::Corrupt {
2198 detail: format!(
2199 "snapshot: labels vec has {} entries but id table has {} total slots",
2200 self.labels.len(),
2201 ids_len,
2202 ),
2203 });
2204 }
2205 let syms_len = self.syms.len() as u32;
2206 for (i, &sym) in self.labels.iter().enumerate() {
2207 let is_tombstoned = self.ids.is_tombstoned(i as u32);
2208 if sym == u32::MAX {
2209 if !is_tombstoned {
2210 return Err(GraphError::Corrupt {
2211 detail: format!(
2212 "snapshot: live node at id slot {i} has sentinel label (u32::MAX)"
2213 ),
2214 });
2215 }
2216 } else if sym >= syms_len {
2217 return Err(GraphError::Corrupt {
2218 detail: format!(
2219 "snapshot: label at id slot {i} references sym {sym} \
2220 which is out of interner range ({syms_len})"
2221 ),
2222 });
2223 }
2224 }
2225 }
2226 let defs: Vec<RuleDef> = state
2227 .rule_defs
2228 .iter()
2229 .map(|b| {
2230 decode_rule_def(b).map_err(|e| GraphError::Corrupt {
2231 detail: format!("snapshot rule_def deserialize: {e}"),
2232 })
2233 })
2234 .collect::<Result<Vec<_>>>()?;
2235 self.engine =
2236 RuleEngine::from_persist(defs, state.provenance, state.rule_tripped, state.rule_fires);
2237 // Candidate indexes are rebuilt lazily on the first mutation (see
2238 // RuleEngine::on_node_changed). HNSW blobs and IVF centroids from the
2239 // snapshot are retained without deserializing so that:
2240 // - clean-open (empty WAL): indexes stay empty; blobs load on first
2241 // ANN query via ensure_hnsw_loaded, or on first mutation via the
2242 // lazy-init guard which calls reindex_all_load_ivf + load_hnsw_state.
2243 // - WAL-present: open_with calls consume_retained_state_eager before
2244 // replay so HNSW/IVF are live before any record fires the hooks.
2245 let ivf_bytes = if state.ivf_state.is_empty() {
2246 Vec::new()
2247 } else {
2248 bincode::serialize(&state.ivf_state).expect("IVF state serialize cannot fail")
2249 };
2250 // Store blobs without eagerly deserializing them.
2251 self.engine
2252 .store_snapshot_state(state.hnsw_state, ivf_bytes);
2253 // Restore view defs from snapshot (V5).
2254 // The ColumnStore already contains view values from the snapshot;
2255 // use restore_view (no collision check, no backfill) so the store
2256 // is aware of the definitions. rebuild_all runs after WAL replay.
2257 for def_bytes in &state.view_defs {
2258 let def: ViewDef =
2259 bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
2260 detail: format!("snapshot view_def deserialize: {e}"),
2261 })?;
2262 self.view_store
2263 .restore_view(def)
2264 .map_err(|e| GraphError::Corrupt {
2265 detail: format!("snapshot view restore: {e}"),
2266 })?;
2267 }
2268 Ok(())
2269 }
2270
2271 /// Restore all persisted state from a V8 `MappedBase` snapshot, **except**
2272 /// topology (`self.topo` stays empty and serves as the WAL-replay overlay).
2273 ///
2274 /// `self.props` IS fully materialised from the base so that HNSW/IVF blob
2275 /// deserialization and view rebuild have access to all column data.
2276 fn restore_v8_base(&mut self, mapped: Arc<core_storage::v8::MappedBase>) -> Result<()> {
2277 self.ids = archived_to_idmap(mapped.ids().map_err(|e| GraphError::Corrupt {
2278 detail: format!("v8: ids section: {e:?}"),
2279 })?);
2280 self.syms = archived_to_interner(mapped.syms().map_err(|e| GraphError::Corrupt {
2281 detail: format!("v8: syms section: {e:?}"),
2282 })?);
2283
2284 // C1: self.props is left as an empty overlay. Column reads go through
2285 // props_view() (ColumnsView::with_base), which consults the archived base
2286 // section zero-copy. This avoids the O(columns) heap copy at every open.
2287
2288 // self.topo deliberately left as Topology::new() — overlay path.
2289
2290 let meta = decode_meta(mapped.meta_bytes().map_err(|e| GraphError::Corrupt {
2291 detail: format!("v8: meta section: {e:?}"),
2292 })?)
2293 .map_err(|e| GraphError::Corrupt {
2294 detail: format!("v8: meta decode: {e:?}"),
2295 })?;
2296 self.labels = meta.labels;
2297 // Cross-section label integrity: labels must cover every id slot (live
2298 // and tombstoned), every non-sentinel sym must be within the interner's
2299 // bound, and no live (non-tombstoned) node may carry the u32::MAX
2300 // sentinel label. Without this check, a crafted snapshot where the META
2301 // section (small, CRC-validated) holds a short `labels` vec, out-of-range
2302 // sym ids, or a sentinel label on a live node, would open successfully
2303 // and then panic in `NodeRef::label()`, `neighborhood_masked()`, and
2304 // related read paths. Catching the inconsistency here converts those
2305 // panics into typed `GraphError::Corrupt` at open time.
2306 {
2307 let ids_len = self.ids.len();
2308 if self.labels.len() != ids_len {
2309 return Err(GraphError::Corrupt {
2310 detail: format!(
2311 "v8: labels section has {} entries but id table has {} total slots",
2312 self.labels.len(),
2313 ids_len,
2314 ),
2315 });
2316 }
2317 let syms_len = self.syms.len() as u32;
2318 for (i, &sym) in self.labels.iter().enumerate() {
2319 let is_tombstoned = self.ids.is_tombstoned(i as u32);
2320 if sym == u32::MAX {
2321 // Sentinel is only valid for tombstoned slots.
2322 if !is_tombstoned {
2323 return Err(GraphError::Corrupt {
2324 detail: format!(
2325 "v8: live node at id slot {i} has sentinel label (u32::MAX)"
2326 ),
2327 });
2328 }
2329 } else if sym >= syms_len {
2330 return Err(GraphError::Corrupt {
2331 detail: format!(
2332 "v8: label at id slot {i} references sym {sym} \
2333 which is out of interner range ({syms_len})"
2334 ),
2335 });
2336 }
2337 }
2338 }
2339 // C3: self.edge_props stays as an empty overlay. Reads go through
2340 // edge_props_view() which consults the mmap'd base section zero-copy
2341 // via EdgePropsView::with_base. No heap decode at open time.
2342
2343 // Restore rule engine.
2344 let (rule_def_bytes, rule_tripped, rule_fires) =
2345 archived_rules_meta_to_owned(mapped.rules_meta_section().map_err(|e| {
2346 GraphError::Corrupt {
2347 detail: format!("v8: rules_meta section: {e:?}"),
2348 }
2349 })?);
2350 let defs: Vec<RuleDef> = rule_def_bytes
2351 .iter()
2352 .map(|b| {
2353 decode_rule_def(b).map_err(|e| GraphError::Corrupt {
2354 detail: format!("v8: rule_def deserialize: {e}"),
2355 })
2356 })
2357 .collect::<Result<Vec<_>>>()?;
2358 self.engine = RuleEngine::from_persist(defs, BTreeMap::new(), rule_tripped, rule_fires);
2359 // C4+C5: provenance, HNSW, and IVF sections are NOT read here.
2360 // `ensure_v8_base_sections_loaded` reads them on first use from
2361 // `self.base` (set by the caller immediately after this returns).
2362 // A clean open touches only: header + IDS + SYMS + META + RULES_META.
2363
2364 // Restore view definitions.
2365 let view_defs =
2366 archived_views_to_owned(mapped.views_section().map_err(|e| GraphError::Corrupt {
2367 detail: format!("v8: views section: {e:?}"),
2368 })?);
2369 for def_bytes in &view_defs {
2370 let def: ViewDef =
2371 bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
2372 detail: format!("v8: view_def deserialize: {e}"),
2373 })?;
2374 self.view_store
2375 .restore_view(def)
2376 .map_err(|e| GraphError::Corrupt {
2377 detail: format!("v8: view restore: {e}"),
2378 })?;
2379 }
2380 // Load the last-change map from section 11 (small section; load eagerly).
2381 // Pre-Task-3 snapshots lack this section; `last_change_bytes` returns &[]
2382 // in that case and `decode_last_change_bytes` returns an empty map.
2383 let last_change_raw = mapped
2384 .last_change_bytes()
2385 .map_err(|e| GraphError::Corrupt {
2386 detail: format!("v8: last_change section: {e:?}"),
2387 })?;
2388 self.last_change = decode_last_change_bytes(last_change_raw);
2389
2390 // Validate that all deferred sections (provenance, HNSW, IVF) fit within
2391 // the file. Pure bounds check — no bytes read, no page faults triggered.
2392 // Catches truncated snapshots at open time before the lazy deferred reads.
2393 mapped.validate_section_bounds().map_err(|e| match e {
2394 GraphError::Corrupt { detail } => GraphError::Corrupt {
2395 detail: format!("v8: section bounds: {detail}"),
2396 },
2397 other => other,
2398 })?;
2399 Ok(())
2400 }
2401
2402 /// Read provenance, HNSW, and IVF sections from the mmap base into the
2403 /// engine's retained fields on first call. Subsequent calls are a no-op
2404 /// (AtomicBool fast-path).
2405 ///
2406 /// Must be called before any code path that reads or mutates engine
2407 /// provenance, HNSW, or IVF state:
2408 /// - WAL replay (before `consume_retained_state_eager`)
2409 /// - First mutation (`log_then_apply_with`)
2410 /// - Read-only paths (`stats`, `explain`, `node_edges`)
2411 /// - Snapshot (`snapshot_with`)
2412 ///
2413 /// No-op for fresh stores and V5-V7 opens (`self.base` is `None`).
2414 fn ensure_v8_base_sections_loaded(&self) {
2415 use std::sync::atomic::Ordering;
2416 if self.v8_sections_loaded.load(Ordering::Acquire) {
2417 return;
2418 }
2419 let _guard = self
2420 .v8_sections_mutex
2421 .lock()
2422 .expect("v8 sections mutex poisoned");
2423 if self.v8_sections_loaded.load(Ordering::Acquire) {
2424 return; // another caller populated while we waited
2425 }
2426 let _t = std::time::Instant::now();
2427 if let Some(base) = &self.base {
2428 // Provenance: raw rkyv bytes; CRC validated inside section_bytes.
2429 // Bounds are already validated at open time (restore_v8_base →
2430 // validate_section_bounds) — unreachable post-validate_section_bounds;
2431 // unwrap_or_default is a safety belt against impossible errors.
2432 let prov_bytes = base
2433 .provenance_raw_bytes()
2434 .map(|b| b.to_vec())
2435 .unwrap_or_default();
2436 self.engine.store_provenance_bytes(prov_bytes);
2437 // HNSW: decode rkyv blobs into owned map.
2438 let hnsw_state = base
2439 .hnsw_section()
2440 .map(archived_hnsw_to_owned)
2441 .unwrap_or_default();
2442 // IVF: raw bincode bytes; deserialized on first mutation/query.
2443 let ivf_bytes = base.ivf_bytes().map(|b| b.to_vec()).unwrap_or_default();
2444 self.engine.store_snapshot_state(hnsw_state, ivf_bytes);
2445 }
2446 self.v8_sections_loaded.store(true, Ordering::Release);
2447 if std::env::var("MUSHROOMDB_TRACE_OPEN").is_ok() {
2448 eprintln!(
2449 "[MUSHROOMDB_TRACE_OPEN] ensure_v8_base_sections_loaded: {:>9.3?}",
2450 _t.elapsed()
2451 );
2452 }
2453 }
2454
2455 /// Return a `TopologyView` that merges the mmap'd base (when present) with
2456 /// the in-memory WAL overlay. Used by all read paths in db.rs that need
2457 /// the full merged topology without going through `self.view()`.
2458 fn topo_view(&self) -> TopologyView<'_> {
2459 match self.base {
2460 None => TopologyView::owned(&self.topo),
2461 Some(ref base) => {
2462 // SAFETY: base lives as long as self; section bounds validated at open.
2463 // topology() uses access_unchecked; all field reads are bounds-checked in seam.rs.
2464 let archived = base
2465 .topology()
2466 .expect("base topology section bounds validated at open");
2467 TopologyView::with_base(&self.topo, archived)
2468 }
2469 }
2470 }
2471
2472 /// Return a `ColumnsView` that merges the mmap'd base columns (when a V8
2473 /// snapshot is open) with the in-memory WAL overlay. Reads consult the
2474 /// overlay first, then fall through to the archived base section zero-copy.
2475 fn props_view(&self) -> core_storage::v8::seam::ColumnsView<'_> {
2476 match self.base {
2477 None => core_storage::v8::seam::ColumnsView::owned(&self.props),
2478 Some(ref base) => {
2479 // columns() uses access_unchecked; field reads are bounds-checked in seam.rs.
2480 let archived = base
2481 .columns()
2482 .expect("base columns section bounds validated at open");
2483 core_storage::v8::seam::ColumnsView::with_base_cached(
2484 &self.props,
2485 archived,
2486 base.mixed_cache(),
2487 )
2488 }
2489 }
2490 }
2491
2492 /// Return an `EdgePropsView` that merges the mmap'd base edge-props section
2493 /// (when a V8 snapshot is open) with the in-memory WAL overlay.
2494 ///
2495 /// Reads consult the overlay first (for post-snapshot mutations), then fall
2496 /// through to the archived base section zero-copy. Tombstones in the
2497 /// overlay mask deleted-from-base entries.
2498 fn edge_props_view(&self) -> EdgePropsView<'_> {
2499 match self.base {
2500 None => EdgePropsView::owned(&self.edge_props),
2501 Some(ref base) => {
2502 // edge_props_section() uses access_unchecked; field reads bounds-checked in seam.rs.
2503 let archived = base
2504 .edge_props_section()
2505 .expect("base edge_props section bounds validated at open");
2506 EdgePropsView::with_base(&self.edge_props, archived)
2507 }
2508 }
2509 }
2510
2511 fn open_at_with(fs: F, commit: u64) -> Result<Self> {
2512 // An as-of view never writes and is pinned to one commit: it takes no
2513 // cross-process lock and does not follow later commits.
2514 let mut db = Self::new_empty(
2515 fs,
2516 OpenOptions {
2517 repair_wal: false,
2518 auto_migrate: false,
2519 read_only: true,
2520 },
2521 );
2522 db.pinned = true; // read_only is set after replay, but pinning is immediate
2523 db.wal_horizon_floor = db.fs.read_horizon_floor()?;
2524 db.archive_genesis_chain = db.fs.has_genesis_marker();
2525 // Same orphaned-archive cleanup as open_with: floor was written first
2526 // during pruning, so a crash may have left stale archives below floor.
2527 db.cleanup_orphaned_archives()?;
2528 // Collect archive frames (oldest-first) and live WAL frames.
2529 // Archives represent pre-snapshot history; the snapshot captures the
2530 // cumulative state at the time of archiving. Crash-window guarantee:
2531 // A: crash before rename → WAL intact, no archive. Reopen: normal.
2532 // B: crash after rename, before new WAL → archive present, WAL
2533 // absent. Reopen: snapshot loaded (full state), no WAL replay.
2534 // C: crash after new baseline WAL written → normal post-archive.
2535 let archive_ns = db.fs.list_archives()?;
2536 let mut archive_frames_all: Vec<WalRecord> = Vec::new();
2537 for n in &archive_ns {
2538 let arc_bytes = db.fs.read_archive(*n)?;
2539 let (arc_frames, _) = decode_all(&arc_bytes);
2540 archive_frames_all.extend(arc_frames);
2541 }
2542 let total_archive_frames = archive_frames_all.len() as u64;
2543
2544 let live_bytes = db.fs.read(FileId::Wal)?;
2545 let (live_records, _valid_len) = decode_all(&live_bytes);
2546 let total_surviving = total_archive_frames + live_records.len() as u64;
2547 // Global total including any pruned history below the horizon floor.
2548 let total = db.wal_horizon_floor + total_surviving;
2549
2550 // Horizon and range check.
2551 if commit < db.wal_horizon_floor {
2552 return Err(GraphError::CommitOutOfRange { commit, total });
2553 }
2554 if commit >= total {
2555 return Err(GraphError::CommitOutOfRange { commit, total });
2556 }
2557
2558 // Local index into surviving frames (0 = first frame of oldest archive).
2559 let local = commit - db.wal_horizon_floor;
2560
2561 if local < total_archive_frames {
2562 // Target commit is in an archive. Correct replay from empty state
2563 // is only possible when the archive chain is an uninterrupted
2564 // genesis chain (first archive taken from a fresh store, no prior
2565 // WAL truncation) and no archives have been pruned (floor == 0).
2566 //
2567 // If either condition is violated the prefix needed to reconstruct
2568 // the requested state is gone; refuse rather than return wrong data.
2569 if db.wal_horizon_floor > 0 || !db.archive_genesis_chain {
2570 return Err(GraphError::CommitOutOfRange { commit, total });
2571 }
2572 // Replay all archive frames up to and including the target commit
2573 // from an empty database state. Archives must be replayed in order
2574 // so that dense-id intern tables are built up correctly.
2575 for rec in archive_frames_all.into_iter().take((local + 1) as usize) {
2576 db.apply(&rec)?;
2577 let _ = db.engine.drain_deltas();
2578 }
2579 } else {
2580 // Target commit is in the live WAL: load snapshot as base, then
2581 // replay the needed live WAL prefix.
2582 //
2583 // Base state: a truncating snapshot (wal_truncated=true) compacts
2584 // all pre-truncation / pre-archive commits. Dense-id records in
2585 // the live WAL reference ids/interns that the snapshot provides.
2586 // Peek 6 bytes (same pattern as open_with).
2587 let snap_header = db.fs.read_prefix(FileId::Snapshot, 6)?;
2588 let is_v8 = snap_header.len() >= 6
2589 && &snap_header[0..4] == b"GDB1"
2590 && u16::from_le_bytes([snap_header[4], snap_header[5]])
2591 == core_storage::snapshot::VERSION_8;
2592 if is_v8 {
2593 let state = if let Some(snap_path) = db.fs.snapshot_path() {
2594 let mapped = core_storage::v8::MappedBase::map(&snap_path).map_err(|e| {
2595 GraphError::Corrupt {
2596 detail: format!("v8: open_at mmap: {e:?}"),
2597 }
2598 })?;
2599 core_storage::snapshot::decode_v8_from_mapped(&mapped)?
2600 } else {
2601 let snap_bytes = db.fs.read(FileId::Snapshot)?;
2602 core_storage::snapshot::decode(&snap_bytes)?
2603 };
2604 if let Some(state) = state {
2605 if state.wal_truncated {
2606 db.restore_snapshot_state(state)?;
2607 }
2608 }
2609 } else if !snap_header.is_empty() {
2610 let snap_bytes = db.fs.read(FileId::Snapshot)?;
2611 if let Some(state) = core_storage::snapshot::decode(&snap_bytes)? {
2612 if state.wal_truncated {
2613 db.restore_snapshot_state(state)?;
2614 }
2615 }
2616 }
2617 // else: snap_header empty = no snapshot file.
2618 let live_local = local - total_archive_frames;
2619 for rec in live_records.into_iter().take((live_local + 1) as usize) {
2620 db.apply(&rec)?;
2621 let _ = db.engine.drain_deltas();
2622 }
2623 }
2624 // Pin: pending_delta_count must be 0 after as-of replay, mirroring T1's
2625 // post-loop assert in open_with.
2626 debug_assert_eq!(
2627 db.engine.pending_delta_count(),
2628 0,
2629 "pending_deltas non-empty after open_at replay — \
2630 per-frame drain must run inside the loop to keep memory O(1)"
2631 );
2632 let _ = db.engine.drain_deltas(); // belt-and-braces no-op
2633 // Rebuild view values after WAL replay so derived-edge-driven views
2634 // reflect the as-of state. open_at always uses the legacy path (no V8
2635 // base), so topo_view is always owned.
2636 {
2637 let topo_view = TopologyView::owned(&db.topo);
2638 db.view_store
2639 .rebuild_all(&mut db.props, &topo_view, &db.ids, &db.syms, &db.labels);
2640 }
2641 // Rebuild full-text index for as-of view (mirrors open_with pattern).
2642 db.fulltext.rebuild_all(
2643 &db.ids,
2644 &db.labels,
2645 &db.syms,
2646 build_props_view(&db.props, &db.base),
2647 );
2648 db.prop_index.rebuild_all(
2649 &db.ids,
2650 &db.labels,
2651 &db.syms,
2652 build_props_view(&db.props, &db.base),
2653 );
2654 // Load roles sidecar (current roles, not point-in-time).
2655 db.roles = Self::load_roles_from_fs(&db.fs)?;
2656 db.read_only = true;
2657 db.total_wal_commits = total;
2658 // Capture initial fold so reader() is immediately usable.
2659 db.fold_now();
2660 Ok(db)
2661 }
2662
2663 /// Whether this instance is a read-only as-of view.
2664 pub fn is_read_only(&self) -> bool {
2665 self.read_only
2666 }
2667
2668 // ── MVCC epoch reader ─────────────────────────────────────────────────────
2669
2670 /// Clone the current overlay state into a new `FrozenOverlay` and reset
2671 /// the delta tail. Called automatically every `FOLD_EVERY_K` commits and at
2672 /// the end of `open_with` / `open_at_with` to prime the reader.
2673 fn fold_now(&mut self) {
2674 let frozen = crate::reader::FrozenOverlay {
2675 ids: self.ids.clone(),
2676 syms: self.syms.clone(),
2677 topo: self.topo.clone(),
2678 props: self.props.clone(),
2679 labels: self.labels.clone(),
2680 edge_props: self.edge_props.clone(),
2681 roles: self.roles.clone(),
2682 fulltext: self.fulltext.clone(),
2683 };
2684 self.fold_overlay = Some(Arc::new(frozen));
2685 self.delta_tail.clear();
2686 self.commits_since_fold = 0;
2687 }
2688
2689 /// Capture a lock-free reader snapshot of the current db state.
2690 ///
2691 /// The read lock is held only for the duration of this call (to clone a
2692 /// handful of `Arc` handles). Subsequent query operations run without any
2693 /// lock.
2694 pub fn reader(&self) -> crate::reader::ReaderSnapshot {
2695 crate::reader::ReaderSnapshot::new(
2696 self.fold_overlay
2697 .clone()
2698 .expect("fold_overlay is always Some after open_with; call reader() after open"),
2699 self.base.clone(),
2700 self.delta_tail.clone(),
2701 )
2702 }
2703
2704 /// Total number of WAL commits at the time [`open_at`] was called.
2705 /// Returns 0 for normal (non-as-of) instances.
2706 pub fn total_wal_commits(&self) -> u64 {
2707 self.total_wal_commits
2708 }
2709
2710 /// Apply a record to in-memory state. Used by both live writes and replay,
2711 /// so replay is definitionally identical to the original execution.
2712 fn apply(&mut self, rec: &WalRecord) -> Result<()> {
2713 match rec {
2714 WalRecord::InsertNode { label, key, props } => {
2715 let id = self.ids.try_insert(key)?;
2716 let sym = self.syms.intern(label);
2717 if self.labels.len() <= id as usize {
2718 // gap slots are sentinels, never valid label symbols
2719 self.labels.resize(id as usize + 1, u32::MAX);
2720 }
2721 self.labels[id as usize] = sym;
2722 for (field, value) in props {
2723 self.props.set(id, field, value.clone());
2724 }
2725 // Initialize view values for the new node before the engine runs so
2726 // delta-based increments start from a known zero baseline.
2727 self.view_store
2728 .init_node_views(id, &mut self.props, &self.syms, &self.labels);
2729 // Fire rules for the newly inserted node.
2730 let cursor = self.engine.pending_delta_count();
2731 let mut eng = std::mem::take(&mut self.engine);
2732 {
2733 let mut gm = make_graph_mut(
2734 &self.ids,
2735 &mut self.syms,
2736 &self.labels,
2737 build_props_view(&self.props, &self.base),
2738 &mut self.topo,
2739 &self.base,
2740 &mut self.edge_props,
2741 );
2742 eng.on_node_changed(id, None, &mut gm);
2743 }
2744 self.engine = eng;
2745 // Process derived-edge deltas for view maintenance.
2746 // Fast path: skip the O(delta_count) allocation when no views exist.
2747 if !self.view_store.is_empty() {
2748 #[cfg(test)]
2749 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2750 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2751 for d in &new_deltas {
2752 self.view_store.on_edge_changed(
2753 d.etype_sym,
2754 d.src_id,
2755 d.dst_id,
2756 d.fired,
2757 &mut self.props,
2758 &build_topo_view(&self.topo, &self.base),
2759 &self.ids,
2760 &self.syms,
2761 &self.labels,
2762 self.base.as_ref().map(|b| {
2763 b.columns()
2764 .expect("base columns section bounds validated at open")
2765 }),
2766 );
2767 }
2768 }
2769 // Full-text index maintenance: index enabled fields for this label.
2770 if self.fulltext.has_label(label) {
2771 for (field, value) in props {
2772 if self.fulltext.is_enabled(label, field) {
2773 self.fulltext.add_tokens(id, field, value);
2774 }
2775 }
2776 }
2777 // Property (equality) index maintenance.
2778 if self.prop_index.has_label(label) {
2779 for (field, value) in props {
2780 self.prop_index.set(label, field, id, value);
2781 }
2782 }
2783 }
2784 WalRecord::InsertEdge {
2785 edge_type,
2786 src_key,
2787 dst_key,
2788 } => {
2789 let src = self.ids.get(src_key).ok_or_else(|| GraphError::Corrupt {
2790 detail: format!("wal replay references unknown key {src_key}"),
2791 })?;
2792 let dst = self.ids.get(dst_key).ok_or_else(|| GraphError::Corrupt {
2793 detail: format!("wal replay references unknown key {dst_key}"),
2794 })?;
2795 let etype = self.syms.intern(edge_type);
2796 // Skip if the edge is already visible in the merged base+overlay
2797 // view. This keeps WAL replay idempotent when the WAL contains
2798 // pre-snapshot records that are already encoded in a V8 base
2799 // (keep_wal=true opens and crash-before-truncation scenarios).
2800 if self.base.is_some()
2801 && self
2802 .topo_view()
2803 .neighbors(etype, Direction::Out, src)
2804 .contains(&dst)
2805 {
2806 return Ok(());
2807 }
2808 self.topo.add_edge(etype, src, dst);
2809 // View maintenance for manual edge insert.
2810 self.view_store.on_edge_changed(
2811 etype,
2812 src,
2813 dst,
2814 true,
2815 &mut self.props,
2816 &build_topo_view(&self.topo, &self.base),
2817 &self.ids,
2818 &self.syms,
2819 &self.labels,
2820 self.base.as_ref().map(|b| {
2821 b.columns()
2822 .expect("base columns section bounds validated at open")
2823 }),
2824 );
2825 // Rule engine: via-hop rules must update when user edges change.
2826 let cursor = self.engine.pending_delta_count();
2827 let mut eng = std::mem::take(&mut self.engine);
2828 {
2829 let mut gm = make_graph_mut(
2830 &self.ids,
2831 &mut self.syms,
2832 &self.labels,
2833 build_props_view(&self.props, &self.base),
2834 &mut self.topo,
2835 &self.base,
2836 &mut self.edge_props,
2837 );
2838 eng.on_edge_changed(edge_type, src, dst, &mut gm);
2839 }
2840 self.engine = eng;
2841 if !self.view_store.is_empty() {
2842 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2843 for d in &new_deltas {
2844 self.view_store.on_edge_changed(
2845 d.etype_sym,
2846 d.src_id,
2847 d.dst_id,
2848 d.fired,
2849 &mut self.props,
2850 &build_topo_view(&self.topo, &self.base),
2851 &self.ids,
2852 &self.syms,
2853 &self.labels,
2854 self.base.as_ref().map(|b| {
2855 b.columns()
2856 .expect("base columns section bounds validated at open")
2857 }),
2858 );
2859 }
2860 }
2861 }
2862 WalRecord::SetProp { key, field, value } => {
2863 let id = self.ids.get(key).ok_or_else(|| GraphError::Corrupt {
2864 detail: format!("wal replay references unknown key {key}"),
2865 })?;
2866 let old_value = build_props_view(&self.props, &self.base)
2867 .get(id, field)
2868 .map(|vr| vr.into_value());
2869 self.props.set(id, field, value.clone());
2870 // Fire rules for the changed field.
2871 let cursor = self.engine.pending_delta_count();
2872 let mut eng = std::mem::take(&mut self.engine);
2873 {
2874 let mut gm = make_graph_mut(
2875 &self.ids,
2876 &mut self.syms,
2877 &self.labels,
2878 build_props_view(&self.props, &self.base),
2879 &mut self.topo,
2880 &self.base,
2881 &mut self.edge_props,
2882 );
2883 eng.on_node_changed(id, Some((field, old_value)), &mut gm);
2884 }
2885 self.engine = eng;
2886 // Derived-edge deltas → view updates.
2887 if !self.view_store.is_empty() {
2888 #[cfg(test)]
2889 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2890 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2891 for d in &new_deltas {
2892 self.view_store.on_edge_changed(
2893 d.etype_sym,
2894 d.src_id,
2895 d.dst_id,
2896 d.fired,
2897 &mut self.props,
2898 &build_topo_view(&self.topo, &self.base),
2899 &self.ids,
2900 &self.syms,
2901 &self.labels,
2902 self.base.as_ref().map(|b| {
2903 b.columns()
2904 .expect("base columns section bounds validated at open")
2905 }),
2906 );
2907 }
2908 }
2909 // Neighbor-aggregate views that read `field` must also update.
2910 self.view_store.on_prop_changed(
2911 id,
2912 field,
2913 &mut self.props,
2914 &build_topo_view(&self.topo, &self.base),
2915 &self.ids,
2916 &self.syms,
2917 &self.labels,
2918 self.base.as_ref().map(|b| {
2919 b.columns()
2920 .expect("base columns section bounds validated at open")
2921 }),
2922 );
2923 // Full-text index maintenance: update tokens for this field if indexed.
2924 if self.fulltext.field_indexed(field) {
2925 let label_opt = self.labels.get(id as usize).and_then(|&sym| {
2926 if sym == u32::MAX {
2927 None
2928 } else {
2929 self.syms.resolve(sym)
2930 }
2931 });
2932 if let Some(label) = label_opt {
2933 if self.fulltext.is_enabled(label, field) {
2934 self.fulltext.remove_node_field(id, field);
2935 self.fulltext.add_tokens(id, field, value);
2936 }
2937 }
2938 }
2939 // Property (equality) index maintenance: re-key this node's value.
2940 if self.prop_index.field_indexed(field) {
2941 let label_opt = self.labels.get(id as usize).and_then(|&sym| {
2942 if sym == u32::MAX {
2943 None
2944 } else {
2945 self.syms.resolve(sym)
2946 }
2947 });
2948 if let Some(label) = label_opt {
2949 self.prop_index.set(label, field, id, value);
2950 }
2951 }
2952 }
2953 WalRecord::Intern { id, text } => {
2954 if let Some(existing) = self.syms.get(text) {
2955 if existing != *id {
2956 return Err(GraphError::Corrupt {
2957 detail: format!(
2958 "wal intern mismatch for {text:?}: have {existing}, record {id}"
2959 ),
2960 });
2961 }
2962 } else {
2963 let got = self.syms.intern(text);
2964 if got != *id {
2965 return Err(GraphError::Corrupt {
2966 detail: format!(
2967 "wal intern assigned {got} for {text:?}, record wanted {id}"
2968 ),
2969 });
2970 }
2971 }
2972 }
2973 WalRecord::InsertNodeId { label, key, props } => {
2974 let id = self.ids.try_insert(key)?;
2975 if self.labels.len() <= id as usize {
2976 self.labels.resize(id as usize + 1, u32::MAX);
2977 }
2978 self.labels[id as usize] = *label;
2979 let label_str = self
2980 .syms
2981 .resolve(*label)
2982 .ok_or_else(|| GraphError::Corrupt {
2983 detail: format!("wal InsertNodeId unknown label intern {label}"),
2984 })?
2985 .to_string();
2986 for (field_sym, value) in props {
2987 let field =
2988 self.syms
2989 .resolve(*field_sym)
2990 .ok_or_else(|| GraphError::Corrupt {
2991 detail: format!(
2992 "wal InsertNodeId unknown field intern {field_sym}"
2993 ),
2994 })?;
2995 self.props.set(id, field, value.clone());
2996 }
2997 self.view_store
2998 .init_node_views(id, &mut self.props, &self.syms, &self.labels);
2999 let cursor = self.engine.pending_delta_count();
3000 let mut eng = std::mem::take(&mut self.engine);
3001 {
3002 let mut gm = make_graph_mut(
3003 &self.ids,
3004 &mut self.syms,
3005 &self.labels,
3006 build_props_view(&self.props, &self.base),
3007 &mut self.topo,
3008 &self.base,
3009 &mut self.edge_props,
3010 );
3011 eng.on_node_changed(id, None, &mut gm);
3012 }
3013 self.engine = eng;
3014 if !self.view_store.is_empty() {
3015 #[cfg(test)]
3016 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3017 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3018 for d in &new_deltas {
3019 self.view_store.on_edge_changed(
3020 d.etype_sym,
3021 d.src_id,
3022 d.dst_id,
3023 d.fired,
3024 &mut self.props,
3025 &build_topo_view(&self.topo, &self.base),
3026 &self.ids,
3027 &self.syms,
3028 &self.labels,
3029 self.base.as_ref().map(|b| {
3030 b.columns()
3031 .expect("base columns section bounds validated at open")
3032 }),
3033 );
3034 }
3035 }
3036 if self.fulltext.has_label(&label_str) {
3037 for (field_sym, value) in props {
3038 let Some(field) = self.syms.resolve(*field_sym) else {
3039 continue;
3040 };
3041 if self.fulltext.is_enabled(&label_str, field) {
3042 self.fulltext.add_tokens(id, field, value);
3043 }
3044 }
3045 }
3046 if self.prop_index.has_label(&label_str) {
3047 for (field_sym, value) in props {
3048 let Some(field) = self.syms.resolve(*field_sym) else {
3049 continue;
3050 };
3051 self.prop_index.set(&label_str, field, id, value);
3052 }
3053 }
3054 }
3055 WalRecord::InsertEdgeId { etype, src, dst } => {
3056 // Replay-over-snapshot: dense ids in the pre-snapshot WAL may
3057 // already be tombstoned. Skip rather than attaching edges to
3058 // dead ids (DeleteNode keys the live re-insert, not the old id).
3059 if self.ids.is_tombstoned(*src)
3060 || self.ids.is_tombstoned(*dst)
3061 || self.ids.key_of(*src).is_none()
3062 || self.ids.key_of(*dst).is_none()
3063 {
3064 return Ok(());
3065 }
3066 // Skip if already visible in the merged view (same idempotency
3067 // guard as InsertEdge above: prevents double-counting when
3068 // pre-snapshot WAL records are replayed over a V8 base).
3069 if self.base.is_some()
3070 && self
3071 .topo_view()
3072 .neighbors(*etype, Direction::Out, *src)
3073 .contains(dst)
3074 {
3075 return Ok(());
3076 }
3077 self.topo.add_edge(*etype, *src, *dst);
3078 self.view_store.on_edge_changed(
3079 *etype,
3080 *src,
3081 *dst,
3082 true,
3083 &mut self.props,
3084 &build_topo_view(&self.topo, &self.base),
3085 &self.ids,
3086 &self.syms,
3087 &self.labels,
3088 self.base.as_ref().map(|b| {
3089 b.columns()
3090 .expect("base columns section bounds validated at open")
3091 }),
3092 );
3093 // Rule engine: via-hop rules fire when user via-edges are inserted.
3094 // Resolve etype back to string so on_edge_changed can match rules by name.
3095 if let Some(etype_str) = self.syms.resolve(*etype).map(|s| s.to_string()) {
3096 let cursor = self.engine.pending_delta_count();
3097 let mut eng = std::mem::take(&mut self.engine);
3098 {
3099 let mut gm = make_graph_mut(
3100 &self.ids,
3101 &mut self.syms,
3102 &self.labels,
3103 build_props_view(&self.props, &self.base),
3104 &mut self.topo,
3105 &self.base,
3106 &mut self.edge_props,
3107 );
3108 eng.on_edge_changed(&etype_str, *src, *dst, &mut gm);
3109 }
3110 self.engine = eng;
3111 if !self.view_store.is_empty() {
3112 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3113 for d in &new_deltas {
3114 self.view_store.on_edge_changed(
3115 d.etype_sym,
3116 d.src_id,
3117 d.dst_id,
3118 d.fired,
3119 &mut self.props,
3120 &build_topo_view(&self.topo, &self.base),
3121 &self.ids,
3122 &self.syms,
3123 &self.labels,
3124 self.base.as_ref().map(|b| {
3125 b.columns()
3126 .expect("base columns section bounds validated at open")
3127 }),
3128 );
3129 }
3130 }
3131 }
3132 }
3133 WalRecord::SetPropId { id, field, value } => {
3134 if self.ids.is_tombstoned(*id) || self.ids.key_of(*id).is_none() {
3135 return Ok(());
3136 }
3137 let field_str = self
3138 .syms
3139 .resolve(*field)
3140 .ok_or_else(|| GraphError::Corrupt {
3141 detail: format!("wal SetPropId unknown field intern {field}"),
3142 })?
3143 .to_string();
3144 let old_value = build_props_view(&self.props, &self.base)
3145 .get(*id, &field_str)
3146 .map(|vr| vr.into_value());
3147 self.props.set(*id, &field_str, value.clone());
3148 let cursor = self.engine.pending_delta_count();
3149 let mut eng = std::mem::take(&mut self.engine);
3150 {
3151 let mut gm = make_graph_mut(
3152 &self.ids,
3153 &mut self.syms,
3154 &self.labels,
3155 build_props_view(&self.props, &self.base),
3156 &mut self.topo,
3157 &self.base,
3158 &mut self.edge_props,
3159 );
3160 eng.on_node_changed(*id, Some((field_str.as_str(), old_value)), &mut gm);
3161 }
3162 self.engine = eng;
3163 if !self.view_store.is_empty() {
3164 #[cfg(test)]
3165 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3166 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3167 for d in &new_deltas {
3168 self.view_store.on_edge_changed(
3169 d.etype_sym,
3170 d.src_id,
3171 d.dst_id,
3172 d.fired,
3173 &mut self.props,
3174 &build_topo_view(&self.topo, &self.base),
3175 &self.ids,
3176 &self.syms,
3177 &self.labels,
3178 self.base.as_ref().map(|b| {
3179 b.columns()
3180 .expect("base columns section bounds validated at open")
3181 }),
3182 );
3183 }
3184 }
3185 self.view_store.on_prop_changed(
3186 *id,
3187 &field_str,
3188 &mut self.props,
3189 &build_topo_view(&self.topo, &self.base),
3190 &self.ids,
3191 &self.syms,
3192 &self.labels,
3193 self.base.as_ref().map(|b| {
3194 b.columns()
3195 .expect("base columns section bounds validated at open")
3196 }),
3197 );
3198 if self.fulltext.field_indexed(&field_str) {
3199 let label_opt = self.labels.get(*id as usize).and_then(|&sym| {
3200 if sym == u32::MAX {
3201 None
3202 } else {
3203 self.syms.resolve(sym)
3204 }
3205 });
3206 if let Some(label) = label_opt {
3207 if self.fulltext.is_enabled(label, &field_str) {
3208 self.fulltext.remove_node_field(*id, &field_str);
3209 self.fulltext.add_tokens(*id, &field_str, value);
3210 }
3211 }
3212 }
3213 if self.prop_index.field_indexed(&field_str) {
3214 let label_opt = self.labels.get(*id as usize).and_then(|&sym| {
3215 if sym == u32::MAX {
3216 None
3217 } else {
3218 self.syms.resolve(sym)
3219 }
3220 });
3221 if let Some(label) = label_opt {
3222 self.prop_index.set(label, &field_str, *id, value);
3223 }
3224 }
3225 }
3226 WalRecord::CreateRule { def_bytes } => {
3227 let def: RuleDef = decode_rule_def(def_bytes).map_err(|e| GraphError::Corrupt {
3228 detail: format!("CreateRule def_bytes deserialize failed: {e}"),
3229 })?;
3230 // Replay-over-snapshot idempotency: the rule was captured in the snapshot
3231 // so the engine already has it; silently skip to avoid a spurious
3232 // RuleInvalid error in the crash window between snapshot write and WAL
3233 // truncation.
3234 if self.engine.rules().any(|r| r.name == def.name) {
3235 return Ok(());
3236 }
3237 let cursor = self.engine.pending_delta_count();
3238 let mut eng = std::mem::take(&mut self.engine);
3239 let result = {
3240 let mut gm = make_graph_mut(
3241 &self.ids,
3242 &mut self.syms,
3243 &self.labels,
3244 build_props_view(&self.props, &self.base),
3245 &mut self.topo,
3246 &self.base,
3247 &mut self.edge_props,
3248 );
3249 eng.create_rule(def, &mut gm)
3250 };
3251 self.engine = eng;
3252 result.map_err(|e| GraphError::RuleInvalid { detail: e })?;
3253 // Derived-edge fires from backfill → view updates.
3254 // Fast path: skip O(edge_count) allocation when no views exist.
3255 if !self.view_store.is_empty() {
3256 #[cfg(test)]
3257 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3258 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3259 for d in &new_deltas {
3260 self.view_store.on_edge_changed(
3261 d.etype_sym,
3262 d.src_id,
3263 d.dst_id,
3264 d.fired,
3265 &mut self.props,
3266 &build_topo_view(&self.topo, &self.base),
3267 &self.ids,
3268 &self.syms,
3269 &self.labels,
3270 self.base.as_ref().map(|b| {
3271 b.columns()
3272 .expect("base columns section bounds validated at open")
3273 }),
3274 );
3275 }
3276 }
3277 }
3278 WalRecord::DeleteRule { name } => {
3279 // Replay-over-snapshot idempotency: the snapshot already captured the
3280 // post-delete state so the rule is absent; silently skip to avoid a
3281 // spurious RuleNotFound error in the crash window between snapshot write
3282 // and WAL truncation.
3283 if !self.engine.rules().any(|r| r.name == *name) {
3284 return Ok(());
3285 }
3286 let cursor = self.engine.pending_delta_count();
3287 let mut eng = std::mem::take(&mut self.engine);
3288 let result = {
3289 let mut gm = make_graph_mut(
3290 &self.ids,
3291 &mut self.syms,
3292 &self.labels,
3293 build_props_view(&self.props, &self.base),
3294 &mut self.topo,
3295 &self.base,
3296 &mut self.edge_props,
3297 );
3298 eng.delete_rule(name, &mut gm)
3299 };
3300 self.engine = eng;
3301 result.map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
3302 // Derived-edge retractions → view updates.
3303 if !self.view_store.is_empty() {
3304 #[cfg(test)]
3305 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3306 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3307 for d in &new_deltas {
3308 self.view_store.on_edge_changed(
3309 d.etype_sym,
3310 d.src_id,
3311 d.dst_id,
3312 d.fired,
3313 &mut self.props,
3314 &build_topo_view(&self.topo, &self.base),
3315 &self.ids,
3316 &self.syms,
3317 &self.labels,
3318 self.base.as_ref().map(|b| {
3319 b.columns()
3320 .expect("base columns section bounds validated at open")
3321 }),
3322 );
3323 }
3324 }
3325 }
3326 WalRecord::RemoveProp { key, field } => {
3327 // Recovery-safe: unknown key or already-absent field is a
3328 // clean no-op. Crash-window replay over a snapshot that
3329 // already applied this record must not Err.
3330 let Some(id) = self.ids.get(key) else {
3331 return Ok(());
3332 };
3333 // Read old value through the seam for rule retraction.
3334 let old = build_props_view(&self.props, &self.base)
3335 .get(id, field)
3336 .map(|vr| vr.into_value());
3337 self.props.remove(id, field);
3338 // If the base still supplies the value after the overlay removal,
3339 // record a tombstone so ColumnsView::get does not resurrect it.
3340 // This covers both the base-only case AND the both-resident case:
3341 // base-only (in_overlay=false): old prop was only in base, remove
3342 // is a no-op on overlay, base still visible → tombstone needed.
3343 // both-resident (in_overlay=true): overlay had v2, base has v1;
3344 // removing overlay uncovers v1 → tombstone needed.
3345 // Idempotent on double-replay: second pass sees the tombstone →
3346 // get() returns None → condition is false → no duplicate tombstone.
3347 if build_props_view(&self.props, &self.base)
3348 .get(id, field)
3349 .is_some()
3350 {
3351 self.props.record_prop_tombstone(id, field);
3352 }
3353 let cursor = self.engine.pending_delta_count();
3354 let mut eng = std::mem::take(&mut self.engine);
3355 {
3356 let mut gm = make_graph_mut(
3357 &self.ids,
3358 &mut self.syms,
3359 &self.labels,
3360 build_props_view(&self.props, &self.base),
3361 &mut self.topo,
3362 &self.base,
3363 &mut self.edge_props,
3364 );
3365 eng.on_node_changed(id, Some((field, old)), &mut gm);
3366 }
3367 self.engine = eng;
3368 // Derived-edge deltas → view updates.
3369 if !self.view_store.is_empty() {
3370 #[cfg(test)]
3371 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3372 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3373 for d in &new_deltas {
3374 self.view_store.on_edge_changed(
3375 d.etype_sym,
3376 d.src_id,
3377 d.dst_id,
3378 d.fired,
3379 &mut self.props,
3380 &build_topo_view(&self.topo, &self.base),
3381 &self.ids,
3382 &self.syms,
3383 &self.labels,
3384 self.base.as_ref().map(|b| {
3385 b.columns()
3386 .expect("base columns section bounds validated at open")
3387 }),
3388 );
3389 }
3390 }
3391 // Neighbor-aggregate views that read `field` must also update.
3392 self.view_store.on_prop_changed(
3393 id,
3394 field,
3395 &mut self.props,
3396 &build_topo_view(&self.topo, &self.base),
3397 &self.ids,
3398 &self.syms,
3399 &self.labels,
3400 self.base.as_ref().map(|b| {
3401 b.columns()
3402 .expect("base columns section bounds validated at open")
3403 }),
3404 );
3405 // Full-text index maintenance: remove tokens for this field.
3406 if self.fulltext.field_indexed(field) {
3407 self.fulltext.remove_node_field(id, field);
3408 }
3409 // Property (equality) index maintenance: drop this node's entry.
3410 if self.prop_index.field_indexed(field) {
3411 if let Some(label) = self.labels.get(id as usize).and_then(|&sym| {
3412 (sym != u32::MAX).then(|| self.syms.resolve(sym)).flatten()
3413 }) {
3414 self.prop_index.remove_node(label, field, id);
3415 }
3416 }
3417 }
3418 WalRecord::DeleteEdge {
3419 edge_type,
3420 src_key,
3421 dst_key,
3422 } => {
3423 // Recovery-safe: unknown keys, unknown etype, or already-
3424 // absent edge is a clean no-op (remove_edge returns false).
3425 let Some(src) = self.ids.get(src_key) else {
3426 return Ok(());
3427 };
3428 let Some(dst) = self.ids.get(dst_key) else {
3429 return Ok(());
3430 };
3431 let Some(etype) = self.syms.get(edge_type) else {
3432 return Ok(());
3433 };
3434 // I3: phantom-tombstone guard. When a V8 base is present, a
3435 // DeleteEdge WAL record for an edge that was already absorbed into
3436 // the new base (i.e. neither in overlay nor in base) must be skipped.
3437 // Without this guard, remove_edge records a tombstone for an edge
3438 // that no longer exists, incorrectly understating edge_count.
3439 if self.base.is_some()
3440 && !self
3441 .topo_view()
3442 .neighbors(etype, core_storage::topology::Direction::Out, src)
3443 .contains(&dst)
3444 {
3445 return Ok(());
3446 }
3447 self.topo.remove_edge(etype, src, dst);
3448 self.edge_props.remove_edge(etype, src, dst);
3449 // View maintenance for manual edge delete (topo already updated above).
3450 self.view_store.on_edge_changed(
3451 etype,
3452 src,
3453 dst,
3454 false,
3455 &mut self.props,
3456 &build_topo_view(&self.topo, &self.base),
3457 &self.ids,
3458 &self.syms,
3459 &self.labels,
3460 self.base.as_ref().map(|b| {
3461 b.columns()
3462 .expect("base columns section bounds validated at open")
3463 }),
3464 );
3465 // Rule engine: via-hop rules must retract when user via-edges are deleted.
3466 let cursor = self.engine.pending_delta_count();
3467 let mut eng = std::mem::take(&mut self.engine);
3468 {
3469 let mut gm = make_graph_mut(
3470 &self.ids,
3471 &mut self.syms,
3472 &self.labels,
3473 build_props_view(&self.props, &self.base),
3474 &mut self.topo,
3475 &self.base,
3476 &mut self.edge_props,
3477 );
3478 eng.on_edge_changed(edge_type, src, dst, &mut gm);
3479 }
3480 self.engine = eng;
3481 if !self.view_store.is_empty() {
3482 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3483 for d in &new_deltas {
3484 self.view_store.on_edge_changed(
3485 d.etype_sym,
3486 d.src_id,
3487 d.dst_id,
3488 d.fired,
3489 &mut self.props,
3490 &build_topo_view(&self.topo, &self.base),
3491 &self.ids,
3492 &self.syms,
3493 &self.labels,
3494 self.base.as_ref().map(|b| {
3495 b.columns()
3496 .expect("base columns section bounds validated at open")
3497 }),
3498 );
3499 }
3500 }
3501 }
3502 WalRecord::DeleteNode { key } => {
3503 // Recovery-safe: already-tombstoned / unknown key is a clean
3504 // no-op. Crash-window replay over a snapshot that already
3505 // applied this record cannot recover the retired id from the
3506 // key (`IdMap::get` is None), so every subsequent step is
3507 // skipped. Each step is independently idempotent if invoked
3508 // twice on a still-live id: retraction is a no-op on empty
3509 // provenance, `remove_edge` returns false, `remove_all` is a
3510 // no-op, `ids.delete` returns None, label sentinel is sticky.
3511 let Some(n) = self.ids.get(key) else {
3512 return Ok(());
3513 };
3514
3515 // (1) Retract derived edges + de-index while props/labels live.
3516 let cursor = self.engine.pending_delta_count();
3517 let mut eng = std::mem::take(&mut self.engine);
3518 {
3519 let mut gm = make_graph_mut(
3520 &self.ids,
3521 &mut self.syms,
3522 &self.labels,
3523 build_props_view(&self.props, &self.base),
3524 &mut self.topo,
3525 &self.base,
3526 &mut self.edge_props,
3527 );
3528 eng.on_node_removed(n, &mut gm);
3529 }
3530 self.engine = eng;
3531 // Derived-edge retractions → view updates for neighbors.
3532 if !self.view_store.is_empty() {
3533 #[cfg(test)]
3534 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3535 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3536 for d in &new_deltas {
3537 self.view_store.on_edge_changed(
3538 d.etype_sym,
3539 d.src_id,
3540 d.dst_id,
3541 d.fired,
3542 &mut self.props,
3543 &build_topo_view(&self.topo, &self.base),
3544 &self.ids,
3545 &self.syms,
3546 &self.labels,
3547 self.base.as_ref().map(|b| {
3548 b.columns()
3549 .expect("base columns section bounds validated at open")
3550 }),
3551 );
3552 }
3553 }
3554
3555 // (2) Sweep ALL remaining edges incident to n, both directions,
3556 // every etype. This cascade is intentionally mask-independent:
3557 // topology integrity requires removing every edge touching the
3558 // deleted node regardless of the caller's visibility scope.
3559 // (The mask limits which nodes a role's read phase can return;
3560 // the WAL delete always executes with full storage authority.)
3561 // Collect then remove so neighbor slices stay valid during
3562 // iteration. Remove from topo first, then call view maintenance
3563 // so Avg/Min/Max recompute sees the correct (reduced) neighbor set.
3564 let etypes: Vec<u32> = self.topo.etypes().collect();
3565 let mut doomed = Vec::new();
3566 for et in &etypes {
3567 for &dst in self.topo.neighbors(*et, Direction::Out, n).as_ref() {
3568 doomed.push((*et, n, dst));
3569 }
3570 for &src in self.topo.neighbors(*et, Direction::In, n).as_ref() {
3571 doomed.push((*et, src, n));
3572 }
3573 }
3574 for (et, s, d) in doomed {
3575 self.topo.remove_edge(et, s, d);
3576 self.edge_props.remove_edge(et, s, d);
3577 // View maintenance: n's own view values will be cleared by
3578 // remove_all below; only update surviving neighbors.
3579 self.view_store.on_edge_changed(
3580 et,
3581 s,
3582 d,
3583 false,
3584 &mut self.props,
3585 &build_topo_view(&self.topo, &self.base),
3586 &self.ids,
3587 &self.syms,
3588 &self.labels,
3589 self.base.as_ref().map(|b| {
3590 b.columns()
3591 .expect("base columns section bounds validated at open")
3592 }),
3593 );
3594 }
3595
3596 // (3) Drop every remaining prop (`ColumnStore::remove_all`).
3597 self.props.remove_all(n);
3598 // Full-text index maintenance: remove all tokens for this node.
3599 self.fulltext.remove_node(n);
3600 // Property (equality) index maintenance: drop all entries for n.
3601 self.prop_index.remove_node_all(n);
3602
3603 // (4) Retire the dense id and stamp the label sentinel.
3604 self.ids.delete(key);
3605 if let Some(slot) = self.labels.get_mut(n as usize) {
3606 *slot = u32::MAX;
3607 }
3608 }
3609 WalRecord::Batch(inner) => {
3610 // Apply each inner record in order through the same apply path.
3611 // Inner records are validated free of nested Batch by encode_record.
3612 for rec in inner {
3613 self.apply(rec)?;
3614 }
3615 }
3616 WalRecord::RebuildRule { name } => {
3617 // Replay-over-snapshot idempotency: the snapshot may already
3618 // reflect a later delete_rule, so the rule is absent; skip.
3619 if !self.engine.rules().any(|r| r.name == *name) {
3620 return Ok(());
3621 }
3622 let cursor = self.engine.pending_delta_count();
3623 let mut eng = std::mem::take(&mut self.engine);
3624 let result = {
3625 let mut gm = make_graph_mut(
3626 &self.ids,
3627 &mut self.syms,
3628 &self.labels,
3629 build_props_view(&self.props, &self.base),
3630 &mut self.topo,
3631 &self.base,
3632 &mut self.edge_props,
3633 );
3634 eng.rebuild(name, &mut gm)
3635 };
3636 self.engine = eng;
3637 result.map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
3638 // Derived-edge delta changes → view updates.
3639 if !self.view_store.is_empty() {
3640 #[cfg(test)]
3641 DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3642 let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3643 for d in &new_deltas {
3644 self.view_store.on_edge_changed(
3645 d.etype_sym,
3646 d.src_id,
3647 d.dst_id,
3648 d.fired,
3649 &mut self.props,
3650 &build_topo_view(&self.topo, &self.base),
3651 &self.ids,
3652 &self.syms,
3653 &self.labels,
3654 self.base.as_ref().map(|b| {
3655 b.columns()
3656 .expect("base columns section bounds validated at open")
3657 }),
3658 );
3659 }
3660 }
3661 }
3662 WalRecord::CreateView { def_bytes } => {
3663 let def: ViewDef =
3664 bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
3665 detail: format!("CreateView def_bytes deserialize failed: {e}"),
3666 })?;
3667 // Replay-over-snapshot idempotency: view already present → skip.
3668 if self.view_store.has_view(&def.name) {
3669 return Ok(());
3670 }
3671 self.view_store
3672 .create_view(
3673 def,
3674 &mut self.props,
3675 &build_topo_view(&self.topo, &self.base),
3676 &self.ids,
3677 &self.syms,
3678 &self.labels,
3679 )
3680 .map_err(|e| GraphError::RuleInvalid { detail: e })?;
3681 }
3682 WalRecord::DeleteView { name } => {
3683 // Replay-over-snapshot idempotency: view already absent → skip.
3684 if !self.view_store.has_view(name) {
3685 return Ok(());
3686 }
3687 self.view_store
3688 .delete_view(name, &mut self.props, &self.ids, &self.labels, &self.syms)
3689 .map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
3690 }
3691 WalRecord::EnableFulltext { label, field } => {
3692 // Replay-over-snapshot idempotency: already enabled → skip.
3693 if self.fulltext.is_enabled(label, field) {
3694 return Ok(());
3695 }
3696 self.fulltext.enable(label, field);
3697 // Backfill: index all live nodes of this label that have the field.
3698 let n = self.ids.len() as u32;
3699 for id in 0..n {
3700 let Some(&sym) = self.labels.get(id as usize) else {
3701 continue;
3702 };
3703 if sym == u32::MAX {
3704 continue; // tombstoned
3705 }
3706 let Some(lbl) = self.syms.resolve(sym) else {
3707 continue;
3708 };
3709 if lbl != label {
3710 continue;
3711 }
3712 if let Some(value) = build_props_view(&self.props, &self.base)
3713 .get(id, field)
3714 .map(|vr| vr.into_value())
3715 {
3716 self.fulltext.add_tokens(id, field, &value);
3717 }
3718 }
3719 }
3720 WalRecord::DisableFulltext { label, field } => {
3721 // Replay-over-snapshot idempotency: already disabled → skip.
3722 if !self.fulltext.is_enabled(label, field) {
3723 return Ok(());
3724 }
3725 // If another label still indexes this field, the postings column
3726 // is kept — but it must not contain node_ids from the now-disabled
3727 // label. Remove them before calling disable() so the field_indexed
3728 // guard inside disable() sees the correct post-removal state.
3729 if self.fulltext.field_indexed_by_other(label, field) {
3730 if let Some(label_sym) = self.syms.get(label) {
3731 for (node_id, &lsym) in self.labels.iter().enumerate() {
3732 if lsym == label_sym {
3733 self.fulltext.remove_node_field(node_id as u32, field);
3734 }
3735 }
3736 }
3737 }
3738 self.fulltext.disable(label, field);
3739 }
3740 WalRecord::EnableIndex { label, field } => {
3741 // Replay-over-snapshot idempotency: already enabled → skip.
3742 if self.prop_index.is_enabled(label, field) {
3743 return Ok(());
3744 }
3745 self.prop_index.enable(label, field);
3746 // Backfill: index all live nodes of this label that have the field.
3747 let n = self.ids.len() as u32;
3748 for id in 0..n {
3749 let Some(&sym) = self.labels.get(id as usize) else {
3750 continue;
3751 };
3752 if sym == u32::MAX {
3753 continue; // tombstoned
3754 }
3755 let Some(lbl) = self.syms.resolve(sym) else {
3756 continue;
3757 };
3758 if lbl != label {
3759 continue;
3760 }
3761 if let Some(value) = build_props_view(&self.props, &self.base)
3762 .get(id, field)
3763 .map(|vr| vr.into_value())
3764 {
3765 self.prop_index.set(label, field, id, &value);
3766 }
3767 }
3768 }
3769 WalRecord::DisableIndex { label, field } => {
3770 self.prop_index.disable(label, field);
3771 }
3772 // History markers carry no replay state — rules re-derive edges
3773 // deterministically on open/replay. Skip unconditionally.
3774 WalRecord::DerivedEdgeAdded { .. } | WalRecord::DerivedEdgeRetracted { .. } => {}
3775 // ── rename_node ──────────────────────────────────────────────────
3776 WalRecord::RenameNode { old_key, new_key } => {
3777 // Recovery-safe: if old_key is already gone (key was renamed
3778 // by a snapshot or a prior replay frame), skip cleanly.
3779 if self.ids.get(old_key).is_none() {
3780 return Ok(());
3781 }
3782 // The rename only updates the key-table; the dense id, all
3783 // topo edges, props, labels, and rule state are id-indexed and
3784 // require no change.
3785 self.ids
3786 .rename(old_key, new_key)
3787 .map_err(|e| GraphError::Corrupt {
3788 detail: format!("wal replay RenameNode {old_key}→{new_key}: {e}"),
3789 })?;
3790 }
3791 }
3792 Ok(())
3793 }
3794
3795 /// Intern `s` in `syms` and emit a WAL `Intern` record so `*Id` records
3796 /// replay on WAL-only `open_at` (no snapshot intern table). Apply is
3797 /// idempotent when the string is already bound. Always emit: after
3798 /// `snapshot()` the WAL is truncated and live intern is not on disk.
3799 fn intern_wal(&mut self, s: &str) -> (u32, WalRecord) {
3800 let id = if let Some(id) = self.syms.get(s) {
3801 id
3802 } else {
3803 self.syms.intern(s)
3804 };
3805 (
3806 id,
3807 WalRecord::Intern {
3808 id,
3809 text: s.to_string(),
3810 },
3811 )
3812 }
3813
3814 /// Rewrite user-facing records into dense-id records. On `Err`, no live
3815 /// state is left mutated: speculative interns made while building the
3816 /// output are rolled back, so a later successful mutation cannot log an
3817 /// `Intern` record whose id replay would never reproduce.
3818 fn rewrite_wal_dense(&mut self, recs: Vec<WalRecord>) -> Result<Vec<WalRecord>> {
3819 let syms_checkpoint = self.syms.len();
3820 let result = self.rewrite_wal_dense_inner(recs);
3821 if result.is_err() {
3822 self.syms.truncate(syms_checkpoint);
3823 }
3824 result
3825 }
3826
3827 fn rewrite_wal_dense_inner(&mut self, recs: Vec<WalRecord>) -> Result<Vec<WalRecord>> {
3828 let mut out = Vec::with_capacity(recs.len());
3829 // Node ids allocated by later apply(InsertNodeId) in this same batch.
3830 let mut pending: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
3831 let mut interned = std::collections::HashSet::<u32>::new();
3832 let mut next = u32::try_from(self.ids.len()).map_err(|_| GraphError::Corrupt {
3833 detail: "id space exhausted".into(),
3834 })?;
3835 let lookup = |ids: &IdMap,
3836 pending: &std::collections::HashMap<String, u32>,
3837 key: &str|
3838 -> Option<u32> { ids.get(key).or_else(|| pending.get(key).copied()) };
3839 for rec in recs {
3840 match rec {
3841 WalRecord::InsertNode { label, key, props } => {
3842 let (label_id, intern) = self.intern_wal(&label);
3843 if interned.insert(label_id) {
3844 out.push(intern);
3845 }
3846 let mut props_id = Vec::with_capacity(props.len());
3847 for (field, value) in props {
3848 let (field_id, intern) = self.intern_wal(&field);
3849 if interned.insert(field_id) {
3850 out.push(intern);
3851 }
3852 props_id.push((field_id, value));
3853 }
3854 if lookup(&self.ids, &pending, &key).is_none() {
3855 pending.insert(key.clone(), next);
3856 next = next.checked_add(1).ok_or_else(|| GraphError::Corrupt {
3857 detail: "id space exhausted".into(),
3858 })?;
3859 }
3860 out.push(WalRecord::InsertNodeId {
3861 label: label_id,
3862 key,
3863 props: props_id,
3864 });
3865 }
3866 WalRecord::SetProp { key, field, value } => {
3867 let id =
3868 lookup(&self.ids, &pending, &key).ok_or_else(|| GraphError::Corrupt {
3869 detail: format!("dense WAL rewrite missing key {key}"),
3870 })?;
3871 let (field_id, intern) = self.intern_wal(&field);
3872 if interned.insert(field_id) {
3873 out.push(intern);
3874 }
3875 out.push(WalRecord::SetPropId {
3876 id,
3877 field: field_id,
3878 value,
3879 });
3880 }
3881 WalRecord::InsertEdge {
3882 edge_type,
3883 src_key,
3884 dst_key,
3885 } => {
3886 let (etype, intern) = self.intern_wal(&edge_type);
3887 if interned.insert(etype) {
3888 out.push(intern);
3889 }
3890 let src = lookup(&self.ids, &pending, &src_key).ok_or_else(|| {
3891 GraphError::Corrupt {
3892 detail: format!("dense WAL rewrite missing src {src_key}"),
3893 }
3894 })?;
3895 let dst = lookup(&self.ids, &pending, &dst_key).ok_or_else(|| {
3896 GraphError::Corrupt {
3897 detail: format!("dense WAL rewrite missing dst {dst_key}"),
3898 }
3899 })?;
3900 out.push(WalRecord::InsertEdgeId { etype, src, dst });
3901 }
3902 WalRecord::RenameNode {
3903 ref old_key,
3904 ref new_key,
3905 } => {
3906 // Track the rename in `pending` so subsequent InsertEdge /
3907 // SetProp records in this batch can resolve the new key.
3908 let id = lookup(&self.ids, &pending, old_key).ok_or_else(|| {
3909 GraphError::Corrupt {
3910 detail: format!(
3911 "dense WAL rewrite: RenameNode old key {old_key} not found"
3912 ),
3913 }
3914 })?;
3915 pending.remove(old_key.as_str());
3916 pending.insert(new_key.clone(), id);
3917 out.push(rec);
3918 }
3919 // # Symbol-order invariant (load-bearing)
3920 //
3921 // Write-time and replay-time symbol assignment must agree: every
3922 // symbol in a `Batch` frame has to receive the same dense id when
3923 // the frame's records are replayed in order as it received when
3924 // the frame was written.
3925 //
3926 // A rule's backfill interns its `edge_type` lazily
3927 // (`core_rules::engine`, every `g.syms.intern(&def.edge_type)`
3928 // site), and that backfill runs from `apply` — during the
3929 // `CreateRule` record itself, and again from any later
3930 // `InsertNodeId` in the same frame that makes the rule fire. At
3931 // write time the whole batch is rewritten before any of it is
3932 // applied, so a later `InsertEdge` in the same batch would win the
3933 // lower id for its edge type; on replay the rule's lazy intern
3934 // gets there first and steals it, and the `Intern` record fails at
3935 // the `wal intern assigned …` check in `apply`.
3936 //
3937 // Pre-interning the rule's `edge_type` here, and emitting its
3938 // `Intern` record ahead of the `CreateRule` record, makes both
3939 // orders identical. `weight_prop` needs no pre-intern:
3940 // `EdgeProps::set` keys props by `String`, never through the
3941 // interner. `via_edge` needs none either: via-hop rules resolve it
3942 // with `syms.get` and skip when it is absent.
3943 //
3944 // `RebuildRule` and `DeleteRule` need no such handling here:
3945 // `RebuildRule` has no `BatchOp` variant, so it never appears
3946 // inside a `Batch` today — it is only ever issued as its own
3947 // standalone commit (`rebuild_rule`, or the auto-rebuild path
3948 // that logs it as a second commit after the triggering op).
3949 // `DeleteRule` does have a `BatchOp` variant and can appear
3950 // inside a `Batch`, but it carries only a rule `name` — no
3951 // `edge_type` or other symbol that needs pre-interning — so
3952 // only `CreateRule` needs this arm.
3953 WalRecord::CreateRule { ref def_bytes } => {
3954 let def = decode_rule_def(def_bytes).map_err(|e| GraphError::Corrupt {
3955 detail: format!("CreateRule def_bytes deserialize failed: {e}"),
3956 })?;
3957 let (etype, intern) = self.intern_wal(&def.edge_type);
3958 if interned.insert(etype) {
3959 out.push(intern);
3960 }
3961 out.push(rec);
3962 }
3963 other => out.push(other),
3964 }
3965 }
3966 Ok(out)
3967 }
3968
3969 fn log_dense(&mut self, recs: Vec<WalRecord>) -> Result<()> {
3970 let recs = self.rewrite_wal_dense(recs)?;
3971 match recs.len() {
3972 0 => Ok(()),
3973 1 => self.log_then_apply(recs.into_iter().next().unwrap()),
3974 _ => self.log_then_apply(WalRecord::Batch(recs)),
3975 }
3976 }
3977
3978 /// Durable write, then notify the event sink. Replay (`apply` during
3979 /// `open`) never enters this function, so it is the replay-silent seam.
3980 fn log_then_apply(&mut self, rec: WalRecord) -> Result<()> {
3981 self.log_then_apply_with(rec, None, self.fsync)
3982 }
3983
3984 /// Whether this frame must fsync under `policy`.
3985 ///
3986 /// Batched contract: user-visible batches (>1 mutation) fsync; single
3987 /// mutations do not. The dense rewrite wraps a single mutation in a
3988 /// `Batch([Intern.., <one *Id record>])`, so `Intern` records are excluded
3989 /// from the count — removing that filter would make every single-op write
3990 /// fsync under Batched (or, if the threshold were raised instead, skip a
3991 /// needed fsync for real two-op batches).
3992 fn wal_needs_sync(policy: FsyncPolicy, rec: &WalRecord) -> bool {
3993 match policy {
3994 FsyncPolicy::Relaxed => false,
3995 FsyncPolicy::Strict => true,
3996 FsyncPolicy::Batched => match rec {
3997 // Intern + one mutation is the single-op rewrite, not a user batch.
3998 WalRecord::Batch(inner) => {
3999 inner
4000 .iter()
4001 .filter(|r| !matches!(r, WalRecord::Intern { .. }))
4002 .count()
4003 > 1
4004 }
4005 _ => false,
4006 },
4007 }
4008 }
4009
4010 /// # Apply-infallibility invariant (load-bearing)
4011 ///
4012 /// The ordering is: WAL append → fsync → apply. If `apply` returned `Err`
4013 /// for a `Batch` frame after a successful WAL write, the WAL would contain
4014 /// the full frame while in-memory state would reflect only the ops before
4015 /// the failure. On reopen, WAL replay would then apply the entire batch —
4016 /// diverging permanently from what the pre-crash process had in memory.
4017 ///
4018 /// For `Batch` frames this situation cannot arise because:
4019 /// - All validation runs via `commit_logged_batch`/`MutPreview` **before**
4020 /// the WAL write. `MutPreview` uses the same `&mut self` that apply will
4021 /// use, with no concurrent mutation between validation exit and apply entry.
4022 /// - Every `apply` arm for a validated op is either infallible by construction
4023 /// (`InsertNode`, `RemoveProp`, `DeleteEdge`, `DeleteNode`), has idempotency
4024 /// guards that return `Ok(())` (`CreateRule`, `DeleteRule`), or is
4025 /// guaranteed-present by validation (`InsertEdge`/`SetProp` key lookups).
4026 /// - `on_node_changed` and `on_node_removed` return `()` — never `Err`.
4027 ///
4028 /// A `debug_assert!` below fires in debug builds if `apply` ever returns
4029 /// `Err` for a `Batch` frame, making any future regression immediately visible
4030 /// in tests rather than silently diverging crash-recovery behaviour.
4031 fn log_then_apply_with(
4032 &mut self,
4033 rec: WalRecord,
4034 ingest: Option<(String, usize)>,
4035 policy: FsyncPolicy,
4036 ) -> Result<()> {
4037 // Read-only guard: as-of instances must never write the WAL.
4038 if self.read_only {
4039 return Err(GraphError::ReadOnly);
4040 }
4041 // Degraded guard: fsync failure left WAL truncated, or a refresh failed
4042 // partway; in-memory state is ahead of (or out of step with) the
4043 // on-disk WAL, so further mutations would deepen the divergence.
4044 // Reopen the database to recover. Checked before the lock guard: this
4045 // is the more serious condition and the more useful error.
4046 if self.degraded {
4047 return Err(GraphError::Io(std::io::Error::other(
4048 "database degraded after group-commit fsync failure; reopen required",
4049 )));
4050 }
4051 // Cross-process guard: this write scope asked for the store's write
4052 // lock and did not get it. Writing anyway would append frames on top of
4053 // a WAL another process is extending, so refuse instead.
4054 if self.lock_denied {
4055 return Err(GraphError::Busy { holder: None });
4056 }
4057 // Ensure retained provenance bytes are decoded into the live mutable
4058 // fields before any mutation touches self.engine.provenance. This is a
4059 // no-op if provenance was never stored (fresh store) or has already been
4060 // consumed (subsequent mutations). WAL replay calls apply() directly
4061 // and is covered by consume_retained_state_eager before replay.
4062 self.ensure_v8_base_sections_loaded();
4063 self.engine.ensure_provenance_loaded_mut();
4064 // Invariant (I-1): no stale deltas may enter from a previous apply.
4065 // If any engine method ever accumulates deltas before erroring, they would
4066 // contaminate the *next* commit's event stream. This assert fires in debug
4067 // builds, making any future regression visible at the earliest point.
4068 debug_assert_eq!(
4069 self.engine.pending_delta_count(),
4070 0,
4071 "stale engine deltas at log_then_apply_with entry — \
4072 a previous apply arm may have accumulated deltas before erroring; \
4073 the caller must drain_deltas() on any error path before returning"
4074 );
4075 let frame = encode_record(&rec);
4076 self.fs.append(FileId::Wal, &frame)?;
4077 // The cursor advances by exactly the bytes appended: these frames are
4078 // ours and already applied, so a later refresh must not replay them.
4079 self.wal_consumed += frame.len() as u64;
4080 if Self::wal_needs_sync(policy, &rec) {
4081 self.fs.sync(FileId::Wal)?;
4082 }
4083 // Marker writing always needs the engine deltas, but the engine only
4084 // accumulates them when emit_deltas is true (normally gated on subscribers
4085 // or views being present). Enable emission for this apply if it is
4086 // currently off, then restore the original state unconditionally via an
4087 // RAII guard — this prevents a panic in apply() from leaking the flag.
4088 // The same guard resets the engine's transient chaining state. A panic
4089 // unwinding out of a rule hook would otherwise leave `chain_depth`
4090 // non-zero, which makes every later `begin_chain` decide chaining is
4091 // already running and silently switch it off for good.
4092 struct RestoreEmitDeltas(*mut RuleEngine, bool);
4093 impl Drop for RestoreEmitDeltas {
4094 fn drop(&mut self) {
4095 // SAFETY: pointer into self (GraphDb); guard is dropped within
4096 // this frame before log_then_apply_with returns.
4097 unsafe {
4098 (*self.0).set_emit_deltas(self.1);
4099 (*self.0).reset_chain_state();
4100 }
4101 }
4102 }
4103 let original_emit = self.engine.emit_deltas();
4104 if !original_emit {
4105 self.engine.set_emit_deltas(true);
4106 }
4107 // SAFETY: raw pointer into self; guard dropped within this frame.
4108 let _emit_guard = RestoreEmitDeltas(&mut self.engine as *mut _, original_emit);
4109
4110 let apply_result = self.apply(&rec);
4111 // For Batch frames, post-validation apply must be infallible (see above).
4112 // A debug_assert here catches any future change that makes apply fallible
4113 // before the caller notices via silent WAL/memory divergence.
4114 if matches!(&rec, WalRecord::Batch(_)) {
4115 debug_assert!(
4116 apply_result.is_ok(),
4117 "Batch apply returned Err after successful WAL write — \
4118 the validate-then-apply invariant has been violated; \
4119 see log_then_apply_with invariant doc"
4120 );
4121 }
4122 if apply_result.is_err() {
4123 // Discard any partial deltas accumulated by the failed apply.
4124 // They must not ride the next commit's event stream (I-1).
4125 // _emit_guard restores emit_deltas on drop automatically.
4126 let _ = self.engine.drain_deltas();
4127 let _ = self.engine.take_rebuild_needed();
4128 apply_result?;
4129 }
4130 self.commit_seq += 1;
4131 let seq = self.commit_seq;
4132 // Update per-node last-change map for the committed record.
4133 // Must happen after commit_seq is incremented so the seq is correct.
4134 self.update_last_change_from_rec(&rec, seq);
4135 // Drain engine deltas and distribute to subscribers before the existing
4136 // MutationEvent sink fires — both happen post-fsync, post-apply.
4137 // _emit_guard restores emit_deltas after this line when it drops.
4138 let engine_deltas = self.engine.drain_deltas();
4139
4140 // Append history-marker WAL records for any derived-edge changes so
4141 // that `edge_history` and `was_linked` can surface rule-attributed
4142 // events. Markers are STATE NO-OPS during replay; they are written
4143 // without an additional fsync (the triggering commit's sync already
4144 // happened; the next commit's sync covers these lazily).
4145 if !engine_deltas.is_empty() {
4146 let markers: Vec<WalRecord> = engine_deltas
4147 .iter()
4148 .map(|d| {
4149 if d.fired {
4150 WalRecord::DerivedEdgeAdded {
4151 rule: d.rule.clone(),
4152 edge_type: d.edge_type.clone(),
4153 src_key: d.src_key.clone(),
4154 dst_key: d.dst_key.clone(),
4155 }
4156 } else {
4157 WalRecord::DerivedEdgeRetracted {
4158 rule: d.rule.clone(),
4159 edge_type: d.edge_type.clone(),
4160 src_key: d.src_key.clone(),
4161 dst_key: d.dst_key.clone(),
4162 }
4163 }
4164 })
4165 .collect();
4166 let marker_frame = if markers.len() == 1 {
4167 markers.into_iter().next().unwrap()
4168 } else {
4169 WalRecord::Batch(markers)
4170 };
4171 // Ignore append errors: markers are best-effort history
4172 // annotations. Losing them does not affect state correctness.
4173 // The cursor only advances when the bytes actually landed.
4174 let marker_bytes = encode_record(&marker_frame);
4175 if self.fs.append(FileId::Wal, &marker_bytes).is_ok() {
4176 self.wal_consumed += marker_bytes.len() as u64;
4177 }
4178 }
4179
4180 // Record MVCC CommitDelta for the epoch reader. The WAL record is
4181 // stored as-is (including any nested Batch / Intern records); the
4182 // ReaderSnapshot's apply_one function handles all variants.
4183 {
4184 let derived_inserts = engine_deltas
4185 .iter()
4186 .filter(|d| d.fired)
4187 .map(|d| (d.etype_sym, d.src_id, d.dst_id))
4188 .collect();
4189 let derived_deletes = engine_deltas
4190 .iter()
4191 .filter(|d| !d.fired)
4192 .map(|d| (d.etype_sym, d.src_id, d.dst_id))
4193 .collect();
4194 let delta = Arc::new(crate::reader::CommitDelta {
4195 records: vec![rec.clone()],
4196 derived_inserts,
4197 derived_deletes,
4198 });
4199 self.delta_tail.push(delta);
4200 self.commits_since_fold += 1;
4201 if self.commits_since_fold >= crate::reader::FOLD_EVERY_K {
4202 self.fold_now();
4203 }
4204 }
4205
4206 if self.defer_events {
4207 // Group-commit drain thread: hold events until after the group
4208 // fsync so subscribers only observe durable data (R2).
4209 self.deferred_events.push(DeferredEvent {
4210 rec: rec.clone(),
4211 engine_deltas,
4212 seq,
4213 ingest,
4214 });
4215 } else {
4216 self.distribute_events(&rec, &engine_deltas, seq);
4217 self.emit_committed(&rec, ingest);
4218 }
4219 // Drift is only known after apply, so auto-rebuild cannot join the
4220 // triggering op's WAL frame. Issue RebuildRule as a second commit.
4221 // Skip when `rec` is itself RebuildRule: rebuild resets drift, so a
4222 // retrigger loop is impossible if the fit succeeded, but we still
4223 // drain the flag so a leftover cannot re-enter.
4224 let rebuilds = self.engine.take_rebuild_needed();
4225 if !matches!(&rec, WalRecord::RebuildRule { .. }) {
4226 let mut failed = Vec::new();
4227 for name in rebuilds {
4228 if self.engine.rules().any(|r| r.name == name) {
4229 // User op is already durable. A failed second commit must
4230 // not surface as the caller's error.
4231 if let Err(e) =
4232 self.log_then_apply(WalRecord::RebuildRule { name: name.clone() })
4233 {
4234 eprintln!(
4235 "auto-rebuild of rule {name:?} failed after durable user commit: {e}"
4236 );
4237 failed.push(name);
4238 }
4239 }
4240 }
4241 for name in failed {
4242 self.engine.queue_rebuild_needed(name);
4243 }
4244 }
4245 Ok(())
4246 }
4247
4248 /// Install a post-commit hook. Replaces any previous sink.
4249 ///
4250 /// The sink runs inside `log_then_apply` after a successful
4251 /// durable commit, while the caller still holds `&mut self`. When this
4252 /// database is behind a [`crate::SharedDb`], that means the **write
4253 /// guard is held**. The sink must never call `read` / `write` (or any
4254 /// other method) on the same `SharedDb` — the `RwLock` is not
4255 /// re-entrant and doing so deadlocks. The sink is `Send + Sync`;
4256 /// `std::sync::mpsc::Sender` is not `Sync` and will not type-check.
4257 /// Intended examples: `std::sync::mpsc::SyncSender`,
4258 /// `tokio::sync::mpsc::Sender`, `tokio::sync::broadcast::Sender`
4259 /// (non-blocking `send`), or `Arc<Mutex<Vec<MutationEvent>>>`.
4260 pub fn set_event_sink(&mut self, sink: Box<dyn Fn(MutationEvent) + Send + Sync>) {
4261 self.event_sink = Some(sink);
4262 }
4263
4264 /// Whether a post-commit event sink is currently installed.
4265 pub fn has_event_sink(&self) -> bool {
4266 self.event_sink.is_some()
4267 }
4268
4269 /// Set WAL fsync cadence. Default [`FsyncPolicy::Strict`].
4270 pub fn set_fsync_policy(&mut self, p: FsyncPolicy) {
4271 self.fsync = p;
4272 }
4273
4274 /// Return the current WAL fsync cadence.
4275 pub fn fsync_policy(&self) -> FsyncPolicy {
4276 self.fsync
4277 }
4278
4279 // ── Group-commit event deferral ───────────────────────────────────────────
4280
4281 /// Enable or disable deferred event mode.
4282 ///
4283 /// When `true`, event notifications (subscription `DbEvent`s and legacy
4284 /// `MutationEvent` sink calls) are buffered rather than fired immediately.
4285 /// Call [`flush_deferred_events`] after the group fsync to deliver them,
4286 /// or [`discard_deferred_events`] if the fsync failed and the group must
4287 /// be treated as lost.
4288 pub fn set_deferred_events_mode(&mut self, defer: bool) {
4289 self.defer_events = defer;
4290 }
4291
4292 /// Fire all buffered events accumulated since [`set_deferred_events_mode`]
4293 /// was set to true. Clears the buffer.
4294 ///
4295 /// Called by the drain thread AFTER a successful group fsync, so
4296 /// subscribers observe only data that is durably on disk.
4297 pub fn flush_deferred_events(&mut self) {
4298 let events = std::mem::take(&mut self.deferred_events);
4299 for de in events {
4300 self.distribute_events(&de.rec, &de.engine_deltas, de.seq);
4301 self.emit_committed(&de.rec, de.ingest);
4302 }
4303 }
4304
4305 /// Discard all buffered events without firing them.
4306 ///
4307 /// Called by the drain thread when a group fsync fails: the WAL has been
4308 /// truncated back to the pre-group offset, so the committed-but-unsynced
4309 /// ops must not be observable to subscribers.
4310 pub fn discard_deferred_events(&mut self) {
4311 self.deferred_events.clear();
4312 }
4313
4314 // ── Degraded state ────────────────────────────────────────────────────────
4315
4316 /// Mark this database as degraded.
4317 ///
4318 /// Called by the group-commit drain thread after a group fsync failure and
4319 /// WAL truncation: the in-memory state is now ahead of the on-disk WAL, so
4320 /// further mutations would deepen the divergence. All subsequent calls to
4321 /// [`log_then_apply_with`] return `Err` until the database is reopened.
4322 pub fn set_degraded(&mut self) {
4323 self.degraded = true;
4324 }
4325
4326 fn emit(&self, ev: MutationEvent) {
4327 if let Some(sink) = &self.event_sink {
4328 sink(ev);
4329 }
4330 }
4331
4332 fn emit_committed(&self, rec: &WalRecord, ingest: Option<(String, usize)>) {
4333 match rec {
4334 WalRecord::Batch(inner) => {
4335 for r in inner {
4336 if let Some(ev) = event_from_record(r, &self.syms, &self.ids) {
4337 self.emit(ev);
4338 }
4339 }
4340 match ingest {
4341 Some((label, inserted)) => {
4342 self.emit(MutationEvent::Ingested { label, inserted })
4343 }
4344 None => {
4345 let ops = inner
4346 .iter()
4347 .filter(|r| !matches!(r, WalRecord::Intern { .. }))
4348 .count();
4349 if ops > 1 {
4350 self.emit(MutationEvent::BatchApplied { ops });
4351 }
4352 }
4353 }
4354 }
4355 other => {
4356 if let Some(ev) = event_from_record(other, &self.syms, &self.ids) {
4357 self.emit(ev);
4358 }
4359 }
4360 }
4361 }
4362
4363 // -----------------------------------------------------------------------
4364 // Subscription API
4365 // -----------------------------------------------------------------------
4366
4367 /// Distribute post-commit events to all live subscribers.
4368 ///
4369 /// Build a row-key → row-data map from a [`ResultSet`].
4370 ///
4371 /// Each row is serialized to JSON to form its key; a debug fallback is used
4372 /// if serialization fails. Used by both the initial-seed path in
4373 /// [`Self::subscribe_query`] and the per-commit diff path in
4374 /// [`Self::distribute_events`] to keep the two in sync.
4375 fn result_to_row_map(
4376 result: &core_query::ResultSet,
4377 ) -> std::collections::HashMap<String, Vec<Option<Value>>> {
4378 (0..result.len())
4379 .map(|i| {
4380 let row = result.row(i).to_vec();
4381 let key = serde_json::to_string(&row).unwrap_or_else(|_| format!("{row:?}"));
4382 (key, row)
4383 })
4384 .collect()
4385 }
4386
4387 /// Collect the set of label syms touched by a WAL record.
4388 ///
4389 /// Returns `Some(set)` when every record in this commit can be attributed to
4390 /// a known label sym. Returns `None` when the commit must not be skipped:
4391 /// edge records, unresolvable key→label lookups, or any record type not in
4392 /// the explicit handled set.
4393 ///
4394 /// Handled record types and their actions:
4395 /// - `InsertNode` → look up label in interner (fails → None)
4396 /// - `InsertNodeId` → label sym is carried directly
4397 /// - `SetProp` → resolve key→id→label (fails → None)
4398 /// - `DeleteNode` → resolve key→id→label (fails → None)
4399 /// - `Batch` → recurse into every inner record
4400 /// - `InsertEdge`, `DeleteEdge`, `InsertEdgeId` → always None (edge records)
4401 /// - everything else → None (conservative)
4402 fn commit_touched_labels(
4403 rec: &WalRecord,
4404 syms: &Interner,
4405 ids: &IdMap,
4406 labels: &[u32],
4407 ) -> Option<BTreeSet<u32>> {
4408 let mut out = BTreeSet::new();
4409 if Self::collect_touched_labels(rec, syms, ids, labels, &mut out) {
4410 Some(out)
4411 } else {
4412 None
4413 }
4414 }
4415
4416 fn collect_touched_labels(
4417 rec: &WalRecord,
4418 syms: &Interner,
4419 ids: &IdMap,
4420 labels: &[u32],
4421 out: &mut BTreeSet<u32>,
4422 ) -> bool {
4423 match rec {
4424 // String-key insert: the dense rewrite converts this to
4425 // [Intern, InsertNodeId], so this arm fires only for legacy WAL
4426 // records written before the dense path was added.
4427 WalRecord::InsertNode { label, .. } => {
4428 if let Some(sym) = syms.get(label) {
4429 out.insert(sym);
4430 true
4431 } else {
4432 false
4433 }
4434 }
4435 // Dense-id insert (produced by rewrite_wal_dense for every
4436 // insert_node call in the current codebase).
4437 WalRecord::InsertNodeId { label, .. } => {
4438 out.insert(*label);
4439 true
4440 }
4441 // String-key prop set: dense path converts to [Intern, SetPropId].
4442 WalRecord::SetProp { key, .. } => {
4443 if let Some(sym) = Self::resolve_key_label_sym(key, ids, labels) {
4444 out.insert(sym);
4445 true
4446 } else {
4447 false
4448 }
4449 }
4450 // Dense-id prop set (produced by rewrite_wal_dense for set_prop).
4451 WalRecord::SetPropId { id, .. } => {
4452 if let Some(sym) = labels.get(*id as usize).copied().filter(|&s| s != u32::MAX) {
4453 out.insert(sym);
4454 true
4455 } else {
4456 false
4457 }
4458 }
4459 WalRecord::DeleteNode { key } => {
4460 if let Some(sym) = Self::resolve_key_label_sym(key, ids, labels) {
4461 out.insert(sym);
4462 true
4463 } else {
4464 false
4465 }
4466 }
4467 WalRecord::Batch(inner) => inner
4468 .iter()
4469 .all(|r| Self::collect_touched_labels(r, syms, ids, labels, out)),
4470 // Intern is a pure metadata record — it does not touch any node's
4471 // label and is safe to skip for the label-skip predicate.
4472 WalRecord::Intern { .. } => true,
4473 // Edge records: always re-execute (edges can change join results).
4474 WalRecord::InsertEdge { .. }
4475 | WalRecord::DeleteEdge { .. }
4476 | WalRecord::InsertEdgeId { .. } => false,
4477 _ => false,
4478 }
4479 }
4480
4481 /// Resolve a node key to its label sym via the dense id table.
4482 /// Returns `None` if the key is unknown or the label is a tombstone sentinel.
4483 fn resolve_key_label_sym(key: &str, ids: &IdMap, labels: &[u32]) -> Option<u32> {
4484 let id = ids.get(key)?;
4485 let sym = labels.get(id as usize).copied()?;
4486 (sym != u32::MAX).then_some(sym)
4487 }
4488
4489 /// Distribute post-commit events to all live subscribers.
4490 ///
4491 /// Called from `log_then_apply_with` after apply + fsync, before the
4492 /// legacy MutationEvent sink. Prunes dead `Weak` entries in-place.
4493 ///
4494 /// Query subscriptions (subscribe_query) re-execute their plan on every
4495 /// call and diff the result against the previous run. Zero overhead when
4496 /// no query subscriptions are active.
4497 fn distribute_events(&mut self, rec: &WalRecord, engine_deltas: &[EngineEdgeDelta], seq: u64) {
4498 if self.subscriptions.is_empty() && self.query_subscriptions.is_empty() {
4499 return;
4500 }
4501
4502 if !self.subscriptions.is_empty() {
4503 // Build write events from the WAL record.
4504 let write_events: Vec<DbEvent> =
4505 Self::write_events_from_record(rec, seq, &self.syms, &self.ids);
4506
4507 // Build edge events from engine deltas. Weight is looked up from
4508 // edge_props at distribution time (after apply), so it's always fresh.
4509 let edge_events: Vec<DbEvent> = engine_deltas
4510 .iter()
4511 .map(|d| {
4512 if d.fired {
4513 // The score lives under the rule's declared weight_prop,
4514 // which is not always the literal "weight".
4515 let prop = self
4516 .engine
4517 .rules()
4518 .find(|r| r.name == d.rule)
4519 .and_then(|r| r.weight_prop.as_deref());
4520 let weight = prop.and_then(|p| {
4521 self.edge_props
4522 .get(d.etype_sym, d.src_id, d.dst_id, p)
4523 .and_then(|v| {
4524 if let core_storage::Value::Float(f) = v {
4525 Some(*f)
4526 } else {
4527 None
4528 }
4529 })
4530 });
4531 DbEvent::EdgeFired {
4532 rule: d.rule.clone(),
4533 src_key: d.src_key.clone(),
4534 dst_key: d.dst_key.clone(),
4535 edge_type: d.edge_type.clone(),
4536 weight,
4537 commit_seq: seq,
4538 }
4539 } else {
4540 DbEvent::EdgeRetracted {
4541 rule: d.rule.clone(),
4542 src_key: d.src_key.clone(),
4543 dst_key: d.dst_key.clone(),
4544 edge_type: d.edge_type.clone(),
4545 commit_seq: seq,
4546 }
4547 }
4548 })
4549 .collect();
4550
4551 // Prune dead entries; push matching events to live ones.
4552 self.subscriptions.retain(|entry| {
4553 let Some(inner) = entry.inner.upgrade() else {
4554 return false;
4555 };
4556 for ev in &write_events {
4557 if event_matches(ev, &entry.filter) {
4558 inner.push(ev.clone());
4559 }
4560 }
4561 for ev in &edge_events {
4562 if event_matches(ev, &entry.filter) {
4563 inner.push(ev.clone());
4564 }
4565 }
4566 true
4567 });
4568
4569 // Turn off delta accumulation if all subscribers dropped and no views remain.
4570 if self.subscriptions.is_empty() && self.view_store.is_empty() {
4571 self.engine.set_emit_deltas(false);
4572 }
4573 }
4574
4575 // Query subscriptions: full re-run per commit, then diff rows.
4576 // IMPORTANT: full re-execution on every commit — use LIMIT to bound cost.
4577 // Differential evaluation is roadmap / Phase 5.
4578 if !self.query_subscriptions.is_empty() {
4579 // Take the list out so we can call self.view() without borrow conflict.
4580 let mut query_subs = std::mem::take(&mut self.query_subscriptions);
4581 let empty_params = BTreeMap::new();
4582 query_subs.retain_mut(|entry| {
4583 let Some(inner) = entry.inner.upgrade() else {
4584 return false; // subscriber dropped — prune
4585 };
4586 // Label-skip: if the plan has a known scan label and this commit
4587 // can be proven to touch only different labels (and no rule-derived
4588 // edge deltas fired), the result set cannot have changed — skip.
4589 if let Some(scan_sym) = entry.scan_label {
4590 if engine_deltas.is_empty() {
4591 let touched =
4592 Self::commit_touched_labels(rec, &self.syms, &self.ids, &self.labels);
4593 if touched.map(|t| !t.contains(&scan_sym)).unwrap_or(false) {
4594 return true; // safe to skip — result set unchanged
4595 }
4596 }
4597 }
4598 QUERY_SUB_EXECS_TL.with(|c| c.set(c.get() + 1));
4599 let result = match execute(&self.view(), &entry.ops, &Params(&empty_params)) {
4600 Ok(r) => r,
4601 Err(e) => {
4602 // Keep the subscription alive; skip the diff for this commit.
4603 // Re-run errors are transient (e.g., planner change) and
4604 // self-heal when the next commit succeeds.
4605 eprintln!("[mushroomdb] subscribe_query re-run failed: {e}");
4606 return true;
4607 }
4608 };
4609 // Build new row map: serialized-key → row data.
4610 let new_row_map = Self::result_to_row_map(&result);
4611 // Removed rows: in prev but not in new.
4612 for (key, row) in &entry.prev_row_map {
4613 if !new_row_map.contains_key(key) {
4614 inner.push(DbEvent::QueryRowRemoved {
4615 columns: entry.columns.clone(),
4616 row: row.clone(),
4617 });
4618 }
4619 }
4620 // Added rows: in new but not in prev.
4621 for (key, row) in &new_row_map {
4622 if !entry.prev_row_map.contains_key(key) {
4623 inner.push(DbEvent::QueryRowAdded {
4624 columns: entry.columns.clone(),
4625 row: row.clone(),
4626 });
4627 }
4628 }
4629 entry.prev_row_map = new_row_map;
4630 true
4631 });
4632 self.query_subscriptions = query_subs;
4633 }
4634 }
4635
4636 /// Returns `true` if any live subscriber or view definition requires delta
4637 /// accumulation. Used to set `engine.emit_deltas` on subscribe/view DDL.
4638 fn needs_emit_deltas(&self) -> bool {
4639 !self.view_store.is_empty()
4640 || self
4641 .subscriptions
4642 .iter()
4643 .any(|e| e.inner.upgrade().is_some())
4644 }
4645
4646 /// Convert a WAL record into `DbEvent` write events with the given seq.
4647 fn write_events_from_record(
4648 rec: &WalRecord,
4649 seq: u64,
4650 intern: &Interner,
4651 ids: &IdMap,
4652 ) -> Vec<DbEvent> {
4653 match rec {
4654 WalRecord::InsertNode { label, key, .. } => vec![DbEvent::NodeInserted {
4655 label: label.clone(),
4656 key: key.clone(),
4657 commit_seq: seq,
4658 }],
4659 // *Id arms run after a successful apply, so resolution can only
4660 // fail on a programming error. Skip the event rather than emit a
4661 // fabricated "" that clients can't tell from a real empty value
4662 // (mirrors event_from_record returning None).
4663 WalRecord::InsertNodeId { label, key, .. } => intern
4664 .resolve(*label)
4665 .map(|label| DbEvent::NodeInserted {
4666 label: label.to_string(),
4667 key: key.clone(),
4668 commit_seq: seq,
4669 })
4670 .into_iter()
4671 .collect(),
4672 WalRecord::SetProp { key, field, .. } => vec![DbEvent::PropSet {
4673 key: key.clone(),
4674 field: field.clone(),
4675 commit_seq: seq,
4676 }],
4677 WalRecord::SetPropId { id, field, .. } => ids
4678 .key_of(*id)
4679 .zip(intern.resolve(*field))
4680 .map(|(key, field)| DbEvent::PropSet {
4681 key: key.to_string(),
4682 field: field.to_string(),
4683 commit_seq: seq,
4684 })
4685 .into_iter()
4686 .collect(),
4687 WalRecord::RemoveProp { key, field } => vec![DbEvent::PropRemoved {
4688 key: key.clone(),
4689 field: field.clone(),
4690 commit_seq: seq,
4691 }],
4692 WalRecord::InsertEdge {
4693 edge_type,
4694 src_key,
4695 dst_key,
4696 } => vec![DbEvent::EdgeInserted {
4697 edge_type: edge_type.clone(),
4698 src: src_key.clone(),
4699 dst: dst_key.clone(),
4700 commit_seq: seq,
4701 }],
4702 WalRecord::InsertEdgeId { etype, src, dst } => (|| {
4703 Some(DbEvent::EdgeInserted {
4704 edge_type: intern.resolve(*etype)?.to_string(),
4705 src: ids.key_of(*src)?.to_string(),
4706 dst: ids.key_of(*dst)?.to_string(),
4707 commit_seq: seq,
4708 })
4709 })()
4710 .into_iter()
4711 .collect(),
4712 WalRecord::DeleteEdge {
4713 edge_type,
4714 src_key,
4715 dst_key,
4716 } => vec![DbEvent::EdgeDeleted {
4717 edge_type: edge_type.clone(),
4718 src: src_key.clone(),
4719 dst: dst_key.clone(),
4720 commit_seq: seq,
4721 }],
4722 WalRecord::DeleteNode { key } => vec![DbEvent::NodeDeleted {
4723 key: key.clone(),
4724 commit_seq: seq,
4725 }],
4726 WalRecord::Batch(inner) => inner
4727 .iter()
4728 .flat_map(|r| Self::write_events_from_record(r, seq, intern, ids))
4729 .collect(),
4730 WalRecord::CreateRule { .. }
4731 | WalRecord::DeleteRule { .. }
4732 | WalRecord::RebuildRule { .. }
4733 | WalRecord::CreateView { .. }
4734 | WalRecord::DeleteView { .. }
4735 | WalRecord::EnableFulltext { .. }
4736 | WalRecord::DisableFulltext { .. }
4737 | WalRecord::EnableIndex { .. }
4738 | WalRecord::DisableIndex { .. }
4739 | WalRecord::Intern { .. }
4740 // History markers produce no DbEvent — the engine delta already
4741 // fired the EdgeFired/EdgeRetracted subscription events.
4742 | WalRecord::DerivedEdgeAdded { .. }
4743 | WalRecord::DerivedEdgeRetracted { .. }
4744 | WalRecord::RenameNode { .. } => vec![],
4745 }
4746 }
4747
4748 /// Subscribe to edge-fire and edge-retract events for one named rule.
4749 ///
4750 /// Returns `Err(GraphError::RuleNotFound)` if `rule_name` is not
4751 /// currently registered. Dropping the returned [`Subscription`] handle
4752 /// unregisters the subscriber — no further events are queued, no
4753 /// resources leak.
4754 pub fn subscribe_rule(&mut self, rule_name: &str) -> core_storage::Result<Subscription> {
4755 if self.read_only {
4756 return Err(core_storage::GraphError::ReadOnly);
4757 }
4758 if !self.engine.rules().any(|r| r.name == rule_name) {
4759 return Err(core_storage::GraphError::RuleNotFound {
4760 name: rule_name.to_string(),
4761 });
4762 }
4763 let inner = SubInner::new(self.sub_capacity());
4764 self.subscriptions.push(SubEntry {
4765 filter: SubFilter::Rule(rule_name.to_string()),
4766 inner: std::sync::Arc::downgrade(&inner),
4767 });
4768 self.engine.set_emit_deltas(true);
4769 Ok(Subscription(inner))
4770 }
4771
4772 /// Subscribe to edge-fire and edge-retract events for **all** rules.
4773 ///
4774 /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
4775 /// as-of instances never commit, so `distribute_events` never runs and the
4776 /// subscription would never deliver events.
4777 pub fn subscribe_all_rules(&mut self) -> core_storage::Result<Subscription> {
4778 if self.read_only {
4779 return Err(core_storage::GraphError::ReadOnly);
4780 }
4781 let inner = SubInner::new(self.sub_capacity());
4782 self.subscriptions.push(SubEntry {
4783 filter: SubFilter::AllRules,
4784 inner: std::sync::Arc::downgrade(&inner),
4785 });
4786 self.engine.set_emit_deltas(true);
4787 Ok(Subscription(inner))
4788 }
4789
4790 /// Subscribe to write events: node insert/delete, prop set/remove.
4791 ///
4792 /// Does not include edge-fire / edge-retract (rule-derived edge events).
4793 ///
4794 /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
4795 /// as-of instances never commit, so `distribute_events` never runs and the
4796 /// subscription would never deliver events.
4797 pub fn subscribe_writes(&mut self) -> core_storage::Result<Subscription> {
4798 if self.read_only {
4799 return Err(core_storage::GraphError::ReadOnly);
4800 }
4801 let inner = SubInner::new(self.sub_capacity());
4802 self.subscriptions.push(SubEntry {
4803 filter: SubFilter::Writes,
4804 inner: std::sync::Arc::downgrade(&inner),
4805 });
4806 self.engine.set_emit_deltas(true);
4807 Ok(Subscription(inner))
4808 }
4809
4810 /// Subscribe to incremental Cypher query results.
4811 ///
4812 /// Parses and plans `cypher`; rejects the query if the plan is not in the
4813 /// allowlisted subset (see [`core_query::cypher::is_subscribable`]):
4814 /// - `MATCH (n:Label) WHERE … RETURN … [LIMIT n]`
4815 /// - `MATCH (a)-[r:TYPE]->(b) RETURN … [LIMIT n]` (exactly one hop)
4816 ///
4817 /// SKIP is not supported — it shifts the result window on every commit,
4818 /// causing spurious Added/Removed churn for rows whose data never changed.
4819 /// Multi-hop Expand chains are not supported; each additional MATCH clause
4820 /// widens scope beyond the documented single-scan / single-hop subset.
4821 ///
4822 /// After each successful commit, the plan is **fully re-executed** and the
4823 /// result is diffed against the previous run. Added rows produce
4824 /// [`DbEvent::QueryRowAdded`]; removed rows produce
4825 /// [`DbEvent::QueryRowRemoved`].
4826 ///
4827 /// **Full re-run per commit; use LIMIT to bound execution cost.**
4828 /// The existing 1 M intermediate-row cap applies. Differential evaluation
4829 /// is roadmap / Phase 5.
4830 ///
4831 /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
4832 /// as-of instances never commit, so `distribute_events` never runs and the
4833 /// subscription would never deliver events.
4834 ///
4835 /// Returns `Err(GraphError::QueryError)` if the query fails to parse, plan,
4836 /// or if the plan shape is not in the allowlist.
4837 pub fn subscribe_query(&mut self, cypher: &str) -> Result<Subscription> {
4838 if self.read_only {
4839 return Err(GraphError::ReadOnly);
4840 }
4841 let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
4842 detail: format!("lex: {e}"),
4843 })?;
4844 let ast = parse(&tokens).map_err(|e| GraphError::QueryError {
4845 detail: format!("parse: {e}"),
4846 })?;
4847 let ops = plan(&ast).map_err(|e| GraphError::QueryError {
4848 detail: format!("plan: {e}"),
4849 })?;
4850 if !is_subscribable(&ops) {
4851 return Err(GraphError::QueryError {
4852 detail: "subscribe_query only supports allowlisted plan shapes: \
4853 MATCH (n:Label) WHERE … RETURN … [LIMIT n] or \
4854 MATCH (a)-[r:TYPE]->(b) RETURN … [LIMIT n] (exactly one hop). \
4855 Not supported: multi-hop Expand chains, SKIP (creates \
4856 unstable offset windows), ORDER BY, DISTINCT, aggregates, \
4857 variable-length paths, OPTIONAL MATCH, WITH, UNWIND. \
4858 Use LIMIT to bound re-execution cost."
4859 .to_string(),
4860 });
4861 }
4862 // Execute once to capture initial state (initial rows are not emitted as
4863 // events — the subscriber learns the baseline via the first query call).
4864 let empty_params = BTreeMap::new();
4865 let initial = execute(&self.view(), &ops, &Params(&empty_params)).map_err(|e| {
4866 GraphError::QueryError {
4867 detail: format!("execute: {e}"),
4868 }
4869 })?;
4870 let columns = initial.columns().to_vec();
4871 let prev_row_map = Self::result_to_row_map(&initial);
4872 let inner = SubInner::new(self.sub_capacity());
4873 // Derive the scan-label sym for the commit-skip fast-path. Any Expand op
4874 // or unrecognized leading scan → None (always re-execute).
4875 let scan_label = extract_scan_label(&ops, &mut self.syms);
4876 self.query_subscriptions.push(QuerySubEntry {
4877 ops,
4878 columns,
4879 prev_row_map,
4880 inner: std::sync::Arc::downgrade(&inner),
4881 scan_label,
4882 });
4883 Ok(Subscription(inner))
4884 }
4885
4886 /// Queue capacity used for new subscriptions.
4887 fn sub_capacity(&self) -> usize {
4888 self.sub_capacity
4889 }
4890
4891 /// Override per-subscriber queue capacity for subsequently created
4892 /// subscriptions on this db instance.
4893 ///
4894 /// Default is [`DEFAULT_SUB_CAPACITY`] (65,536 events). Use a smaller
4895 /// value in tests to exercise the [`DbEvent::Lagged`] path without
4896 /// generating tens of thousands of events.
4897 ///
4898 /// This is a test-support escape hatch. Calling it in production reduces
4899 /// subscriber reliability (more Lagged events). It is hidden from rustdoc
4900 /// to discourage accidental production use.
4901 #[doc(hidden)]
4902 pub fn set_sub_capacity(&mut self, capacity: usize) {
4903 self.sub_capacity = capacity;
4904 }
4905
4906 // -----------------------------------------------------------------------
4907
4908 /// Start an atomic batch.
4909 ///
4910 /// The returned [`BatchBuilder`] borrows `self` mutably until
4911 /// [`BatchBuilder::commit`]. Builder methods queue ops only — no
4912 /// validation, no WAL I/O. `commit` validates every queued op against
4913 /// live state plus preceding ops in this batch (duplicate key inside
4914 /// the batch is `Err`; an edge between two nodes created earlier in
4915 /// the batch is valid; `delete_node` then insert of the same key is a
4916 /// fresh identity). Validation never mutates the database. Any failure
4917 /// leaves WAL bytes and in-memory state identical to before `commit`.
4918 /// On success, one `WalRecord::Batch` frame is appended (one fsync)
4919 /// and each inner record is applied in order so rules fire per record.
4920 /// An empty batch, or a batch of only no-ops, writes zero WAL bytes.
4921 ///
4922 /// **Rule-window limitation:** batch validation cannot see edges that a
4923 /// rule created earlier in the *same* batch will derive at apply time, so
4924 /// a `delete_edge` / `insert_edge` in that window is silently no-oped
4925 /// where sequential calls would return `Err(RuleOwned)`. State integrity
4926 /// is unaffected (idempotent apply, provenance intact). Create rules in
4927 /// their own batch, or sequentially, when later ops may touch derived
4928 /// edges.
4929 pub fn batch(&mut self) -> BatchBuilder<'_, F> {
4930 BatchBuilder {
4931 db: self,
4932 ops: Vec::new(),
4933 }
4934 }
4935
4936 /// Closure-style atomic write batch.
4937 ///
4938 /// Equivalent to calling [`GraphDb::batch`], invoking `build` to queue ops,
4939 /// then committing. All ops queued inside `build` are validated in order and
4940 /// committed as a single `WalRecord::Batch` frame (one fsync). Rules fire
4941 /// once per inner record, in order, after commit — semantically identical to
4942 /// sequential single-op writes.
4943 ///
4944 /// **Error semantics — validate-then-apply.** `build` queues ops without
4945 /// touching the database. [`BatchBuilder::commit`] validates every op against
4946 /// live state plus earlier ops in this batch before writing anything. If op N
4947 /// fails validation (duplicate key, unknown key, rule-owned edge, …) the
4948 /// entire batch is rejected: no WAL bytes are written and no in-memory state
4949 /// changes. The database is identical to its state before `write_batch` was
4950 /// called.
4951 ///
4952 /// **Atomicity is crash-level, NOT isolation-level.** On replay after a crash,
4953 /// a partial (torn) `Batch` frame applies NONE of its ops — the frame is
4954 /// either fully applied or not at all. However, while applying a committed
4955 /// batch, concurrent readers may observe intermediate states as ops are applied
4956 /// sequentially in memory. There is no interactive transaction isolation in v1.
4957 /// This is documented as "crash-atomic write batches; no interactive
4958 /// transactions or read isolation."
4959 ///
4960 /// **Returns** `(nodes_inserted, edges_inserted)`. An empty or all-noop batch
4961 /// writes zero WAL bytes and returns `(0, 0)`.
4962 ///
4963 /// # Example
4964 ///
4965 /// ```rust,ignore
4966 /// let (nodes, edges) = db.write_batch(|b| {
4967 /// b.insert_node("Person", "alice", vec![("age".into(), Value::Int(30))]);
4968 /// b.insert_node("Person", "bob", vec![]);
4969 /// b.insert_edge("KNOWS", "alice", "bob");
4970 /// b.set_prop("alice", "role", Value::Str("admin".into()));
4971 /// b.delete_node("old_key");
4972 /// })?;
4973 /// // One fsync; on crash replay: all five ops land or none do.
4974 /// ```
4975 pub fn write_batch<C>(&mut self, build: C) -> Result<(usize, usize)>
4976 where
4977 C: FnOnce(&mut BatchBuilder<'_, F>),
4978 {
4979 let mut b = self.batch();
4980 build(&mut b);
4981 b.commit()
4982 }
4983
4984 /// Insert `rows` as nodes of `label`. One call is one atomic batch:
4985 /// auto-declared KeyMatch rules (if any) first, then the accepted node
4986 /// inserts, so incremental fire sees the new rules. Per-row key problems
4987 /// are collected in [`IngestReport::row_errors`] and skipped; a commit
4988 /// `Err` means nothing was applied.
4989 ///
4990 /// Auto-FK rule names are `auto_fk_<src_label_lowercase>_<field>` so
4991 /// distinct source labels sharing an FK field each get their own rule.
4992 pub fn ingest(
4993 &mut self,
4994 label: &str,
4995 rows: Vec<BTreeMap<String, Value>>,
4996 opts: &IngestOptions,
4997 ) -> Result<IngestReport> {
4998 self.ingest_with_edges(label, rows, opts, &[])
4999 }
5000
5001 /// [`ingest`] plus user edges in the **same** previewed WAL batch.
5002 /// A failing edge rejects the whole request; nothing is applied.
5003 pub fn ingest_with_edges(
5004 &mut self,
5005 label: &str,
5006 rows: Vec<BTreeMap<String, Value>>,
5007 opts: &IngestOptions,
5008 edges: &[(String, String, String)],
5009 ) -> Result<IngestReport> {
5010 crate::ingest::run(self, label, rows, opts, edges)
5011 }
5012
5013 /// Parse `json` as an array of objects and ingest via [`GraphDb::ingest`].
5014 ///
5015 /// JSON `null` fields are silently omitted (not stored, not a row error).
5016 /// Nested objects and arrays-of-objects are a per-row error (row skipped).
5017 /// Parse failures and a top-level value that is not an array of objects
5018 /// return [`GraphError::IngestError`].
5019 pub fn ingest_json(
5020 &mut self,
5021 label: &str,
5022 json: &str,
5023 opts: &IngestOptions,
5024 ) -> Result<IngestReport> {
5025 crate::ingest::run_json(self, label, json, opts)
5026 }
5027
5028 fn commit_logged_batch(
5029 &mut self,
5030 ops: Vec<BatchOp>,
5031 ingest: Option<(String, usize)>,
5032 // Two-source rule: write_batch_authz threads authz here directly (never
5033 // touches pending_write_authz); query_write_authz sets the field instead
5034 // and passes None. Only one source is non-None per call.
5035 param_authz: Option<WriteAuthz>,
5036 ) -> Result<(usize, usize)> {
5037 // Read-only guard: catches empty-batch calls before the early-return
5038 // that skips log_then_apply_with, ensuring all mutation entry points fail.
5039 if self.read_only {
5040 return Err(GraphError::ReadOnly);
5041 }
5042 // Ensure provenance is decoded before MutPreview accesses it
5043 // (note_delete_rule / is_rule_owned may call engine.provenance()).
5044 self.engine.ensure_provenance_loaded_mut();
5045
5046 // ── Authz pre-check ──────────────────────────────────────────────────
5047 // Evaluate the decision table per-op BEFORE MutPreview so that a denial
5048 // produces no WAL frame (all-or-nothing at the authz boundary extends
5049 // the existing validate-then-apply contract to role-scope checks).
5050 //
5051 // `batch_created` tracks key→label for nodes created by earlier ops in
5052 // THIS batch, so InsertEdgeUpsert can count same-batch placeholder nodes
5053 // as visible without needing to call `self.ids.get` on not-yet-committed
5054 // keys (they won't be there yet).
5055 //
5056 // Two-source rule: param_authz (write_batch_authz path) takes precedence;
5057 // fall back to self.pending_write_authz (query_write_authz/Cypher path).
5058 // Cloning the field copy avoids a simultaneous borrow of self.ids below.
5059 let authz_opt = param_authz.or_else(|| self.pending_write_authz.clone());
5060 if let Some(ref authz) = authz_opt {
5061 let mut batch_created: BTreeMap<String, String> = BTreeMap::new();
5062 for op in &ops {
5063 self.check_single_op_authz(authz, op, &batch_created)?;
5064 // Update batch_created after a passing authz check so that
5065 // subsequent ops in this batch see the nodes as "about to exist".
5066 match op {
5067 BatchOp::InsertNode { label, key, .. } => {
5068 // Only track genuinely new nodes (absent from the
5069 // snapshot at authz-check time). A pre-existing visible
5070 // key would be a DuplicateKey — not a real creation —
5071 // so MutPreview handles it. Letting it into batch_created
5072 // would allow a later SetProp to bypass update_labels
5073 // via the "batch-created → always updatable" ruling
5074 // (delete+recreate exploit, fix for I1 review round 2).
5075 //
5076 // Accepted edge: for a delete+recreate-with-different-
5077 // label batch, node_status resolves the pre-delete
5078 // (store) label for any subsequent update checks. This
5079 // grants no net-new capability — a role that can delete+
5080 // create can already place arbitrary props via
5081 // InsertNode's own props field.
5082 if self.ids.get(key.as_str()).is_none() {
5083 batch_created.insert(key.clone(), label.clone());
5084 }
5085 }
5086 BatchOp::InsertEdgeUpsert {
5087 placeholder_label,
5088 src_key,
5089 dst_key,
5090 ..
5091 } => {
5092 // Both endpoints will be created if not already in store.
5093 for ep_key in [src_key, dst_key] {
5094 if self.ids.get(ep_key.as_str()).is_none()
5095 && !batch_created.contains_key(ep_key.as_str())
5096 {
5097 batch_created.insert(ep_key.clone(), placeholder_label.clone());
5098 }
5099 }
5100 }
5101 _ => {}
5102 }
5103 }
5104 }
5105
5106 let recs = {
5107 let mut preview = MutPreview::new(self);
5108 let mut recs = Vec::with_capacity(ops.len());
5109 for op in ops {
5110 match op {
5111 BatchOp::InsertNode { label, key, props } => {
5112 preview.check_insert_node(&key)?;
5113 preview.note_insert_node(&key, &props);
5114 recs.push(WalRecord::InsertNode { label, key, props });
5115 }
5116 BatchOp::InsertEdge {
5117 edge_type,
5118 src_key,
5119 dst_key,
5120 } => {
5121 if preview.prepare_insert_edge(&edge_type, &src_key, &dst_key)? {
5122 preview.note_insert_edge(&edge_type, &src_key, &dst_key);
5123 recs.push(WalRecord::InsertEdge {
5124 edge_type,
5125 src_key,
5126 dst_key,
5127 });
5128 }
5129 }
5130 BatchOp::SetProp { key, field, value } => {
5131 preview.check_live_key(&key)?;
5132 preview.note_set_prop(&key, &field, &value);
5133 recs.push(WalRecord::SetProp { key, field, value });
5134 }
5135 BatchOp::RemoveProp { key, field } => {
5136 if preview.prepare_remove_prop(&key, &field)? {
5137 preview.note_remove_prop(&key, &field);
5138 recs.push(WalRecord::RemoveProp { key, field });
5139 }
5140 }
5141 BatchOp::DeleteEdge {
5142 edge_type,
5143 src_key,
5144 dst_key,
5145 } => {
5146 if preview.prepare_delete_edge(&edge_type, &src_key, &dst_key)? {
5147 preview.note_delete_edge(&edge_type, &src_key, &dst_key);
5148 recs.push(WalRecord::DeleteEdge {
5149 edge_type,
5150 src_key,
5151 dst_key,
5152 });
5153 }
5154 }
5155 BatchOp::DeleteNode { key } => {
5156 preview.check_live_key(&key)?;
5157 preview.note_delete_node(&key);
5158 recs.push(WalRecord::DeleteNode { key });
5159 }
5160 BatchOp::CreateRule(def) => {
5161 preview.check_create_rule(&def)?;
5162 let def_bytes =
5163 bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
5164 detail: format!("serialize rule: {e}"),
5165 })?;
5166 preview.note_create_rule(&def);
5167 recs.push(WalRecord::CreateRule { def_bytes });
5168 }
5169 BatchOp::DeleteRule { name } => {
5170 preview.check_delete_rule(&name)?;
5171 preview.note_delete_rule(&name);
5172 recs.push(WalRecord::DeleteRule { name });
5173 }
5174 BatchOp::RenameNode { old_key, new_key } => {
5175 preview.check_rename_node(&old_key, &new_key)?;
5176 preview.note_rename_node(&old_key, &new_key);
5177 recs.push(WalRecord::RenameNode { old_key, new_key });
5178 }
5179 BatchOp::InsertEdgeUpsert {
5180 edge_type,
5181 src_key,
5182 dst_key,
5183 placeholder_label,
5184 } => {
5185 // Auto-create any missing endpoints as plain InsertNode ops.
5186 // Rules fire and last-change is updated for each created node.
5187 for key in [&src_key, &dst_key] {
5188 if !preview.has_key(key) {
5189 preview.check_insert_node(key)?;
5190 preview.note_insert_node(key, &[]);
5191 recs.push(WalRecord::InsertNode {
5192 label: placeholder_label.clone(),
5193 key: key.clone(),
5194 props: vec![],
5195 });
5196 }
5197 }
5198 if preview.prepare_insert_edge(&edge_type, &src_key, &dst_key)? {
5199 preview.note_insert_edge(&edge_type, &src_key, &dst_key);
5200 recs.push(WalRecord::InsertEdge {
5201 edge_type,
5202 src_key,
5203 dst_key,
5204 });
5205 }
5206 }
5207 }
5208 }
5209 recs
5210 };
5211 if recs.is_empty() {
5212 return Ok((0, 0));
5213 }
5214 // rewrite_wal_dense converts every InsertNode/InsertEdge into its
5215 // *Id form, so only the dense variants can appear in `recs` here.
5216 let recs = self.rewrite_wal_dense(recs)?;
5217 let nodes_inserted = recs
5218 .iter()
5219 .filter(|r| matches!(r, WalRecord::InsertNodeId { .. }))
5220 .count();
5221 let edges_inserted = recs
5222 .iter()
5223 .filter(|r| matches!(r, WalRecord::InsertEdgeId { .. }))
5224 .count();
5225 // Ingest / write_batch / query_write: one Batch frame, one fsync per call
5226 // under Strict. Pass self.fsync directly so Strict stays Strict —
5227 // wal_needs_sync(Strict, _) always returns true regardless of op count.
5228 // Mapping Strict → Batched (the prior bug) caused wal_needs_sync to
5229 // short-circuit on single-op batches and silently skip the fsync.
5230 // Batched fsyncs only for multi-op batches; Relaxed always skips.
5231 self.log_then_apply_with(WalRecord::Batch(recs), ingest, self.fsync)?;
5232 Ok((nodes_inserted, edges_inserted))
5233 }
5234
5235 fn commit_batch(&mut self, ops: Vec<BatchOp>) -> Result<(usize, usize)> {
5236 self.commit_logged_batch(ops, None, None)
5237 }
5238
5239 /// Commit one submission WITHOUT an fsync — for use inside `commit_group`
5240 /// and the group-commit drain thread, which do a single group fsync later.
5241 fn commit_batch_nosync(&mut self, ops: Vec<BatchOp>) -> Result<(usize, usize)> {
5242 // Restore fsync policy even on panic via a raw-pointer drop guard.
5243 // A panic here would poison the RwLock anyway, but the correct policy
5244 // must be in place if the guard is ever unwrapped.
5245 struct RestoreFsync(*mut FsyncPolicy, FsyncPolicy);
5246 impl Drop for RestoreFsync {
5247 fn drop(&mut self) {
5248 // SAFETY: the pointer is valid for the full duration of
5249 // commit_batch_nosync; the guard is dropped before the frame
5250 // returns, and GraphDb outlives this frame.
5251 unsafe {
5252 *self.0 = self.1;
5253 }
5254 }
5255 }
5256 let saved = self.fsync;
5257 // SAFETY: raw pointer into self; guard dropped within this frame.
5258 let _g = RestoreFsync(&mut self.fsync as *mut FsyncPolicy, saved);
5259 self.fsync = FsyncPolicy::Relaxed;
5260 self.commit_logged_batch(ops, None, None)
5261 }
5262
5263 /// Commit multiple op-batches as a **group**: each submission gets its own
5264 /// WAL `Batch` frame, but there is exactly **one** `Fs::sync` for the whole
5265 /// group (under `Strict` / `Batched` policy; `Relaxed` skips all syncs).
5266 ///
5267 /// # Durability semantics
5268 ///
5269 /// A crash before the group fsync may lose **all** submissions in the group.
5270 /// A crash after the group fsync preserves all of them. No submission is
5271 /// ever torn: each WAL frame is either fully applied on replay or dropped
5272 /// in its entirety (CRC-protected frame boundaries).
5273 ///
5274 /// Events and subscription notifications fire per-submission immediately
5275 /// after apply, which may be before the group fsync. From a subscriber's
5276 /// perspective this is equivalent to the `Relaxed` durability window.
5277 /// Submitters using [`SharedDb::submit_batch`] only unblock after the group
5278 /// fsync, so from their perspective durability is fully guaranteed.
5279 ///
5280 /// # MVCC interplay
5281 ///
5282 /// Each submission records its own `CommitDelta`; the fold-every-K counter
5283 /// increments per submission (not per group), preserving existing reader
5284 /// snapshot semantics.
5285 ///
5286 /// # Returns
5287 ///
5288 /// One `Result<(nodes_inserted, edges_inserted)>` per input group element,
5289 /// in order. Failures are per-submission (validation errors); the group
5290 /// fsync error (if any) is returned as the second tuple element.
5291 pub fn commit_group(
5292 &mut self,
5293 groups: Vec<Vec<BatchOp>>,
5294 ) -> (Vec<Result<(usize, usize)>>, Option<GraphError>) {
5295 let mut results = Vec::with_capacity(groups.len());
5296 for ops in groups {
5297 results.push(self.commit_batch_nosync(ops));
5298 }
5299 let any_ok = results.iter().any(|r| r.is_ok());
5300 let sync_err = if self.fsync != FsyncPolicy::Relaxed && any_ok {
5301 self.fs
5302 .sync(core_storage::fs::FileId::Wal)
5303 .map_err(GraphError::Io)
5304 .err()
5305 } else {
5306 None
5307 };
5308 (results, sync_err)
5309 }
5310
5311 /// Like [`commit_group`] but skips the group fsync entirely.
5312 ///
5313 /// Used by the drain thread to apply submissions under the write lock and
5314 /// then perform the single fsync OUTSIDE the lock (via
5315 /// `core_storage::sync_wal_at`), reducing the write-lock hold time visible
5316 /// to concurrent readers.
5317 pub fn commit_group_nosync(
5318 &mut self,
5319 groups: Vec<Vec<BatchOp>>,
5320 ) -> Vec<Result<(usize, usize)>> {
5321 let mut results = Vec::with_capacity(groups.len());
5322 for ops in groups {
5323 results.push(self.commit_batch_nosync(ops));
5324 }
5325 results
5326 }
5327
5328 pub fn insert_node(
5329 &mut self,
5330 label: &str,
5331 key: &str,
5332 props: Vec<(String, Value)>,
5333 ) -> Result<()> {
5334 if self.read_only {
5335 return Err(GraphError::ReadOnly);
5336 }
5337 MutPreview::new(self).check_insert_node(key)?;
5338 self.log_dense(vec![WalRecord::InsertNode {
5339 label: label.into(),
5340 key: key.into(),
5341 props,
5342 }])
5343 }
5344
5345 pub fn insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
5346 if self.read_only {
5347 return Err(GraphError::ReadOnly);
5348 }
5349 if !MutPreview::new(self).prepare_insert_edge(edge_type, src_key, dst_key)? {
5350 return Ok(false);
5351 }
5352 self.log_dense(vec![WalRecord::InsertEdge {
5353 edge_type: edge_type.into(),
5354 src_key: src_key.into(),
5355 dst_key: dst_key.into(),
5356 }])?;
5357 Ok(true)
5358 }
5359
5360 pub fn set_prop(&mut self, key: &str, field: &str, value: Value) -> Result<()> {
5361 if self.read_only {
5362 return Err(GraphError::ReadOnly);
5363 }
5364 if let Some(view_name) = self.view_store.view_for_prop(field) {
5365 return Err(GraphError::ViewPropReadOnly {
5366 view_name: view_name.to_string(),
5367 });
5368 }
5369 MutPreview::new(self).check_live_key(key)?;
5370 self.log_dense(vec![WalRecord::SetProp {
5371 key: key.into(),
5372 field: field.into(),
5373 value,
5374 }])
5375 }
5376
5377 /// Remove a property. Returns `Ok(false)` (and does not log) if the field
5378 /// is already absent. Unknown or tombstoned keys are `Err(KeyNotFound)`.
5379 pub fn remove_prop(&mut self, key: &str, field: &str) -> Result<bool> {
5380 if self.read_only {
5381 return Err(GraphError::ReadOnly);
5382 }
5383 if let Some(view_name) = self.view_store.view_for_prop(field) {
5384 return Err(GraphError::ViewPropReadOnly {
5385 view_name: view_name.to_string(),
5386 });
5387 }
5388 if !MutPreview::new(self).prepare_remove_prop(key, field)? {
5389 return Ok(false);
5390 }
5391 self.log_then_apply(WalRecord::RemoveProp {
5392 key: key.into(),
5393 field: field.into(),
5394 })?;
5395 Ok(true)
5396 }
5397
5398 /// Delete a user edge. Returns `Ok(false)` (and does not log) if the edge
5399 /// is absent. Unknown keys are `Err(KeyNotFound)`. Rule-owned edges — in
5400 /// provenance, or a pair a live rule would derive — are `Err(RuleOwned)`
5401 /// (the rule would just put the edge back; delete or change the rule).
5402 pub fn delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
5403 if self.read_only {
5404 return Err(GraphError::ReadOnly);
5405 }
5406 if !MutPreview::new(self).prepare_delete_edge(edge_type, src_key, dst_key)? {
5407 return Ok(false);
5408 }
5409 self.log_then_apply(WalRecord::DeleteEdge {
5410 edge_type: edge_type.into(),
5411 src_key: src_key.into(),
5412 dst_key: dst_key.into(),
5413 })?;
5414 Ok(true)
5415 }
5416
5417 /// Delete a live node. Unknown or already-tombstoned keys are
5418 /// `Err(KeyNotFound)` and are not logged. Validation runs before the WAL
5419 /// write; `apply` of a logged `DeleteNode` for an already-tombstoned key
5420 /// (crash window) is a clean no-op.
5421 ///
5422 /// Returns a [`DeleteReport`] with counts of manual and derived edges
5423 /// removed (computed from live state before the deletion is applied).
5424 pub fn delete_node(&mut self, key: &str) -> Result<DeleteReport> {
5425 if self.read_only {
5426 return Err(GraphError::ReadOnly);
5427 }
5428 // Provenance must be loaded before we query provenance_touching.
5429 self.engine.ensure_provenance_loaded_mut();
5430 let id = self
5431 .ids
5432 .get(key)
5433 .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
5434
5435 // Count edges before the delete is applied so we can report counts.
5436 let derived_set: BTreeSet<(u32, u32, u32)> = self
5437 .engine
5438 .provenance_touching(id)
5439 .map(|(_, etype, src, dst)| (etype, src, dst))
5440 .collect();
5441 let derived_edges = derived_set.len() as u64;
5442
5443 let mut total_topo = 0u64;
5444 let tv = self.topo_view();
5445 for et in tv.etypes() {
5446 total_topo += tv.neighbors(et, Direction::Out, id).len() as u64
5447 + tv.neighbors(et, Direction::In, id).len() as u64;
5448 }
5449 // For symmetric rules (e.g. Overlap), a→b and b→a are two separate directed
5450 // triples in both the topo scan (Out and In from id) and in provenance_touching.
5451 // The subtraction remains correct because both counts include both directions.
5452 let manual_edges = total_topo.saturating_sub(derived_edges);
5453
5454 self.log_then_apply(WalRecord::DeleteNode { key: key.into() })?;
5455 Ok(DeleteReport {
5456 manual_edges,
5457 derived_edges,
5458 })
5459 }
5460
5461 /// Rename a live node's key. The dense id (and therefore all edges,
5462 /// props, history, and last-change tracking) is unaffected.
5463 ///
5464 /// Returns `Err(KeyNotFound)` if `old` is not a live key.
5465 /// Returns `Err(DuplicateKey)` if `new` is already live.
5466 pub fn rename_node(&mut self, old: &str, new: &str) -> Result<()> {
5467 if self.read_only {
5468 return Err(GraphError::ReadOnly);
5469 }
5470 MutPreview::new(self).check_rename_node(old, new)?;
5471 self.log_then_apply(WalRecord::RenameNode {
5472 old_key: old.into(),
5473 new_key: new.into(),
5474 })
5475 }
5476
5477 /// Return the IVF drift counter for the dst-side candidate index of `rule`.
5478 /// `None` if the rule does not exist or is not approximate.
5479 ///
5480 /// The drift counter increments on IVF insert/remove after the last fit.
5481 /// When dst-side drift exceeds [`core_rules::IVF_DRIFT_REBUILD`], apply
5482 /// WAL-logs `RebuildRule` as a second commit (rebuild resets the counter).
5483 pub fn ivf_dst_drift(&self, rule: &str) -> Option<u64> {
5484 // SideIvfExport = (centroids, node→cluster, drift)
5485 self.engine
5486 .export_ivf_state()
5487 .remove(rule)
5488 .map(|(_src, dst)| dst.2)
5489 }
5490
5491 /// Validate and WAL-log a new rule, then backfill derived edges inside apply.
5492 /// Validation and duplicate-name check run before logging so invalid rules
5493 /// never enter the WAL.
5494 pub fn create_rule(&mut self, def: RuleDef) -> Result<()> {
5495 if self.read_only {
5496 return Err(GraphError::ReadOnly);
5497 }
5498 MutPreview::new(self).check_create_rule(&def)?;
5499 let def_bytes = bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
5500 detail: format!("serialize rule: {e}"),
5501 })?;
5502 self.log_then_apply(WalRecord::CreateRule { def_bytes })
5503 }
5504
5505 /// WAL-log rule deletion. Returns RuleNotFound if the rule does not exist.
5506 pub fn delete_rule(&mut self, name: &str) -> Result<()> {
5507 if self.read_only {
5508 return Err(GraphError::ReadOnly);
5509 }
5510 MutPreview::new(self).check_delete_rule(name)?;
5511 self.log_then_apply(WalRecord::DeleteRule { name: name.into() })
5512 }
5513
5514 /// Return a snapshot of all registered rules.
5515 pub fn rules(&self) -> Vec<RuleDef> {
5516 self.engine.rules().cloned().collect()
5517 }
5518
5519 // -----------------------------------------------------------------------
5520 // Rule suggestion API
5521 // -----------------------------------------------------------------------
5522
5523 /// Profile the database and suggest linking rules with previewed edge counts.
5524 ///
5525 /// Uses the default seed ([`core_rules::SUGGEST_DEFAULT_SEED`]) for deterministic
5526 /// sampling. Suggestions are sorted by estimated edge count (descending).
5527 /// **NO auto-accept** — call [`GraphDb::create_rule`] explicitly to apply.
5528 pub fn suggest_rules(&self) -> Vec<core_rules::RuleSuggestion> {
5529 self.suggest_rules_seeded(core_rules::SUGGEST_DEFAULT_SEED)
5530 }
5531
5532 /// Like [`suggest_rules`] but with a caller-supplied RNG seed for
5533 /// reproducibility. Same seed + same data = identical output.
5534 pub fn suggest_rules_seeded(&self, seed: u64) -> Vec<core_rules::RuleSuggestion> {
5535 self.suggest_rules_with_config(&core_rules::suggest::SuggestConfig::default(), seed)
5536 .suggestions
5537 }
5538
5539 /// [`suggest_rules_seeded`] with a fully custom [`SuggestConfig`].
5540 ///
5541 /// Returns a [`core_rules::SuggestReport`] that includes both the candidate list
5542 /// and a `truncated` flag indicating whether the global budget fired before all
5543 /// candidates were evaluated.
5544 pub fn suggest_rules_with_config(
5545 &self,
5546 config: &core_rules::suggest::SuggestConfig,
5547 seed: u64,
5548 ) -> core_rules::SuggestReport {
5549 use std::collections::BTreeMap;
5550
5551 // Collect (node_id, key) pairs per label, skipping tombstoned nodes.
5552 let mut label_nodes: BTreeMap<String, Vec<(u32, String)>> = BTreeMap::new();
5553 for id in 0..self.ids.len() as u32 {
5554 let Some(key) = self.ids.key_of(id) else {
5555 continue;
5556 };
5557 let Some(&sym) = self.labels.get(id as usize) else {
5558 continue;
5559 };
5560 if sym == u32::MAX {
5561 continue; // tombstoned
5562 }
5563 let Some(label) = self.syms.resolve(sym) else {
5564 continue;
5565 };
5566 label_nodes
5567 .entry(label.to_string())
5568 .or_default()
5569 .push((id, key.to_string()));
5570 }
5571
5572 let existing = self.rules();
5573 let pv = build_props_view(&self.props, &self.base);
5574 let all_fields: Vec<String> = pv.field_names();
5575
5576 core_rules::suggest::suggest_rules(
5577 &label_nodes,
5578 &|id, field| pv.get(id, field).map(|vr| vr.into_value()),
5579 &all_fields,
5580 &existing,
5581 config,
5582 seed,
5583 )
5584 }
5585
5586 /// Recompute a rule's derived edges from scratch. WAL-logged so un-trip
5587 /// plus later mutations replay identically (rebuild is a pure function
5588 /// of state).
5589 ///
5590 /// Only exit from the tripped latch: if the full desired set fits the
5591 /// budget, it is applied completely and `tripped` clears; if it still
5592 /// exceeds the budget, provenance is left untouched and `tripped` stays
5593 /// true. Counts as a fire evaluation (see [`RuleStats::fires`]).
5594 /// Unknown rule → `RuleNotFound`, nothing logged.
5595 pub fn rebuild_rule(&mut self, name: &str) -> Result<()> {
5596 if self.read_only {
5597 return Err(GraphError::ReadOnly);
5598 }
5599 if !self.engine.rules().any(|r| r.name == name) {
5600 return Err(GraphError::RuleNotFound { name: name.into() });
5601 }
5602 self.log_then_apply(WalRecord::RebuildRule { name: name.into() })
5603 }
5604
5605 // -----------------------------------------------------------------------
5606 // Materialized view API
5607 // -----------------------------------------------------------------------
5608
5609 /// Register a new materialized property view, backfill its values for all
5610 /// existing nodes, and WAL-log the definition.
5611 ///
5612 /// # Errors
5613 /// - `ReadOnly`: called on an as-of instance.
5614 /// - `RuleInvalid`: name collision, view_prop collision, or invalid def.
5615 pub fn create_view(&mut self, def: ViewDef) -> Result<()> {
5616 if self.read_only {
5617 return Err(GraphError::ReadOnly);
5618 }
5619 // Pre-validate before WAL write.
5620 def.validate()
5621 .map_err(|e| GraphError::RuleInvalid { detail: e })?;
5622 if self.view_store.has_view(&def.name) {
5623 return Err(GraphError::RuleInvalid {
5624 detail: format!("view {:?} already exists", def.name),
5625 });
5626 }
5627 if let Some(existing) = self.view_store.view_for_prop(&def.view_prop) {
5628 return Err(GraphError::RuleInvalid {
5629 detail: format!(
5630 "view_prop {:?} is already used by view {:?}",
5631 def.view_prop, existing
5632 ),
5633 });
5634 }
5635 let def_bytes = bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
5636 detail: format!("serialize view: {e}"),
5637 })?;
5638 // Enable delta accumulation before the view is registered so subsequent
5639 // incremental edge events reach view maintenance from this point onward.
5640 // (The backfill inside create_view reads topo directly; it does not rely
5641 // on pending deltas.)
5642 self.engine.set_emit_deltas(true);
5643 self.log_then_apply(WalRecord::CreateView { def_bytes })
5644 }
5645
5646 /// Remove a named view and delete its values from every node.
5647 ///
5648 /// # Errors
5649 /// - `ReadOnly`: called on an as-of instance.
5650 /// - `RuleNotFound`: view does not exist.
5651 pub fn delete_view(&mut self, name: &str) -> Result<()> {
5652 if self.read_only {
5653 return Err(GraphError::ReadOnly);
5654 }
5655 if !self.view_store.has_view(name) {
5656 return Err(GraphError::RuleNotFound { name: name.into() });
5657 }
5658 let result = self.log_then_apply(WalRecord::DeleteView { name: name.into() });
5659 // After deletion, disable accumulation if no listeners remain.
5660 if !self.needs_emit_deltas() {
5661 self.engine.set_emit_deltas(false);
5662 }
5663 result
5664 }
5665
5666 /// Snapshot of all registered view definitions.
5667 pub fn views(&self) -> Vec<ViewDef> {
5668 self.view_store.views().cloned().collect()
5669 }
5670
5671 // -----------------------------------------------------------------------
5672 // Full-text-lite API
5673 // -----------------------------------------------------------------------
5674
5675 /// Enable full-text indexing for all nodes of `label` on property `field`.
5676 ///
5677 /// After this call, every subsequent write to `(label, field)` is reflected
5678 /// in the index incrementally. Existing nodes are backfilled immediately.
5679 /// The declaration is persisted as a WAL record; the index itself is rebuilt
5680 /// from scratch on re-open (no snapshot format changes).
5681 ///
5682 /// # Errors
5683 /// - [`GraphError::ReadOnly`]: called on an as-of instance.
5684 /// - [`GraphError::RuleInvalid`]: `(label, field)` is already indexed.
5685 pub fn enable_fulltext(&mut self, label: &str, field: &str) -> Result<()> {
5686 if self.read_only {
5687 return Err(GraphError::ReadOnly);
5688 }
5689 if self.fulltext.is_enabled(label, field) {
5690 return Err(GraphError::RuleInvalid {
5691 detail: format!("full-text index for ({label:?}, {field:?}) already enabled"),
5692 });
5693 }
5694 self.log_then_apply(WalRecord::EnableFulltext {
5695 label: label.into(),
5696 field: field.into(),
5697 })
5698 }
5699
5700 /// Disable full-text indexing for `(label, field)` and drop its postings.
5701 ///
5702 /// # Errors
5703 /// - [`GraphError::ReadOnly`]: called on an as-of instance.
5704 /// - [`GraphError::RuleNotFound`]: `(label, field)` is not currently indexed.
5705 pub fn disable_fulltext(&mut self, label: &str, field: &str) -> Result<()> {
5706 if self.read_only {
5707 return Err(GraphError::ReadOnly);
5708 }
5709 if !self.fulltext.is_enabled(label, field) {
5710 return Err(GraphError::RuleNotFound {
5711 name: format!("fulltext({label},{field})"),
5712 });
5713 }
5714 self.log_then_apply(WalRecord::DisableFulltext {
5715 label: label.into(),
5716 field: field.into(),
5717 })
5718 }
5719
5720 /// Whether `(label, field)` is currently indexed for full-text search.
5721 pub fn is_fulltext_enabled(&self, label: &str, field: &str) -> bool {
5722 self.fulltext.is_enabled(label, field)
5723 }
5724
5725 /// Every `(label, field)` pair with a live full-text index, sorted.
5726 ///
5727 /// Note that [`GraphDb::search`] is keyed by field alone — a pair only
5728 /// declares which nodes are *indexed*, so callers that want to search
5729 /// everything indexed should query each distinct field once.
5730 pub fn fulltext_pairs(&self) -> Vec<(String, String)> {
5731 let mut v: Vec<(String, String)> = self.fulltext.enabled_pairs().cloned().collect();
5732 v.sort();
5733 v
5734 }
5735
5736 /// Enable an equality index for all nodes of `label` on scalar property
5737 /// `field`. Subsequent `WHERE n.field = value` lookups become O(matches)
5738 /// instead of an O(N_label) scan. Existing nodes are backfilled; the
5739 /// declaration persists via WAL and the postings rebuild on re-open.
5740 ///
5741 /// # Errors
5742 /// - [`GraphError::ReadOnly`]: called on an as-of instance.
5743 /// - [`GraphError::RuleInvalid`]: `(label, field)` is already indexed.
5744 pub fn enable_index(&mut self, label: &str, field: &str) -> Result<()> {
5745 if self.read_only {
5746 return Err(GraphError::ReadOnly);
5747 }
5748 if self.prop_index.is_enabled(label, field) {
5749 return Err(GraphError::RuleInvalid {
5750 detail: format!("property index for ({label:?}, {field:?}) already enabled"),
5751 });
5752 }
5753 self.log_then_apply(WalRecord::EnableIndex {
5754 label: label.into(),
5755 field: field.into(),
5756 })
5757 }
5758
5759 /// Disable the equality index for `(label, field)` and drop its postings.
5760 ///
5761 /// # Errors
5762 /// - [`GraphError::ReadOnly`]: called on an as-of instance.
5763 /// - [`GraphError::RuleNotFound`]: `(label, field)` is not currently indexed.
5764 pub fn disable_index(&mut self, label: &str, field: &str) -> Result<()> {
5765 if self.read_only {
5766 return Err(GraphError::ReadOnly);
5767 }
5768 if !self.prop_index.is_enabled(label, field) {
5769 return Err(GraphError::RuleNotFound {
5770 name: format!("index({label},{field})"),
5771 });
5772 }
5773 self.log_then_apply(WalRecord::DisableIndex {
5774 label: label.into(),
5775 field: field.into(),
5776 })
5777 }
5778
5779 /// Whether `(label, field)` currently has an equality index.
5780 pub fn is_index_enabled(&self, label: &str, field: &str) -> bool {
5781 self.prop_index.is_enabled(label, field)
5782 }
5783
5784 /// Search a full-text-indexed field.
5785 ///
5786 /// Returns `(node_key, match_count)` pairs sorted by match_count descending,
5787 /// ties broken by key (lexicographic). Tombstoned nodes are excluded.
5788 ///
5789 /// **Query syntax:**
5790 /// - Space-separated terms are AND'd: `"foo bar"` requires both.
5791 /// - `OR` between terms forms disjunction: `"foo OR bar"` matches either.
5792 /// - Trailing `*` on a term is a prefix match: `"rust*"` matches `rustlang`, `rusty`.
5793 /// - `AND` keyword is accepted explicitly and is the default.
5794 /// - Tokenization is unicode-alphanumeric (same as index time); case-insensitive.
5795 ///
5796 /// **Unindexed field:** returns `Ok(vec![])` if `field` is not indexed.
5797 /// Pin: this is the documented, tested, stable behavior for v1.
5798 ///
5799 /// **Memory / performance:** O(postings) lookup; no scan. The index is
5800 /// in-memory and proportional to total indexed text across all enabled fields.
5801 ///
5802 /// **v2 grammar:** supports `"phrase"`, `-negation`, `prefix*`, `OR`, `AND`.
5803 /// Results are BM25-scored (k1=1.2, b=0.75) and sorted by score descending,
5804 /// key ascending for deterministic tiebreaking.
5805 pub fn search(&self, field: &str, query: &str) -> Vec<(String, f64)> {
5806 // Resolve node_ids to keys (excluding tombstones) then re-sort by
5807 // (score DESC, key ASC) to give a deterministic, key-lexicographic
5808 // tiebreak. FulltextIndex::search sorts by (score DESC, node_id ASC)
5809 // which diverges from key order when nodes were not inserted in key-lex order.
5810 let mut results: Vec<(String, f64)> = self
5811 .fulltext
5812 .search(field, query, 0)
5813 .into_iter()
5814 .filter_map(|(id, score)| self.ids.key_of(id).map(|key| (key.to_string(), score)))
5815 .collect();
5816 results.sort_by(|a, b| {
5817 b.1.partial_cmp(&a.1)
5818 .unwrap_or(std::cmp::Ordering::Equal)
5819 .then(a.0.cmp(&b.0))
5820 });
5821 results
5822 }
5823
5824 /// [`search`](Self::search), stopping at the `k` best hits.
5825 ///
5826 /// Same ranking and the same deterministic tiebreak, but the index drops
5827 /// everything past `k` before any key is resolved, so a caller that wants
5828 /// the top few out of a field that matched thousands does not pay to
5829 /// materialise and re-sort the tail. `k == 0` means no limit, exactly as
5830 /// [`search`](Self::search) behaves.
5831 ///
5832 /// The BM25 scoring itself is not bounded by `k` — every candidate is
5833 /// scored either way — so this trims the resolve and the sort, not the
5834 /// search.
5835 pub fn search_top(&self, field: &str, query: &str, k: usize) -> Vec<(String, f64)> {
5836 // A tombstoned id resolves to nothing, so asking the index for exactly
5837 // `k` could return fewer. Over-fetching a little and truncating after
5838 // the filter keeps the count right without unbounding the call.
5839 let want = if k == 0 { 0 } else { k.saturating_mul(2) };
5840 let mut results: Vec<(String, f64)> = self
5841 .fulltext
5842 .search(field, query, want)
5843 .into_iter()
5844 .filter_map(|(id, score)| self.ids.key_of(id).map(|key| (key.to_string(), score)))
5845 .collect();
5846 results.sort_by(|a, b| {
5847 b.1.partial_cmp(&a.1)
5848 .unwrap_or(std::cmp::Ordering::Equal)
5849 .then(a.0.cmp(&b.0))
5850 });
5851 if k > 0 {
5852 results.truncate(k);
5853 }
5854 results
5855 }
5856
5857 /// Hybrid search: Reciprocal Rank Fusion (RRF) over fulltext + vector results.
5858 ///
5859 /// Takes up to `4*k` fulltext hits for `(text_field, query_text)` and up to
5860 /// `4*k` vector hits for `(vector_field, query_vec, min=0.0)`, then fuses
5861 /// them with RRF using a fixed constant of 60.
5862 ///
5863 /// ```text
5864 /// score(d) = Σ 1 / (60 + rank_i(d)) (rank 1-based per list)
5865 /// ```
5866 ///
5867 /// Returns the top `k` nodes by fused score, ties broken by node key
5868 /// ascending (deterministic).
5869 ///
5870 /// # Vector leg fallback
5871 ///
5872 /// When `query_vec` is empty the vector leg is skipped entirely and
5873 /// results are ranked by the text list alone through the same RRF path
5874 /// (each text result scores `1/(60 + rank)` from that single list).
5875 ///
5876 /// When `label` is `None`, the vector leg **always** returns empty results.
5877 /// Internally `label` is mapped to `""`, which does not match any rule-created
5878 /// HNSW index (all such indexes are keyed to a specific non-empty label), and
5879 /// the brute-force fallback finds no nodes with an empty label. The fused
5880 /// ranking is therefore text-only in this case.
5881 pub fn search_hybrid(
5882 &self,
5883 text_field: &str,
5884 query_text: &str,
5885 vector_field: &str,
5886 query_vec: &[f64],
5887 label: Option<&str>,
5888 k: usize,
5889 ) -> Vec<(String, f64)> {
5890 use std::collections::HashMap;
5891
5892 const RRF_K: f64 = 60.0;
5893 let pool = 4 * k;
5894
5895 // Accumulate per-node RRF scores.
5896 let mut scores: HashMap<String, f64> = HashMap::new();
5897
5898 // Text leg.
5899 let text_hits = self.search(text_field, query_text);
5900 for (rank0, (key, _count)) in text_hits.into_iter().take(pool).enumerate() {
5901 let rank = (rank0 + 1) as f64;
5902 *scores.entry(key).or_insert(0.0) += 1.0 / (RRF_K + rank);
5903 }
5904
5905 // Vector leg (skipped when query_vec is empty).
5906 if !query_vec.is_empty() {
5907 let vec_hits = self.find_similar_vector(vector_field, label, query_vec, pool, 0.0);
5908 for (rank0, (key, _sim)) in vec_hits.into_iter().enumerate() {
5909 let rank = (rank0 + 1) as f64;
5910 *scores.entry(key).or_insert(0.0) += 1.0 / (RRF_K + rank);
5911 }
5912 }
5913
5914 // Sort: score DESC, then key ASC for deterministic tie-breaking.
5915 let mut ranked: Vec<(String, f64)> = scores.into_iter().collect();
5916 ranked.sort_by(|a, b| {
5917 b.1.partial_cmp(&a.1)
5918 .unwrap_or(std::cmp::Ordering::Equal)
5919 .then(a.0.cmp(&b.0))
5920 });
5921 ranked.truncate(k);
5922 ranked
5923 }
5924
5925 /// For DST/testing: scratch BM25 search over live nodes without the index.
5926 /// Walks every live node, re-stems field tokens, computes corpus stats, and
5927 /// returns BM25-ranked results.
5928 ///
5929 /// The oracle: the ordered key list of `search(field, q)` must equal that of
5930 /// `scratch_search(field, q)` at every quiescent state.
5931 #[doc(hidden)]
5932 pub fn scratch_search(&self, field: &str, query: &str) -> Vec<(String, f64)> {
5933 use core_storage::fulltext::{parse_query, value_tokens_stemmed_with_positions};
5934 use std::collections::BTreeMap;
5935
5936 let groups = parse_query(query);
5937 if groups.is_empty() {
5938 return vec![];
5939 }
5940
5941 // --- Pass 1: collect all live indexed nodes with stemmed token data ---
5942 struct NodeData {
5943 key: String,
5944 /// stemmed_token → positions (sorted)
5945 tokens: BTreeMap<String, Vec<u32>>,
5946 dl: u32,
5947 }
5948
5949 let mut nodes: Vec<NodeData> = Vec::new();
5950 for id in 0..self.ids.len() as u32 {
5951 let Some(key) = self.ids.key_of(id) else {
5952 continue;
5953 };
5954 let Some(&sym) = self.labels.get(id as usize) else {
5955 continue;
5956 };
5957 if sym == u32::MAX {
5958 continue;
5959 }
5960 let label = match self.syms.resolve(sym) {
5961 Some(l) => l,
5962 None => continue,
5963 };
5964 if !self.fulltext.is_enabled(label, field) {
5965 continue;
5966 }
5967 let Some(value) = self.props_view().get(id, field).map(|vr| vr.into_value()) else {
5968 continue;
5969 };
5970 // Use value_tokens_stemmed_with_positions so list elements are
5971 // separated by POSITION_GAP — identical to the index path, which
5972 // prevents phrase queries from matching across element boundaries.
5973 let stemmed_with_pos = match &value {
5974 Value::Str(_) | Value::List(_) => value_tokens_stemmed_with_positions(&value),
5975 _ => continue,
5976 };
5977 let dl = stemmed_with_pos.len() as u32;
5978 let mut tok_map: BTreeMap<String, Vec<u32>> = BTreeMap::new();
5979 for (tok, pos) in stemmed_with_pos {
5980 tok_map.entry(tok).or_default().push(pos);
5981 }
5982 nodes.push(NodeData {
5983 key: key.to_string(),
5984 tokens: tok_map,
5985 dl,
5986 });
5987 }
5988
5989 if nodes.is_empty() {
5990 return vec![];
5991 }
5992
5993 // --- BM25 corpus stats ---
5994 let n = nodes.len() as f64;
5995 let avg_dl: f64 = nodes.iter().map(|nd| nd.dl as f64).sum::<f64>() / n;
5996 // df per stemmed token across all live indexed nodes.
5997 let mut df_map: BTreeMap<&str, f64> = BTreeMap::new();
5998 for nd in &nodes {
5999 for tok in nd.tokens.keys() {
6000 *df_map.entry(tok.as_str()).or_insert(0.0) += 1.0;
6001 }
6002 }
6003
6004 const K1: f64 = 1.2;
6005 const B: f64 = 0.75;
6006
6007 // --- Pass 2: score each node against each OR-group ---
6008 let mut results: Vec<(String, f64)> = Vec::new();
6009 for nd in &nodes {
6010 let dl = nd.dl as f64;
6011 let mut total_score = 0.0f64;
6012
6013 'group: for group in &groups {
6014 let mut group_score = 0.0f64;
6015
6016 for term in group {
6017 if term.negated {
6018 // Negated: if doc has this stemmed token → group fails.
6019 let present = if term.prefix {
6020 nd.tokens.keys().any(|t| t.starts_with(term.token.as_str()))
6021 } else {
6022 nd.tokens.contains_key(term.token.as_str())
6023 };
6024 if present {
6025 continue 'group;
6026 }
6027 continue;
6028 }
6029 if term.prefix {
6030 // Prefix: sum BM25 for all matching stemmed tokens.
6031 let mut prefix_matched = false;
6032 for (tok, positions) in &nd.tokens {
6033 if tok.starts_with(term.token.as_str()) {
6034 let tf = positions.len() as f64;
6035 let df = df_map.get(tok.as_str()).copied().unwrap_or(1.0);
6036 let idf = ((n - df + 0.5) / (df + 0.5) + 1.0).ln();
6037 let tf_norm =
6038 tf * (K1 + 1.0) / (tf + K1 * (1.0 - B + B * dl / avg_dl));
6039 group_score += idf * tf_norm;
6040 prefix_matched = true;
6041 }
6042 }
6043 if !prefix_matched {
6044 continue 'group;
6045 }
6046 } else {
6047 // term.token is already stemmed by parse_query; use directly.
6048 match nd.tokens.get(term.token.as_str()) {
6049 None => continue 'group,
6050 Some(positions) => {
6051 let tf = positions.len() as f64;
6052 let df = df_map.get(term.token.as_str()).copied().unwrap_or(1.0);
6053 let idf = ((n - df + 0.5) / (df + 0.5) + 1.0).ln();
6054 let tf_norm =
6055 tf * (K1 + 1.0) / (tf + K1 * (1.0 - B + B * dl / avg_dl));
6056 group_score += idf * tf_norm;
6057 }
6058 }
6059 }
6060 }
6061
6062 if group_score > 0.0 {
6063 total_score += group_score;
6064 }
6065 }
6066
6067 if total_score > 0.0 {
6068 results.push((nd.key.clone(), total_score));
6069 }
6070 }
6071
6072 results.sort_by(|a, b| {
6073 b.1.partial_cmp(&a.1)
6074 .unwrap_or(std::cmp::Ordering::Equal)
6075 .then(a.0.cmp(&b.0))
6076 });
6077 results
6078 }
6079
6080 /// Return the current view-maintained value of `view_prop` for node `key`.
6081 /// Equivalent to `get_prop` but documents that it reads a view-managed column.
6082 pub fn get_view_prop(&self, key: &str, view_prop: &str) -> Option<Value> {
6083 let id = self.ids.get(key)?;
6084 self.props_view()
6085 .get(id, view_prop)
6086 .map(|vr| vr.into_value())
6087 }
6088
6089 /// For testing / DST oracle: scratch recompute of a view value for one node.
6090 ///
6091 /// Returns `None` if the node does not exist, the view does not exist, or
6092 /// the view has no result for the node (e.g. Avg with no qualifying neighbors).
6093 #[doc(hidden)]
6094 pub fn scratch_view_value(&self, key: &str, view_name: &str) -> Option<Value> {
6095 let node = self.ids.get(key)?;
6096 let def = self.view_store.views().find(|v| v.name == view_name)?;
6097 // Use TopologyView so that NeighborAgg sees base + overlay edges
6098 // without materialising a temporary Topology (I1).
6099 let topo_view = self.topo_view();
6100 core_rules::views::compute_view_value(
6101 def,
6102 node,
6103 self.props_view(),
6104 &topo_view,
6105 &self.ids,
6106 &self.syms,
6107 &self.labels,
6108 )
6109 }
6110
6111 // -----------------------------------------------------------------------
6112 // Graph algorithm API
6113 // -----------------------------------------------------------------------
6114
6115 /// Run PageRank over the unified topology (manual + derived edges).
6116 ///
6117 /// Returns a [`PageRankReport`] with scores sorted descending (ties: key
6118 /// ascending). Set `config.edge_type` to restrict to one edge type.
6119 /// `config.converged` is `true` only when the power iteration converged
6120 /// within `config.max_iters` and within any time budget.
6121 pub fn pagerank(&self, config: &crate::algo::PageRankConfig) -> crate::algo::PageRankReport {
6122 let topo = build_topo_view(&self.topo, &self.base);
6123 let edge_props = self.edge_props_view();
6124 crate::algo::pagerank(
6125 &topo,
6126 &self.ids,
6127 &self.syms,
6128 &self.labels,
6129 &edge_props,
6130 config,
6131 )
6132 }
6133
6134 /// Weakly-connected components over the unified topology (treated as
6135 /// undirected regardless of how edges were inserted).
6136 ///
6137 /// Component IDs are the key of the smallest member in the component
6138 /// (deterministic). Result sorted by (component_id, key).
6139 pub fn connected_components(&self, config: &crate::algo::WccConfig) -> crate::algo::WccReport {
6140 let topo = build_topo_view(&self.topo, &self.base);
6141 let edge_props = self.edge_props_view();
6142 crate::algo::wcc(
6143 &topo,
6144 &self.ids,
6145 &self.syms,
6146 &self.labels,
6147 &edge_props,
6148 config,
6149 )
6150 }
6151
6152 /// Degree centrality for every live node.
6153 ///
6154 /// `direction`: `AlgoDir::Out` = out-degree, `AlgoDir::In` = in-degree,
6155 /// `AlgoDir::Both` = out + in (total directed degree).
6156 ///
6157 /// For one-shot ranking use this; for a live property updated on every
6158 /// write, create a Degree materialized view instead (see `docs/site/algorithms.md`).
6159 pub fn degree_centrality(
6160 &self,
6161 config: &crate::algo::DegreeConfig,
6162 ) -> crate::algo::DegreeReport {
6163 let topo = build_topo_view(&self.topo, &self.base);
6164 let edge_props = self.edge_props_view();
6165 crate::algo::degree_centrality(
6166 &topo,
6167 &self.ids,
6168 &self.syms,
6169 &self.labels,
6170 &edge_props,
6171 config,
6172 )
6173 }
6174
6175 /// Louvain community detection over the unified topology (undirected).
6176 ///
6177 /// See [`crate::algo::LouvainConfig`] for edge-type/weight/label
6178 /// restriction and [`crate::algo::CommunityReport`] for the shape of the
6179 /// result (communities sorted size-desc, then smallest member key asc).
6180 pub fn communities(&self, config: &crate::algo::LouvainConfig) -> crate::algo::CommunityReport {
6181 let topo = build_topo_view(&self.topo, &self.base);
6182 let edge_props = self.edge_props_view();
6183 crate::algo::louvain(
6184 &topo,
6185 &self.ids,
6186 &self.syms,
6187 &self.labels,
6188 &edge_props,
6189 config,
6190 )
6191 }
6192
6193 /// Write a vector of `(node_key, score)` pairs as `prop_name` on each node,
6194 /// atomically via a single write-batch (one WAL frame, one fsync).
6195 ///
6196 /// # Errors
6197 /// - [`GraphError::ReadOnly`]: called on an as-of instance.
6198 /// - [`GraphError::RuleInvalid`]: `prop_name` is managed by an existing view
6199 /// (collision check mirrors `create_view`).
6200 /// - [`GraphError::KeyNotFound`]: a key in `scores` does not exist as a live node.
6201 pub fn write_scores(&mut self, prop_name: &str, scores: &[(String, f64)]) -> Result<()> {
6202 if self.read_only {
6203 return Err(GraphError::ReadOnly);
6204 }
6205 // Collision check: refuse if prop_name is view-managed.
6206 if let Some(view_name) = self.view_store.view_for_prop(prop_name) {
6207 return Err(GraphError::RuleInvalid {
6208 detail: format!(
6209 "prop {:?} is managed by view {:?} and cannot be written as scores",
6210 prop_name, view_name
6211 ),
6212 });
6213 }
6214 // Refuse if prop_name is a view name itself (confusing namespace collision).
6215 if self.view_store.has_view(prop_name) {
6216 return Err(GraphError::RuleInvalid {
6217 detail: format!(
6218 "prop_name {:?} collides with an existing view name",
6219 prop_name
6220 ),
6221 });
6222 }
6223 // Write all scores in a single crash-atomic batch.
6224 self.write_batch(|b| {
6225 for (key, score) in scores {
6226 b.set_prop(key, prop_name, Value::Float(*score));
6227 }
6228 })?;
6229 Ok(())
6230 }
6231
6232 /// Return the value of `field` for the node with key `key`, or `None` if
6233 /// the node or field is absent. Reads through the overlay-over-base
6234 /// `ColumnsView`, materialising base values on demand (zero heap cost for
6235 /// overlay hits; one clone per base hit).
6236 pub fn get_prop(&self, key: &str, field: &str) -> Option<Value> {
6237 let id = self.ids.get(key)?;
6238 self.props_view().get(id, field).map(|vr| vr.into_value())
6239 }
6240
6241 pub fn has_node(&self, key: &str) -> bool {
6242 self.ids.get(key).is_some()
6243 }
6244
6245 /// Borrow the raw id map. Used by `NodeMask::from_keys` to resolve keys.
6246 pub(crate) fn ids(&self) -> &IdMap {
6247 &self.ids
6248 }
6249
6250 // -----------------------------------------------------------------------
6251 // RBAC role resolution
6252 // -----------------------------------------------------------------------
6253
6254 /// Parse `roles.json` bytes from `fs`.
6255 ///
6256 /// Return values:
6257 /// `Ok(Some(roles))` — file absent (returns `vec![]`) **or** file present
6258 /// and valid; in both cases `mask_for_role` uses the
6259 /// list normally (an absent file means no roles defined).
6260 /// `Ok(None)` — file present but corrupt or unrecognised version
6261 /// → poisoned state; `mask_for_role` returns `Err` for
6262 /// any role name until the file is fixed and the DB
6263 /// re-opened (or `apply_schema` is called to repair it).
6264 ///
6265 /// Note: `None` signals corruption, not absence — the opposite of what an
6266 /// optional "file missing" convention would suggest. The open path stores
6267 /// this result on `db.roles` directly.
6268 fn load_roles_from_fs(fs: &F) -> Result<Option<Vec<RoleDef>>> {
6269 let bytes = fs.read(FileId::Roles).map_err(GraphError::Io)?;
6270 if bytes.is_empty() {
6271 // Empty bytes means either the file is absent or zero-byte — both
6272 // are treated identically as "no roles defined". A zero-byte
6273 // roles.json does NOT widen access: an absent file and a zero-byte
6274 // file both resolve to an empty role list (sees nothing by default).
6275 return Ok(Some(vec![]));
6276 }
6277 match serde_json::from_slice::<RolesFile>(&bytes) {
6278 Ok(f) if f.version == 1 || f.version == 2 => Ok(Some(f.roles)),
6279 // Corrupt or unrecognised version (>2): poison the roles state.
6280 _ => Ok(None),
6281 }
6282 }
6283
6284 /// Resolve a role to a node-visibility mask against the current graph state.
6285 ///
6286 /// Returns `Err` when:
6287 /// - `roles.json` was present but corrupt at open (poisoned state), or
6288 /// - `role` does not match any defined role name.
6289 ///
6290 /// The mask union is: explicit `keys` (unknown keys silently ignored) plus
6291 /// all live nodes carrying any label in `labels`. Label resolution is live
6292 /// — new nodes of an allowed label are visible without re-applying the
6293 /// schema. An empty union = empty mask = sees nothing.
6294 pub fn mask_for_role(&self, role: &str) -> Result<crate::mask::NodeMask> {
6295 let roles = self.roles.as_ref().ok_or_else(|| GraphError::Corrupt {
6296 detail:
6297 "roles.json was corrupt at open; fix the file and re-open to restore role access"
6298 .into(),
6299 })?;
6300 let def = roles
6301 .iter()
6302 .find(|r| r.name == role)
6303 .ok_or_else(|| GraphError::KeyNotFound {
6304 key: format!("role:{role}"),
6305 })?;
6306
6307 let mut visible = std::collections::HashSet::new();
6308
6309 // Key leg: resolve explicit keys to dense ids (unknown keys ignored).
6310 for key in &def.keys {
6311 if let Some(id) = self.ids.get(key) {
6312 visible.insert(id);
6313 }
6314 }
6315
6316 // Label leg: live scan — iterate labels vec for matching symbol.
6317 for label_name in &def.labels {
6318 if let Some(sym) = self.syms.get(label_name) {
6319 for (i, &s) in self.labels.iter().enumerate() {
6320 if s == sym {
6321 visible.insert(i as u32);
6322 }
6323 }
6324 }
6325 }
6326
6327 Ok(crate::mask::NodeMask::from_ids(visible))
6328 }
6329
6330 /// Return the current list of role definitions.
6331 ///
6332 /// Returns an empty list when no roles are defined or when `roles.json`
6333 /// was corrupt at open (check [`mask_for_role`](Self::mask_for_role) for
6334 /// the fail-loud error in that case).
6335 pub fn roles(&self) -> Vec<RoleDef> {
6336 self.roles.as_deref().unwrap_or(&[]).to_vec()
6337 }
6338
6339 // ── Role-scoped write authz ───────────────────────────────────────────────
6340
6341 /// Execute `ops` with optional role-scoped write authorization.
6342 ///
6343 /// - `None` → full authority, identical to [`write_batch`](Self::write_batch)
6344 /// (zero-cost bypass of all authz checks).
6345 /// - `Some(authz)` → the decision table is evaluated per-op BEFORE any WAL
6346 /// record is built. A denial returns an error with no WAL frame written
6347 /// (all-or-nothing at the authz boundary, then at the MutPreview boundary).
6348 ///
6349 /// See the plan's "authz decision table" section for the full semantics.
6350 pub fn write_batch_authz(
6351 &mut self,
6352 authz: Option<&WriteAuthz>,
6353 ops: Vec<BatchOp>,
6354 ) -> Result<(usize, usize)> {
6355 // Thread authz as a direct parameter — never touches pending_write_authz.
6356 self.commit_logged_batch(ops, None, authz.cloned())
6357 }
6358
6359 /// Execute a Cypher write statement with role-scoped write authorization.
6360 ///
6361 /// Resolves scope + mask from `self.roles` inside the call (same write-guard
6362 /// lifetime as execution, satisfying §5 lock discipline). The resolved
6363 /// `WriteAuthz` is stored as `pending_write_authz` for the duration of the
6364 /// call so that all inner `batch.commit()` calls are authz-checked.
6365 ///
6366 /// MERGE is handled specially: the MERGE scope precondition (§3.3) is
6367 /// checked in `exec_merge` BEFORE `has_node` to close the §6.2
6368 /// timing-oracle item (hidden ≡ absent for unscoped roles).
6369 ///
6370 /// Roles with `write: None` (v1 behavior) → `RoleWriteDenied` with
6371 /// "this endpoint is not permitted".
6372 pub fn query_write_authz(
6373 &mut self,
6374 role: &str,
6375 cypher: &str,
6376 params: &BTreeMap<String, Value>,
6377 ) -> Result<ResultSet> {
6378 // Resolve scope (fails fast if role has no write scope).
6379 // write:None → byte-identical v1 blanket-403 body (plan §v1-sidecar mandate).
6380 let scope =
6381 {
6382 let roles = self.roles.as_deref().ok_or_else(|| GraphError::Corrupt {
6383 detail: "roles.json was corrupt at open; re-open to restore role access".into(),
6384 })?;
6385 let def = roles.iter().find(|r| r.name == role).ok_or_else(|| {
6386 GraphError::KeyNotFound {
6387 key: format!("role:{role}"),
6388 }
6389 })?;
6390 def.write
6391 .clone()
6392 .ok_or_else(|| GraphError::RoleWriteDenied {
6393 reason: "role-bound token: writes are not permitted".into(),
6394 })?
6395 };
6396 // Resolve mask inside the call (same guard, §5 coherence).
6397 let mask = self.mask_for_role(role)?;
6398 self.pending_write_authz = Some(WriteAuthz {
6399 role: role.into(),
6400 scope,
6401 mask,
6402 });
6403 // RAII guard: always clears pending_write_authz on scope exit, including
6404 // on panic or early-return, mirroring the RestoreEmitDeltas precedent.
6405 struct ClearPendingAuthzOnDrop(*mut Option<WriteAuthz>);
6406 impl Drop for ClearPendingAuthzOnDrop {
6407 fn drop(&mut self) {
6408 // SAFETY: pointer into the owning GraphDb; guard is dropped
6409 // within this function's frame before it returns.
6410 unsafe { *self.0 = None };
6411 }
6412 }
6413 // SAFETY: raw pointer into self; guard dropped before this fn returns.
6414 let _authz_guard = ClearPendingAuthzOnDrop(&mut self.pending_write_authz as *mut _);
6415 let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
6416 detail: format!("lex: {e}"),
6417 })?;
6418 let stmt = parse_write(&tokens).map_err(|e| GraphError::QueryError {
6419 detail: format!("parse: {e}"),
6420 })?;
6421 self.exec_write_stmt(stmt, params)
6422 }
6423
6424 /// Execute `ops` with optional role-scoped write authorization, suppressing
6425 /// fsync (for use inside the group-commit drain thread, which performs one
6426 /// group fsync after releasing the write lock).
6427 ///
6428 /// Identical to [`write_batch_authz`] except the fsync policy is temporarily
6429 /// forced to `Relaxed` for the duration of the call, matching the drain-thread
6430 /// contract established by [`commit_batch_nosync`].
6431 pub(crate) fn write_batch_authz_nosync(
6432 &mut self,
6433 authz: Option<&WriteAuthz>,
6434 ops: Vec<BatchOp>,
6435 ) -> Result<(usize, usize)> {
6436 let saved = self.fsync;
6437 struct RestoreFsync(*mut FsyncPolicy, FsyncPolicy);
6438 impl Drop for RestoreFsync {
6439 fn drop(&mut self) {
6440 // SAFETY: pointer into the owning GraphDb; guard is dropped
6441 // within the enclosing function's frame before it returns.
6442 unsafe { *self.0 = self.1 };
6443 }
6444 }
6445 // SAFETY: raw pointer into self; guard dropped before this fn returns.
6446 let _g = RestoreFsync(&mut self.fsync as *mut FsyncPolicy, saved);
6447 self.fsync = FsyncPolicy::Relaxed;
6448 self.commit_logged_batch(ops, None, authz.cloned())
6449 }
6450
6451 /// Execute a `/ingest` request with role-scoped write authorization.
6452 ///
6453 /// Resolves the role's `WriteScope` and `NodeMask` inside this call (same
6454 /// write-guard lifetime as the mutation, satisfying §5 lock discipline).
6455 /// Sets `pending_write_authz` for the duration of the call so that the
6456 /// `commit_ingest` → `commit_logged_batch` path picks up the authz context
6457 /// and evaluates the decision table per-op before any WAL write.
6458 ///
6459 /// §7.3: roles with empty `create_labels` will see every `InsertNode` op
6460 /// denied by the decision table with the appropriate §4.3 scope reason;
6461 /// no special HTTP-layer check is needed.
6462 ///
6463 /// Roles with `write: None` return `RoleWriteDenied` with
6464 /// "writes are not permitted" (byte-identical to v1 blanket 403).
6465 pub fn ingest_with_edges_authz(
6466 &mut self,
6467 role: &str,
6468 label: &str,
6469 rows: Vec<std::collections::BTreeMap<String, Value>>,
6470 opts: &crate::ingest::IngestOptions,
6471 edges: &[(String, String, String)],
6472 ) -> Result<crate::ingest::IngestReport> {
6473 // Resolve scope (fails fast if role has no write scope).
6474 // write:None → byte-identical v1 blanket-403 body (plan §v1-sidecar mandate).
6475 let scope =
6476 {
6477 let roles = self.roles.as_deref().ok_or_else(|| GraphError::Corrupt {
6478 detail: "roles.json was corrupt at open; re-open to restore role access".into(),
6479 })?;
6480 let def = roles.iter().find(|r| r.name == role).ok_or_else(|| {
6481 GraphError::KeyNotFound {
6482 key: format!("role:{role}"),
6483 }
6484 })?;
6485 def.write
6486 .clone()
6487 .ok_or_else(|| GraphError::RoleWriteDenied {
6488 reason: "role-bound token: writes are not permitted".into(),
6489 })?
6490 };
6491 let mask = self.mask_for_role(role)?;
6492 self.pending_write_authz = Some(WriteAuthz {
6493 role: role.into(),
6494 scope,
6495 mask,
6496 });
6497 // RAII guard: always clears pending_write_authz on scope exit, including
6498 // on panic or early-return, mirroring the RestoreEmitDeltas precedent.
6499 struct ClearPendingAuthzOnDrop(*mut Option<WriteAuthz>);
6500 impl Drop for ClearPendingAuthzOnDrop {
6501 fn drop(&mut self) {
6502 // SAFETY: pointer into the owning GraphDb; guard is dropped
6503 // within this function's frame before it returns.
6504 unsafe { *self.0 = None };
6505 }
6506 }
6507 // SAFETY: raw pointer into self; guard dropped before this fn returns.
6508 let _authz_guard = ClearPendingAuthzOnDrop(&mut self.pending_write_authz as *mut _);
6509 self.ingest_with_edges(label, rows, opts, edges)
6510 }
6511
6512 /// Evaluate the write-authz decision table for one `BatchOp`.
6513 ///
6514 /// Called by `commit_logged_batch` for each op when `pending_write_authz`
6515 /// is `Some`, BEFORE MutPreview. A denial returns an error immediately;
6516 /// the remaining ops are not evaluated and no WAL frame is written.
6517 ///
6518 /// `batch_created` carries the key→label pairs of nodes that earlier ops in
6519 /// THIS batch will create. Used by `InsertEdgeUpsert` to count same-batch
6520 /// placeholder nodes as visible (spec: "a placeholder endpoint the SAME
6521 /// batch creates counts as visible if its label passed the create-class gate").
6522 fn check_single_op_authz(
6523 &self,
6524 authz: &WriteAuthz,
6525 op: &BatchOp,
6526 batch_created: &BTreeMap<String, String>,
6527 ) -> Result<()> {
6528 // Helper: 3-way node status under the authz mask.
6529 //
6530 // Batch-created nodes (from earlier InsertNode in THIS batch) are treated
6531 // as Visible with their recorded label — their create gate already passed
6532 // and they are not yet in self.ids (not committed). This fixes the
6533 // MERGE+ON CREATE SET case where InsertNode + SetProp arrive together:
6534 // the SetProp must not see the node as Absent.
6535 let node_status = |key: &str| -> NodeAuthzStatus {
6536 if let Some(label) = batch_created.get(key) {
6537 return NodeAuthzStatus::Visible(label.clone());
6538 }
6539 match self.ids.get(key) {
6540 None => NodeAuthzStatus::Absent,
6541 Some(id) if !authz.mask.contains_id(id) => NodeAuthzStatus::Hidden,
6542 Some(id) => {
6543 let label = self
6544 .labels
6545 .get(id as usize)
6546 .and_then(|&sym| {
6547 if sym == u32::MAX {
6548 None
6549 } else {
6550 self.syms.resolve(sym).map(str::to_string)
6551 }
6552 })
6553 .unwrap_or_default();
6554 NodeAuthzStatus::Visible(label)
6555 }
6556 }
6557 };
6558
6559 // Helper: is an InsertEdgeUpsert endpoint visible?
6560 // A same-batch placeholder counts as visible if its label passed
6561 // the create-class gate (spec "upsert placeholder-counts-as-visible").
6562 let upsert_ep_visible = |ep_key: &str, placeholder_label: &str| -> bool {
6563 // In store and visible?
6564 if let Some(id) = self.ids.get(ep_key) {
6565 return authz.mask.contains_id(id);
6566 }
6567 // Created by an earlier op in this batch?
6568 if let Some(created_label) = batch_created.get(ep_key) {
6569 return authz.scope.create_labels.contains(created_label);
6570 }
6571 // Will be created by THIS InsertEdgeUpsert: placeholder_label
6572 // must pass the create-class gate.
6573 authz
6574 .scope
6575 .create_labels
6576 .contains(&placeholder_label.to_string())
6577 };
6578
6579 match op {
6580 // RenameNode / CreateRule / DeleteRule: defense-in-depth gate.
6581 // These ops are never routed to role-scoped paths by the HTTP layer,
6582 // but we 403 them here to close any future bypass route.
6583 BatchOp::RenameNode { .. } | BatchOp::CreateRule(_) | BatchOp::DeleteRule { .. } => {
6584 return Err(GraphError::RoleWriteDenied {
6585 reason: "role-bound token: this endpoint is not permitted".into(),
6586 });
6587 }
6588
6589 // ── CREATE-class: InsertNode ─────────────────────────────────────
6590 //
6591 // Decision table row 1 (scope-before-lookup): check label in
6592 // create_labels BEFORE any key lookup. This is the structural
6593 // closure of the §6.2 timing-oracle item — the denial fires even
6594 // when the store is EMPTY (see test_create_scope_denied_empty_store).
6595 BatchOp::InsertNode { label, key, .. } => {
6596 if !authz.scope.create_labels.contains(label) {
6597 return Err(GraphError::RoleWriteDenied {
6598 reason: format!(
6599 "role-bound token: label '{}' not in write scope (create_labels)",
6600 label
6601 ),
6602 });
6603 }
6604 // Row 2/3: key lookup.
6605 match self.ids.get(key.as_str()) {
6606 Some(id) if authz.mask.contains_id(id) => {
6607 // Visible: DuplicateKey — let MutPreview handle this.
6608 }
6609 Some(_) => {
6610 // Hidden: indistinguishable from absent to the role.
6611 return Err(GraphError::RoleWriteDenied {
6612 reason: "role-bound token: target node not visible".into(),
6613 });
6614 }
6615 None => {
6616 // Absent: proceed (create).
6617 }
6618 }
6619 }
6620
6621 // ── UPDATE-class: SetProp, RemoveProp ────────────────────────────
6622 BatchOp::SetProp { key, .. } | BatchOp::RemoveProp { key, .. } => {
6623 if batch_created.contains_key(key.as_str()) {
6624 // Batch-created node: create gate already passed this batch.
6625 // Updating it in the same batch is always allowed, regardless
6626 // of update_labels (ruling §3.5: "writer just created it").
6627 } else {
6628 let label = match node_status(key) {
6629 NodeAuthzStatus::Visible(lbl) => lbl,
6630 _ => {
6631 return Err(GraphError::RoleWriteDenied {
6632 reason: "role-bound token: target node not visible".into(),
6633 });
6634 }
6635 };
6636 if !authz.scope.update_labels.contains(&label) {
6637 return Err(GraphError::RoleWriteDenied {
6638 reason: format!(
6639 "role-bound token: label '{}' not in write scope (update_labels)",
6640 label
6641 ),
6642 });
6643 }
6644 }
6645 }
6646
6647 // ── DELETE-class: DeleteNode ─────────────────────────────────────
6648 BatchOp::DeleteNode { key } => {
6649 let label = match node_status(key) {
6650 NodeAuthzStatus::Visible(lbl) => lbl,
6651 _ => {
6652 return Err(GraphError::RoleWriteDenied {
6653 reason: "role-bound token: target node not visible".into(),
6654 });
6655 }
6656 };
6657 if !authz.scope.delete_labels.contains(&label) {
6658 return Err(GraphError::RoleWriteDenied {
6659 reason: format!(
6660 "role-bound token: label '{}' not in write scope (delete_labels)",
6661 label
6662 ),
6663 });
6664 }
6665 }
6666
6667 // ── DELETE-class: DeleteEdge ─────────────────────────────────────
6668 //
6669 // Derived-edge rejection runs BEFORE the delete_edge_types scope
6670 // check (spec §3.5: "existing derived-edge rejection precedes
6671 // delete_edge_types check").
6672 BatchOp::DeleteEdge {
6673 edge_type,
6674 src_key,
6675 dst_key,
6676 } => {
6677 // Check provenance ownership BEFORE scope (spec §3.5 ordering).
6678 if let (Some(src_id), Some(dst_id), Some(et_sym)) = (
6679 self.ids.get(src_key.as_str()),
6680 self.ids.get(dst_key.as_str()),
6681 self.syms.get(edge_type.as_str()),
6682 ) {
6683 if self.engine.is_owned(et_sym, src_id, dst_id) {
6684 return Err(GraphError::RuleOwned {
6685 detail: format!(
6686 "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
6687 delete or change the owning rule"
6688 ),
6689 });
6690 }
6691 // Also check would_derive via MutPreview (empty overlay, pre-batch).
6692 let preview = MutPreview::new(self);
6693 if preview.would_derive(edge_type, src_key, dst_key) {
6694 return Err(GraphError::RuleOwned {
6695 detail: format!(
6696 "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
6697 delete or change the owning rule, or a live rule would \
6698 re-derive it"
6699 ),
6700 });
6701 }
6702 }
6703 // Scope check (AFTER derived-edge check, BEFORE endpoint visibility).
6704 if !authz.scope.delete_edge_types.contains(edge_type) {
6705 return Err(GraphError::RoleWriteDenied {
6706 reason: format!(
6707 "role-bound token: edge type '{}' not in write scope (delete_edge_types)",
6708 edge_type
6709 ),
6710 });
6711 }
6712 // Both endpoints must be visible.
6713 for ep_key in [src_key.as_str(), dst_key.as_str()] {
6714 match self.ids.get(ep_key) {
6715 None => {
6716 return Err(GraphError::RoleWriteDenied {
6717 reason: "role-bound token: edge endpoint not visible".into(),
6718 });
6719 }
6720 Some(id) if !authz.mask.contains_id(id) => {
6721 return Err(GraphError::RoleWriteDenied {
6722 reason: "role-bound token: edge endpoint not visible".into(),
6723 });
6724 }
6725 _ => {}
6726 }
6727 }
6728 }
6729
6730 // ── EDGE-CREATE: InsertEdge ──────────────────────────────────────
6731 //
6732 // Scope check BEFORE endpoint lookup (preserves timing symmetry).
6733 BatchOp::InsertEdge {
6734 edge_type,
6735 src_key,
6736 dst_key,
6737 } => {
6738 if !authz.scope.create_edge_types.contains(edge_type) {
6739 return Err(GraphError::RoleWriteDenied {
6740 reason: format!(
6741 "role-bound token: edge type '{}' not in write scope (create_edge_types)",
6742 edge_type
6743 ),
6744 });
6745 }
6746 // Both endpoints must be visible. A node created by an earlier
6747 // InsertNode in the same batch (tracked in batch_created) counts
6748 // as visible if its label passed the create-class gate.
6749 for ep_key in [src_key.as_str(), dst_key.as_str()] {
6750 if batch_created.contains_key(ep_key) {
6751 // Created earlier this batch — already scope-checked.
6752 continue;
6753 }
6754 match self.ids.get(ep_key) {
6755 None => {
6756 return Err(GraphError::RoleWriteDenied {
6757 reason: "role-bound token: edge endpoint not visible".into(),
6758 });
6759 }
6760 Some(id) if !authz.mask.contains_id(id) => {
6761 return Err(GraphError::RoleWriteDenied {
6762 reason: "role-bound token: edge endpoint not visible".into(),
6763 });
6764 }
6765 _ => {}
6766 }
6767 }
6768 }
6769
6770 // ── EDGE-CREATE: InsertEdgeUpsert ────────────────────────────────
6771 //
6772 // Scope check first; then endpoint visibility using same-batch
6773 // placeholder awareness (spec: "a placeholder endpoint the SAME
6774 // batch creates counts as visible if its label passed the
6775 // create-class gate").
6776 BatchOp::InsertEdgeUpsert {
6777 edge_type,
6778 src_key,
6779 dst_key,
6780 placeholder_label,
6781 } => {
6782 if !authz.scope.create_edge_types.contains(edge_type) {
6783 return Err(GraphError::RoleWriteDenied {
6784 reason: format!(
6785 "role-bound token: edge type '{}' not in write scope (create_edge_types)",
6786 edge_type
6787 ),
6788 });
6789 }
6790 // Check placeholder label against create_labels (create-class gate).
6791 // This ensures the auto-created endpoints are scope-allowed.
6792 for ep_key in [src_key.as_str(), dst_key.as_str()] {
6793 if !upsert_ep_visible(ep_key, placeholder_label) {
6794 return Err(GraphError::RoleWriteDenied {
6795 reason: "role-bound token: edge endpoint not visible".into(),
6796 });
6797 }
6798 }
6799 }
6800 }
6801 Ok(())
6802 }
6803
6804 /// Write `roles` to `roles.json` atomically and update the in-memory list.
6805 ///
6806 /// Called by `apply_schema` when roles change. Never called on unchanged
6807 /// re-apply — this preserves byte-identical idempotency.
6808 pub(crate) fn commit_roles(&mut self, roles: Vec<RoleDef>) -> Result<()> {
6809 let file = RolesFile::new_versioned(roles.clone());
6810 let bytes = serde_json::to_vec(&file).map_err(|e| GraphError::Corrupt {
6811 detail: format!("roles serialization: {e}"),
6812 })?;
6813 self.fs
6814 .write_atomic(FileId::Roles, &bytes)
6815 .map_err(GraphError::Io)?;
6816 self.roles = Some(roles);
6817 // Refresh the MVCC frozen overlay so that reader() immediately sees the
6818 // updated role definitions without waiting for the next K-commit fold.
6819 self.fold_now();
6820 Ok(())
6821 }
6822
6823 fn view(&self) -> GraphView<'_> {
6824 GraphView {
6825 ids: &self.ids,
6826 syms: &self.syms,
6827 labels: &self.labels,
6828 props: self.props_view(),
6829 topo: self.topo_view(),
6830 edge_props: self.edge_props_view(),
6831 mask: None,
6832 prop_index: Some(&self.prop_index),
6833 }
6834 }
6835
6836 fn view_masked<'a>(&'a self, mask: &'a crate::mask::NodeMask) -> GraphView<'a> {
6837 GraphView {
6838 ids: &self.ids,
6839 syms: &self.syms,
6840 labels: &self.labels,
6841 props: self.props_view(),
6842 topo: self.topo_view(),
6843 edge_props: self.edge_props_view(),
6844 mask: Some(&mask.visible),
6845 prop_index: Some(&self.prop_index),
6846 }
6847 }
6848
6849 /// Execute a read-only Cypher query with a node visibility mask.
6850 ///
6851 /// Only nodes whose key is in `mask` are accessible: label scans, key
6852 /// lookups, and neighbor expansions all respect the mask. Edges where
6853 /// either endpoint is hidden are silently dropped.
6854 ///
6855 /// Returns `Err` with a "masked queries are read-only" message when
6856 /// `cypher` is a write statement (CREATE / MERGE / MATCH…SET / DELETE).
6857 pub fn query_masked(
6858 &self,
6859 cypher: &str,
6860 params: &std::collections::BTreeMap<String, Value>,
6861 mask: &crate::mask::NodeMask,
6862 ) -> Result<ResultSet> {
6863 // Reject write statements up front.
6864 let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
6865 detail: format!("lex: {e}"),
6866 })?;
6867 if is_write_tokens(&tokens) {
6868 return Err(GraphError::MaskedReadOnly);
6869 }
6870 let union = parse_read(&tokens).map_err(|e| GraphError::QueryError {
6871 detail: format!("parse: {e}"),
6872 })?;
6873 // Each UNION part executes against the same masked view, so the mask
6874 // applies uniformly across the chain.
6875 execute_union(&self.view_masked(mask), &union, &Params(params)).map_err(|e| {
6876 GraphError::QueryError {
6877 detail: format!("execute: {e}"),
6878 }
6879 })
6880 }
6881
6882 pub fn node_ref(&self, key: &str) -> Option<NodeRef<'_, F>> {
6883 let id = self.ids.get(key)?;
6884 Some(NodeRef { db: self, id })
6885 }
6886
6887 /// BFS neighborhood expansion restricted to visible nodes in `mask`.
6888 ///
6889 /// Hidden nodes are never used as traversal intermediaries in either
6890 /// [`MaskMode::Omit`] or [`MaskMode::Stub`] — a visible node reachable
6891 /// only through a hidden node will not appear in results.
6892 ///
6893 /// In [`MaskMode::Stub`] mode, hidden nodes that are direct neighbours of
6894 /// a visited visible node are appended to the result as stub rows
6895 /// (`label` column is `null`, same key+depth columns as visible rows).
6896 /// They are NOT added to the BFS frontier.
6897 ///
6898 /// Returns `None` when `key` does not exist (caller should 404).
6899 ///
6900 /// **SECURITY**: role-token callers always pass an Omit-mode mask, so
6901 /// stub rows are never produced on the role path.
6902 pub fn neighborhood_masked(
6903 &self,
6904 key: &str,
6905 depth: u32,
6906 edge_types: Option<&[&str]>,
6907 dir: Dir,
6908 mask: &crate::mask::NodeMask,
6909 ) -> Option<ResultSet> {
6910 let start_id = self.ids.get(key)?;
6911 let view = self.view_masked(mask);
6912 let resolved: Option<Vec<u32>> = edge_types.map(|names| {
6913 names
6914 .iter()
6915 .filter_map(|name| view.syms.get(name))
6916 .collect()
6917 });
6918 let nb = neighborhood(&view, start_id, depth, resolved.as_deref(), dir);
6919 let mut rs = ResultSet::new(vec!["key".into(), "label".into(), "depth".into()]);
6920 // Collect visible BFS results (start_id at depth 0, BFS nodes after).
6921 let mut visited: Vec<(u32, u32)> = Vec::with_capacity(nb.nodes.len() + 1);
6922 visited.push((start_id, 0));
6923 for (nid, d) in &nb.nodes {
6924 let k = view.key_of(*nid);
6925 let label = view
6926 .label_of(*nid)
6927 .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
6928 rs.push_row(vec![
6929 Some(Value::Str(k.to_string())),
6930 Some(Value::Str(label.to_string())),
6931 Some(Value::Int(*d as i64)),
6932 ]);
6933 visited.push((*nid, *d));
6934 }
6935 // Stub mode: add hidden direct neighbours of each visited node as stubs.
6936 // Hidden nodes are edge-endpoints only — they are not added to the BFS
6937 // frontier, so the BFS never expands through them.
6938 if mask.mode() == crate::mask::MaskMode::Stub {
6939 let raw_view = self.view();
6940 let mut seen: std::collections::HashSet<u32> =
6941 visited.iter().map(|(id, _)| *id).collect();
6942 for (node_id, node_depth) in &visited {
6943 if *node_depth >= depth {
6944 continue;
6945 }
6946 for e in expand(&raw_view, *node_id, resolved.as_deref(), dir) {
6947 let nbr = if e.src == *node_id { e.dst } else { e.src };
6948 if !mask.contains_id(nbr) && seen.insert(nbr) {
6949 if let Some(k) = self.ids.key_of(nbr) {
6950 rs.push_row(vec![
6951 Some(Value::Str(k.to_string())),
6952 None,
6953 Some(Value::Int((*node_depth + 1) as i64)),
6954 ]);
6955 }
6956 }
6957 }
6958 }
6959 }
6960 Some(rs)
6961 }
6962
6963 /// Live node's key, label, and columnar props. Unknown or tombstoned → `None`.
6964 pub fn node_info(&self, key: &str) -> Option<NodeInfo> {
6965 let n = self.node_ref(key)?;
6966 Some(NodeInfo {
6967 key: n.key().to_string(),
6968 label: n.label().to_string(),
6969 props: n.props(),
6970 })
6971 }
6972
6973 /// Look up a node with mask awareness.
6974 ///
6975 /// | Key state | Omit mode | Stub mode |
6976 /// |-------------------|-----------------|------------------------|
6977 /// | does not exist | `None` (→ 404) | `None` (→ 404) |
6978 /// | exists, visible | `Some(Visible)` | `Some(Visible)` |
6979 /// | exists, hidden | `None` (→ 404) | `Some(Restricted)` |
6980 ///
6981 /// **SECURITY**: only call from client-mask (full-token) paths.
6982 /// Role-token paths must use [`node_info`] after an explicit visibility check.
6983 pub fn node_info_masked(
6984 &self,
6985 key: &str,
6986 mask: &crate::mask::NodeMask,
6987 ) -> Option<MaskedNodeResult> {
6988 let id = self.ids.get(key)?;
6989 if mask.contains_id(id) {
6990 Some(MaskedNodeResult::Visible(self.node_info(key)?))
6991 } else {
6992 match mask.mode() {
6993 crate::mask::MaskMode::Stub => Some(MaskedNodeResult::Restricted),
6994 crate::mask::MaskMode::Omit => None,
6995 }
6996 }
6997 }
6998
6999 /// Get edges for `key` with mask-aware hidden-endpoint handling.
7000 ///
7001 /// - Omit mode: edges to hidden endpoints are excluded (same as role-path filtering).
7002 /// - Stub mode: edges to hidden endpoints are included; `src_restricted`/`dst_restricted`
7003 /// is `true` for each hidden endpoint.
7004 ///
7005 /// Unknown key → [`GraphError::KeyNotFound`].
7006 ///
7007 /// **SECURITY**: only call from client-mask (full-token) paths.
7008 pub fn node_edges_masked(
7009 &self,
7010 key: &str,
7011 mask: &crate::mask::NodeMask,
7012 ) -> Result<Vec<MaskedEdge>> {
7013 self.ensure_v8_base_sections_loaded();
7014 let id = self
7015 .ids
7016 .get(key)
7017 .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
7018 let derived: BTreeSet<(u32, u32, u32)> = self
7019 .engine
7020 .provenance_touching(id)
7021 .map(|(_rule, etype, src, dst)| (etype, src, dst))
7022 .collect();
7023 let mut edges = Vec::new();
7024 let tv = self.topo_view();
7025 for etype in tv.etypes() {
7026 // etype comes from the archived CSR (access_unchecked, no eager CRC).
7027 // A bit-flip in the large TOPOLOGY section can produce an etype id
7028 // that is not in the interner. Return Corrupt rather than panic.
7029 let edge_type = self
7030 .syms
7031 .resolve(etype)
7032 .ok_or_else(|| GraphError::Corrupt {
7033 detail: format!("v8: topology etype {etype} not in interner"),
7034 })?
7035 .to_string();
7036 for dir in [Direction::Out, Direction::In] {
7037 for &nbr in tv.neighbors(etype, dir, id).as_ref() {
7038 let nbr_restricted = !mask.contains_id(nbr);
7039 if nbr_restricted && mask.mode() == crate::mask::MaskMode::Omit {
7040 continue;
7041 }
7042 let nbr_key = self
7043 .ids
7044 .key_of(nbr)
7045 .ok_or_else(|| GraphError::Corrupt {
7046 detail: format!("topology id {nbr} has no key"),
7047 })?
7048 .to_string();
7049 let (src_id, dst_id, src_key, dst_key, src_restricted, dst_restricted) =
7050 match dir {
7051 Direction::Out => {
7052 (id, nbr, key.to_string(), nbr_key, false, nbr_restricted)
7053 }
7054 Direction::In => {
7055 (nbr, id, nbr_key, key.to_string(), nbr_restricted, false)
7056 }
7057 };
7058 edges.push(MaskedEdge {
7059 edge_type: edge_type.clone(),
7060 src_key,
7061 src_restricted,
7062 dst_key,
7063 dst_restricted,
7064 derived: derived.contains(&(etype, src_id, dst_id)),
7065 });
7066 }
7067 }
7068 }
7069 edges.sort_by(|a, b| {
7070 a.edge_type
7071 .cmp(&b.edge_type)
7072 .then(a.src_key.cmp(&b.src_key))
7073 .then(a.dst_key.cmp(&b.dst_key))
7074 });
7075 edges.dedup_by(|a, b| {
7076 a.edge_type == b.edge_type && a.src_key == b.src_key && a.dst_key == b.dst_key
7077 });
7078 Ok(edges)
7079 }
7080
7081 /// Every directed edge incident on `key`, both directions, every etype.
7082 ///
7083 /// Walk is `topology.etypes()` × `{Out, In}` × `neighbors()`. `derived` is
7084 /// membership in [`RuleEngine::provenance_touching`] (O(degree) via the
7085 /// Plan-8 `by_node` index). Sorted by `(edge_type, src_key, dst_key)`.
7086 /// Unknown key → [`GraphError::KeyNotFound`].
7087 pub fn node_edges(&self, key: &str) -> Result<Vec<EdgeInfo>> {
7088 self.ensure_v8_base_sections_loaded();
7089 let id = self
7090 .ids
7091 .get(key)
7092 .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
7093 let derived: BTreeSet<(u32, u32, u32)> = self
7094 .engine
7095 .provenance_touching(id)
7096 .map(|(_rule, etype, src, dst)| (etype, src, dst))
7097 .collect();
7098 let mut edges = Vec::new();
7099 let tv = self.topo_view();
7100 for etype in tv.etypes() {
7101 // Same guard as node_edges_masked: etype from unchecked-CRC CSR.
7102 let edge_type = self
7103 .syms
7104 .resolve(etype)
7105 .ok_or_else(|| GraphError::Corrupt {
7106 detail: format!("v8: topology etype {etype} not in interner"),
7107 })?
7108 .to_string();
7109 for dir in [Direction::Out, Direction::In] {
7110 for &nbr in tv.neighbors(etype, dir, id).as_ref() {
7111 let (src, dst, src_key, dst_key) = match dir {
7112 Direction::Out => (
7113 id,
7114 nbr,
7115 key.to_string(),
7116 self.ids
7117 .key_of(nbr)
7118 .ok_or_else(|| GraphError::Corrupt {
7119 detail: format!("topology id {nbr} has no key"),
7120 })?
7121 .to_string(),
7122 ),
7123 Direction::In => (
7124 nbr,
7125 id,
7126 self.ids
7127 .key_of(nbr)
7128 .ok_or_else(|| GraphError::Corrupt {
7129 detail: format!("topology id {nbr} has no key"),
7130 })?
7131 .to_string(),
7132 key.to_string(),
7133 ),
7134 };
7135 edges.push(EdgeInfo {
7136 edge_type: edge_type.clone(),
7137 src_key,
7138 dst_key,
7139 derived: derived.contains(&(etype, src, dst)),
7140 });
7141 }
7142 }
7143 }
7144 edges.sort_by(|a, b| {
7145 a.edge_type
7146 .cmp(&b.edge_type)
7147 .then(a.src_key.cmp(&b.src_key))
7148 .then(a.dst_key.cmp(&b.dst_key))
7149 });
7150 // Self-loops appear in both Out and In; sort makes the pair adjacent
7151 // (sort key matches PartialEq for this case) so one pass drops the dup.
7152 edges.dedup();
7153 Ok(edges)
7154 }
7155
7156 // ── Backup ────────────────────────────────────────────────────────────────
7157
7158 /// Copy this store to `dest` as a consistent, verified snapshot.
7159 ///
7160 /// Copies every durable file in the database directory — `snapshot.bin`,
7161 /// `wal.bin`, all `wal.<N>.archive` files, `wal.floor`, `wal.genesis`, and
7162 /// `roles.json` — into a freshly created `dest` directory using OS-level
7163 /// `copy` calls (no large in-process buffers).
7164 ///
7165 /// # Consistency guarantee
7166 ///
7167 /// The guarantee is **process-local**: the caller holds `&self`, which
7168 /// prevents any concurrent writer in the **same process** from modifying
7169 /// the files during the copy. Running `mushroomdb backup` against a
7170 /// directory that is **concurrently being written by another process** (e.g.
7171 /// `mushroomdb serve`) is **unsafe** — the copy can be torn. The post-copy
7172 /// `verified: true` result reduces but does not eliminate the risk of a
7173 /// silent corrupt backup (CRC catches many bit-flips; it cannot catch a
7174 /// consistent mid-write snapshot).
7175 ///
7176 /// **The safe path for a live-served store is `POST /backup` on the HTTP
7177 /// server.** That handler acquires the read lock on the shared database
7178 /// before calling this method, which is the correct cross-process
7179 /// synchronisation point because the server is the single process writing
7180 /// the files.
7181 ///
7182 /// After copying, opens the destination read-only and runs the CRC section
7183 /// verifier (`verify_snapshot`) to confirm byte-for-byte integrity.
7184 /// `BackupReport::verified` reflects whether both checks passed.
7185 ///
7186 /// Returns `Err` when `self` is not backed by a `RealFs` (e.g. `SimFs`).
7187 pub fn backup_to(&self, dest: &std::path::Path) -> Result<BackupReport> {
7188 // Derive source directory from snapshot_path (RealFs only).
7189 let src_dir = match self.fs.snapshot_path() {
7190 Some(p) => p.parent().map(|d| d.to_path_buf()).ok_or_else(|| {
7191 GraphError::Io(std::io::Error::other("snapshot has no parent dir"))
7192 })?,
7193 None => {
7194 return Err(GraphError::Io(std::io::Error::other(
7195 "backup_to requires a real filesystem (RealFs)",
7196 )))
7197 }
7198 };
7199
7200 std::fs::create_dir_all(dest)?;
7201
7202 let mut files: Vec<String> = Vec::new();
7203 let mut bytes: u64 = 0;
7204
7205 // Helper: copy src_dir/name → dest/name if the file exists.
7206 let mut try_copy = |name: &str| -> std::io::Result<()> {
7207 let src_path = src_dir.join(name);
7208 if src_path.exists() {
7209 let n = std::fs::copy(&src_path, dest.join(name))?;
7210 bytes += n;
7211 files.push(name.to_string());
7212 }
7213 Ok(())
7214 };
7215
7216 try_copy("snapshot.bin")?;
7217 try_copy("snapshot.bin.bak")?;
7218 try_copy("wal.bin")?;
7219 try_copy("wal.floor")?;
7220 try_copy("wal.genesis")?;
7221 try_copy("roles.json")?;
7222
7223 // Copy WAL archives.
7224 let archives = self.fs.list_archives()?;
7225 for n in &archives {
7226 let name = format!("wal.{n}.archive");
7227 let n_bytes = std::fs::copy(src_dir.join(&name), dest.join(&name))?;
7228 bytes += n_bytes;
7229 files.push(name);
7230 }
7231
7232 files.sort();
7233
7234 // Post-copy verification: open dest and run CRC checks.
7235 let snap_in_dest = dest.join("snapshot.bin").exists();
7236 let crc_ok = if snap_in_dest {
7237 crate::verify_snapshot(dest)
7238 .map(|results| results.iter().all(|(_, _, _, r)| r.is_ok()))
7239 .unwrap_or(false)
7240 } else {
7241 true // WAL-only store: nothing to CRC-check in snapshot
7242 };
7243 let opens_ok = GraphDb::<core_storage::fs::RealFs>::open(dest).is_ok();
7244 let verified = crc_ok && opens_ok;
7245
7246 Ok(BackupReport {
7247 files,
7248 bytes,
7249 verified,
7250 })
7251 }
7252
7253 // ── Export helpers ────────────────────────────────────────────────────────
7254
7255 /// All live nodes, sorted by key (deterministic).
7256 ///
7257 /// Reads base + WAL overlay. Tombstoned nodes are excluded.
7258 pub fn all_nodes_for_export(&self) -> Vec<NodeInfo> {
7259 self.ensure_v8_base_sections_loaded();
7260 let pv = self.props_view();
7261 let mut nodes = Vec::new();
7262 for id in 0..self.ids.len() as u32 {
7263 let Some(key) = self.ids.key_of(id) else {
7264 continue;
7265 };
7266 let Some(&sym) = self.labels.get(id as usize) else {
7267 continue;
7268 };
7269 if sym == u32::MAX {
7270 continue; // tombstoned
7271 }
7272 let Some(label) = self.syms.resolve(sym) else {
7273 continue;
7274 };
7275 let mut props = BTreeMap::new();
7276 for field in pv.field_names() {
7277 if let Some(vr) = pv.get(id, &field) {
7278 props.insert(field, vr.into_value());
7279 }
7280 }
7281 nodes.push(NodeInfo {
7282 key: key.to_string(),
7283 label: label.to_string(),
7284 props,
7285 });
7286 }
7287 nodes.sort_by(|a, b| a.key.cmp(&b.key));
7288 nodes
7289 }
7290
7291 /// All directed edges, sorted by `(edge_type, src, dst)`. Each edge appears once.
7292 ///
7293 /// Derived edges carry `derived: true` and the creating rule's name in `rule`.
7294 /// Manual edges carry `derived: false` and `rule: None`.
7295 /// `weight` is the creating rule's `weight_prop` value read off the edge
7296 /// (numeric only), mirroring the convention used by [`GraphDb::explain`]
7297 /// and [`GraphDb::weighted_edges`]. Deterministic across runs on the same
7298 /// store state.
7299 pub fn all_edges_for_export(&self) -> Vec<ExportEdge> {
7300 self.ensure_v8_base_sections_loaded();
7301
7302 // Build (etype_sym, src_id, dst_id) → rule_name for O(1) derivation lookup.
7303 let mut prov: HashMap<(u32, u32, u32), String> = HashMap::new();
7304 for (rule_name, triples) in self.engine.provenance() {
7305 for &(etype, src, dst) in triples {
7306 prov.insert((etype, src, dst), rule_name.clone());
7307 }
7308 }
7309
7310 // rule_name → weight_prop, for O(1) lookup per derived edge.
7311 let weight_props: HashMap<&str, Option<&str>> = self
7312 .engine
7313 .rules()
7314 .map(|r| (r.name.as_str(), r.weight_prop.as_deref()))
7315 .collect();
7316
7317 let tv = self.topo_view();
7318 let ep = self.edge_props_view();
7319 let mut edges = Vec::new();
7320
7321 for id in 0..self.ids.len() as u32 {
7322 let Some(key) = self.ids.key_of(id) else {
7323 continue;
7324 };
7325 let Some(&lsym) = self.labels.get(id as usize) else {
7326 continue;
7327 };
7328 if lsym == u32::MAX {
7329 continue; // tombstoned
7330 }
7331
7332 for etype_sym in tv.etypes() {
7333 // etype from archived CSR (access_unchecked, no eager CRC).
7334 // Skip edges whose etype is not in the interner; this can only
7335 // occur with a corrupt large TOPOLOGY section (bit-flip on an
7336 // etype field in the archived data). The function returns Vec,
7337 // not Result, so we continue rather than propagate.
7338 let Some(edge_type) = self.syms.resolve(etype_sym) else {
7339 continue;
7340 };
7341 let edge_type = edge_type.to_string();
7342 for &nbr in tv.neighbors(etype_sym, Direction::Out, id).as_ref() {
7343 let Some(dst_key) = self.ids.key_of(nbr) else {
7344 continue; // skip corrupt entries
7345 };
7346 let prov_key = (etype_sym, id, nbr);
7347 let rule = prov.get(&prov_key).cloned();
7348 let derived = rule.is_some();
7349 let weight = rule
7350 .as_deref()
7351 .and_then(|rn| weight_props.get(rn).copied().flatten())
7352 .and_then(|prop| match ep.get(etype_sym, id, nbr, prop) {
7353 Some(Value::Float(f)) => Some(f),
7354 Some(Value::Int(i)) => Some(i as f64),
7355 _ => None,
7356 });
7357 edges.push(ExportEdge {
7358 edge_type: edge_type.clone(),
7359 src: key.to_string(),
7360 dst: dst_key.to_string(),
7361 derived,
7362 rule,
7363 weight,
7364 });
7365 }
7366 }
7367 }
7368
7369 edges.sort_by(|a, b| {
7370 a.edge_type
7371 .cmp(&b.edge_type)
7372 .then(a.src.cmp(&b.src))
7373 .then(a.dst.cmp(&b.dst))
7374 });
7375 edges
7376 }
7377
7378 /// All directed edges of `edge_type`, with the raw value of `weight_prop`
7379 /// on each edge when given.
7380 ///
7381 /// `weight` is `Some(f)` only when `weight_prop` is set and the edge
7382 /// carries that property with a numeric (`Int`/`Float`) value; otherwise
7383 /// `None` — callers that want a default weight (e.g. `1.0` for missing
7384 /// props) apply it themselves, matching the convention used internally
7385 /// by [`GraphDb::pagerank`], [`GraphDb::connected_components`],
7386 /// [`GraphDb::degree_centrality`], and [`GraphDb::communities`].
7387 ///
7388 /// Sorted by `(src, dst)` for determinism. Reads the unified topology
7389 /// (manual + rule-derived edges). An unknown `edge_type` returns an
7390 /// empty vec.
7391 pub fn weighted_edges(
7392 &self,
7393 edge_type: &str,
7394 weight_prop: Option<&str>,
7395 ) -> Vec<(String, String, Option<f64>)> {
7396 let Some(etype_sym) = self.syms.get(edge_type) else {
7397 return Vec::new();
7398 };
7399 let tv = self.topo_view();
7400 let ep = self.edge_props_view();
7401 let mut out = Vec::new();
7402 for id in 0..self.ids.len() as u32 {
7403 let Some(key) = self.ids.key_of(id) else {
7404 continue;
7405 };
7406 let Some(&sym) = self.labels.get(id as usize) else {
7407 continue;
7408 };
7409 if sym == u32::MAX {
7410 continue; // tombstoned
7411 }
7412 for &nbr in tv.neighbors(etype_sym, Direction::Out, id).as_ref() {
7413 let Some(dst_key) = self.ids.key_of(nbr) else {
7414 continue;
7415 };
7416 let weight = weight_prop.and_then(|prop| match ep.get(etype_sym, id, nbr, prop) {
7417 Some(Value::Float(f)) => Some(f),
7418 Some(Value::Int(i)) => Some(i as f64),
7419 _ => None,
7420 });
7421 out.push((key.to_string(), dst_key.to_string(), weight));
7422 }
7423 }
7424 out.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
7425 out
7426 }
7427
7428 pub fn nodes_with_label(&self, label: &str) -> Vec<NodeRef<'_, F>> {
7429 self.view()
7430 .nodes_with_label(label)
7431 .into_iter()
7432 .map(|id| NodeRef { db: self, id })
7433 .collect()
7434 }
7435
7436 pub fn find_nodes(&self, label: &str, filter: &Filter) -> Vec<NodeRef<'_, F>> {
7437 let view = self.view();
7438 view.nodes_with_label(label)
7439 .into_iter()
7440 .filter(|&id| {
7441 eval_filter(filter, &|field| {
7442 view.prop(id, field).map(|vr| vr.into_value())
7443 })
7444 })
7445 .map(|id| NodeRef { db: self, id })
7446 .collect()
7447 }
7448
7449 /// Returns `true` if any approximate (HNSW) VectorSimilar rule covers
7450 /// `field`. Use as a capability probe: when `true`, `find_similar_vector`
7451 /// with `label = None` will use the native ANN path rather than the O(n)
7452 /// brute-force scan.
7453 pub fn has_vector_rule(&self, field: &str) -> bool {
7454 self.engine.hnsw_has_rule(field)
7455 }
7456
7457 /// Find nodes whose `field` vector is most similar to `q` (cosine
7458 /// similarity), returning up to `k` results with similarity ≥ `min`,
7459 /// sorted descending.
7460 ///
7461 /// When `label` is `None` the search spans all labels (via
7462 /// `hnsw_search_any_dst` or a full brute-force scan); when `label` is
7463 /// `Some(lbl)` it restricts to nodes with that label.
7464 ///
7465 /// Uses the HNSW index when one is available (fast path); otherwise falls
7466 /// back to an O(n) brute-force scan.
7467 pub fn find_similar_vector(
7468 &self,
7469 field: &str,
7470 label: Option<&str>,
7471 q: &[f64],
7472 k: usize,
7473 min: f64,
7474 ) -> Vec<(String, f64)> {
7475 // Ensure any HNSW blobs retained from the snapshot are deserialized
7476 // before the first ANN query on a clean-open (no-WAL) path.
7477 self.engine.ensure_hnsw_loaded();
7478 // L2-normalise query for cosine via dot product.
7479 let norm: f64 = q.iter().map(|x| x * x).sum::<f64>().sqrt();
7480 if norm == 0.0 {
7481 return vec![];
7482 }
7483 let q_unit: Vec<f64> = q.iter().map(|x| x / norm).collect();
7484
7485 // Try HNSW fast path.
7486 // `None` label searches across all VectorSimilar rules covering `field`
7487 // (merging their results); `Some(lbl)` restricts to rules whose
7488 // dst_label matches. Returns `None` when no populated HNSW index
7489 // covers the request — the O(n) brute-force fallback handles that case.
7490 let hnsw_hits = match label {
7491 Some(lbl) => self.engine.hnsw_search_dst(field, lbl, &q_unit, k),
7492 None => self.engine.hnsw_search_any_dst(field, &q_unit, k),
7493 };
7494 if let Some(hits) = hnsw_hits {
7495 let mut out: Vec<(String, f64)> = hits
7496 .into_iter()
7497 .filter(|&(_, sim)| sim >= min)
7498 .filter_map(|(id, sim)| self.ids.key_of(id).map(|key| (key.to_string(), sim)))
7499 .collect();
7500 out.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
7501 out.truncate(k);
7502 return out;
7503 }
7504
7505 // Brute-force fallback: O(n) scan (only reached when no HNSW index
7506 // covers the request).
7507 let view = self.view();
7508 let candidate_ids: Vec<u32> = match label {
7509 Some(lbl) => view.nodes_with_label(lbl),
7510 None => view.nodes_all(),
7511 };
7512 let mut scored: Vec<(String, f64)> = candidate_ids
7513 .into_iter()
7514 .filter_map(|id| {
7515 let v = view.prop(id, field)?;
7516 let v_owned = v.into_value();
7517 let xs = value_as_float_list(&v_owned)?;
7518 let v_norm: f64 = xs.iter().map(|x| x * x).sum::<f64>().sqrt();
7519 if v_norm == 0.0 {
7520 return None;
7521 }
7522 let dot: f64 = q_unit
7523 .iter()
7524 .zip(xs.iter())
7525 .map(|(a, b)| a * (b / v_norm))
7526 .sum();
7527 if dot < min {
7528 return None;
7529 }
7530 let key = self.ids.key_of(id)?.to_string();
7531 Some((key, dot))
7532 })
7533 .collect();
7534 scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
7535 scored.truncate(k);
7536 scored
7537 }
7538
7539 /// Like [`find_similar_vector`] but restricts results to nodes visible in
7540 /// `mask`. Hidden nodes never appear in results; the mask is applied
7541 /// **before** k-truncation so a caller still receives up to `k` visible
7542 /// hits.
7543 ///
7544 /// # HNSW path (over-fetch policy)
7545 ///
7546 /// When an HNSW index covers the request, this function fetches `4 * k`
7547 /// candidates from the index and discards hidden nodes in the post-filter
7548 /// step. If fewer than `k` visible nodes remain after filtering the caller
7549 /// receives whatever is available — we do not re-query the index. The 4×
7550 /// multiplier is a heuristic suited for sparsely masked graphs; callers
7551 /// operating under a very selective mask should register a VectorSimilar
7552 /// rule with a non-approximate index, or use the brute-force path (no HNSW
7553 /// rule) which exhaustively filters through the masked [`GraphView`].
7554 ///
7555 /// # Brute-force path
7556 ///
7557 /// When no HNSW index covers the request the function builds a masked
7558 /// [`GraphView`] so that `nodes_all` / `nodes_with_label` return only
7559 /// visible nodes, guaranteeing exact `k` results (or all visible nodes if
7560 /// fewer than `k` exist).
7561 pub fn find_similar_vector_masked(
7562 &self,
7563 field: &str,
7564 label: Option<&str>,
7565 q: &[f64],
7566 k: usize,
7567 min: f64,
7568 mask: &crate::mask::NodeMask,
7569 ) -> Vec<(String, f64)> {
7570 self.engine.ensure_hnsw_loaded();
7571 let norm: f64 = q.iter().map(|x| x * x).sum::<f64>().sqrt();
7572 if norm == 0.0 {
7573 return vec![];
7574 }
7575 let q_unit: Vec<f64> = q.iter().map(|x| x / norm).collect();
7576
7577 // HNSW fast path — over-fetch 4×k so post-masking still yields up to k
7578 // visible hits. See doc comment above for the policy rationale.
7579 let over_k = k.saturating_mul(4).max(k + 1);
7580 let hnsw_hits = match label {
7581 Some(lbl) => self.engine.hnsw_search_dst(field, lbl, &q_unit, over_k),
7582 None => self.engine.hnsw_search_any_dst(field, &q_unit, over_k),
7583 };
7584 if let Some(hits) = hnsw_hits {
7585 let mut out: Vec<(String, f64)> = hits
7586 .into_iter()
7587 .filter(|&(id, sim)| sim >= min && mask.visible.contains(&id))
7588 .filter_map(|(id, sim)| self.ids.key_of(id).map(|key| (key.to_string(), sim)))
7589 .collect();
7590 out.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
7591 out.truncate(k);
7592 return out;
7593 }
7594
7595 // Brute-force fallback — masked view ensures only visible nodes are
7596 // enumerated by nodes_all(); nodes_with_label() does not filter by
7597 // mask so we apply view.visible() explicitly for the labeled case.
7598 let view = self.view_masked(mask);
7599 let candidate_ids: Vec<u32> = match label {
7600 Some(lbl) => view
7601 .nodes_with_label(lbl)
7602 .into_iter()
7603 .filter(|&id| view.visible(id))
7604 .collect(),
7605 None => view.nodes_all(),
7606 };
7607 let mut scored: Vec<(String, f64)> = candidate_ids
7608 .into_iter()
7609 .filter_map(|id| {
7610 let v = view.prop(id, field)?;
7611 let v_owned = v.into_value();
7612 let xs = value_as_float_list(&v_owned)?;
7613 let v_norm: f64 = xs.iter().map(|x| x * x).sum::<f64>().sqrt();
7614 if v_norm == 0.0 {
7615 return None;
7616 }
7617 let dot: f64 = q_unit
7618 .iter()
7619 .zip(xs.iter())
7620 .map(|(a, b)| a * (b / v_norm))
7621 .sum();
7622 if dot < min {
7623 return None;
7624 }
7625 let key = self.ids.key_of(id)?.to_string();
7626 Some((key, dot))
7627 })
7628 .collect();
7629 scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
7630 scored.truncate(k);
7631 scored
7632 }
7633
7634 /// Read a single property from an edge.
7635 ///
7636 /// Returns `None` when the edge does not exist, the field is absent, or any
7637 /// of the string keys cannot be resolved to interned ids. Only edge props
7638 /// written by rules (weight fields) are accessible without a `set_edge_prop`
7639 /// binding; topology-only edges (no props set) return `None` for every field.
7640 pub fn get_edge_prop(
7641 &self,
7642 edge_type: &str,
7643 src_key: &str,
7644 dst_key: &str,
7645 field: &str,
7646 ) -> Option<Value> {
7647 let etype = self.syms.get(edge_type)?;
7648 let src = self.ids.get(src_key)?;
7649 let dst = self.ids.get(dst_key)?;
7650 self.edge_props_view().get(etype, src, dst, field)
7651 }
7652
7653 /// Lex → parse → plan → execute `cypher` over a read-only view.
7654 /// Every pipeline `Err(String)` becomes `GraphError::QueryError` with a
7655 /// stage prefix (`lex:` / `parse:` / `plan:` / `execute:`).
7656 pub fn query(&self, cypher: &str, params: &BTreeMap<String, Value>) -> Result<ResultSet> {
7657 let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
7658 detail: format!("lex: {e}"),
7659 })?;
7660 let union = parse_read(&tokens).map_err(|e| GraphError::QueryError {
7661 detail: format!("parse: {e}"),
7662 })?;
7663 let t0 = std::time::Instant::now();
7664 let result = execute_union(&self.view(), &union, &Params(params)).map_err(|e| {
7665 GraphError::QueryError {
7666 detail: format!("execute: {e}"),
7667 }
7668 });
7669 let elapsed_ms = t0.elapsed().as_millis() as u64;
7670 let threshold = self.slow_query_threshold_ms;
7671 if threshold > 0 && elapsed_ms >= threshold {
7672 eprintln!("[mushroomdb] slow query ({elapsed_ms}ms): {cypher}");
7673 let entry = SlowQueryEntry {
7674 ms: elapsed_ms,
7675 query: cypher.to_string(),
7676 at_commit: self.commit_seq,
7677 };
7678 if let Ok(mut log) = self.slow_queries.lock() {
7679 if log.entries.len() == SLOW_QUERY_RING_CAP {
7680 log.entries.pop_front();
7681 }
7682 log.entries.push_back(entry);
7683 log.total += 1;
7684 }
7685 }
7686 result
7687 }
7688
7689 /// Convenience entry-point that accepts a slice of `(name, value)` pairs
7690 /// instead of a pre-built `BTreeMap`. Equivalent to building the map and
7691 /// calling [`GraphDb::query`].
7692 pub fn query_with_params(&self, cypher: &str, params: &[(&str, Value)]) -> Result<ResultSet> {
7693 let map: BTreeMap<String, Value> = params
7694 .iter()
7695 .map(|(k, v)| (k.to_string(), v.clone()))
7696 .collect();
7697 self.query(cypher, &map)
7698 }
7699
7700 /// Execute a Cypher write statement (CREATE / MATCH…SET / MATCH…DELETE / MERGE).
7701 ///
7702 /// All mutations flow through the same `insert_node` / `set_prop` /
7703 /// `delete_edge` / `insert_edge` path as the Rust API so the rule engine
7704 /// fires and the WAL captures everything with one fsync per statement.
7705 ///
7706 /// Returns a one-row [`ResultSet`] with columns `created`, `properties_set`,
7707 /// and `deleted` matching the write-result contract.
7708 ///
7709 /// **Mutation routing**: mutations are collected into a single
7710 /// [`BatchBuilder`] and committed atomically (one WAL `Batch` frame, one
7711 /// fsync). The MATCH phase for SET/DELETE uses a read-only `execute` call
7712 /// over `self.view()` — the borrow is dropped before the batch is opened.
7713 ///
7714 /// **Limitations (v1)**:
7715 /// - SET RHS must be a literal, `$param`, or arithmetic; bare property copy → named error.
7716 /// - `DETACH DELETE n` → calls `delete_node` for each matched node (removes all edges).
7717 /// - Bare `DELETE n` → error if n has any incident edges; succeeds for isolated nodes.
7718 /// - MERGE supports `ON CREATE SET` / `ON MATCH SET` in the same write batch.
7719 /// - Deleting a derived edge → named error "cannot delete derived edge".
7720 pub fn query_write(
7721 &mut self,
7722 cypher: &str,
7723 params: &BTreeMap<String, Value>,
7724 ) -> Result<ResultSet> {
7725 let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
7726 detail: format!("lex: {e}"),
7727 })?;
7728 let stmt = parse_write(&tokens).map_err(|e| GraphError::QueryError {
7729 detail: format!("parse: {e}"),
7730 })?;
7731 self.exec_write_stmt(stmt, params)
7732 }
7733
7734 fn exec_write_stmt(
7735 &mut self,
7736 stmt: WriteStatement,
7737 params: &BTreeMap<String, Value>,
7738 ) -> Result<ResultSet> {
7739 match stmt {
7740 WriteStatement::Create(s) => self.exec_create(s, params),
7741 WriteStatement::MatchSet(s) => self.exec_match_set(s, params),
7742 WriteStatement::MatchDelete(s) => self.exec_match_delete(s, params),
7743 WriteStatement::MatchDeleteNode(s) => self.exec_match_delete_node(s, params),
7744 WriteStatement::Merge(s) => self.exec_merge(s, params),
7745 }
7746 }
7747
7748 fn exec_create(
7749 &mut self,
7750 stmt: core_query::cypher::CreateStmt,
7751 params: &BTreeMap<String, Value>,
7752 ) -> Result<ResultSet> {
7753 // Extract the node key from props: require a string-valued `id` field.
7754 let mut var_to_key: BTreeMap<String, String> = BTreeMap::new();
7755 for node in &stmt.nodes {
7756 let var = node.var.as_deref().unwrap_or("_cn0");
7757 let key = node
7758 .props
7759 .iter()
7760 .find(|(f, _)| f == "id")
7761 .and_then(|(_, v)| {
7762 if let Value::Str(s) = v {
7763 Some(s.clone())
7764 } else {
7765 None
7766 }
7767 })
7768 .ok_or_else(|| GraphError::QueryError {
7769 detail: format!(
7770 "CREATE node ({}:{}) requires a string 'id' property",
7771 var, node.label
7772 ),
7773 })?;
7774 var_to_key.insert(var.to_string(), key);
7775 }
7776
7777 let mut batch = self.batch();
7778 let mut created: usize = 0;
7779 for node in &stmt.nodes {
7780 let var = node.var.as_deref().unwrap_or("_cn0");
7781 let key = &var_to_key[var];
7782 batch.insert_node(&node.label, key, node.props.clone());
7783 created += 1;
7784 }
7785 for edge in &stmt.edges {
7786 let src_key = var_to_key
7787 .get(&edge.src_var)
7788 .ok_or_else(|| GraphError::QueryError {
7789 detail: format!("CREATE edge src variable '{}' is not bound", edge.src_var),
7790 })?;
7791 let dst_key = var_to_key
7792 .get(&edge.dst_var)
7793 .ok_or_else(|| GraphError::QueryError {
7794 detail: format!("CREATE edge dst variable '{}' is not bound", edge.dst_var),
7795 })?;
7796 batch.insert_edge(&edge.etype, src_key, dst_key);
7797 }
7798 batch.commit()?;
7799
7800 // Optional RETURN clause: project created bindings as a read result.
7801 if let Some(returns) = stmt.returns {
7802 // Each created node is looked up by its key via a separate MATCH pattern.
7803 // Multiple single-node patterns cross-join to produce 1 output row with
7804 // all variables bound (each pattern returns exactly 1 row).
7805 let patterns: Vec<Pattern> = stmt
7806 .nodes
7807 .iter()
7808 .map(|node| {
7809 let var = node.var.as_deref().unwrap_or("_cn0");
7810 let key = var_to_key[var].clone();
7811 Pattern {
7812 start: NodePat {
7813 var: Some(var.to_string()),
7814 label: Some(node.label.clone()),
7815 props: vec![("id".to_string(), Operand::Lit(Value::Str(key)))],
7816 },
7817 chain: vec![],
7818 shortest: false,
7819 }
7820 })
7821 .collect();
7822 let q = Query {
7823 matches: patterns,
7824 optional_clauses: vec![],
7825 where_expr: None,
7826 unwinds: vec![],
7827 post_unwind_where: None,
7828 stages: vec![],
7829 returns,
7830 distinct: false,
7831 order_by: vec![],
7832 skip: None,
7833 limit: None,
7834 };
7835 let ops = plan(&q).map_err(|e| GraphError::QueryError {
7836 detail: format!("plan: {e}"),
7837 })?;
7838 return execute(&self.view(), &ops, &Params(params)).map_err(|e| {
7839 GraphError::QueryError {
7840 detail: format!("execute: {e}"),
7841 }
7842 });
7843 }
7844
7845 let mut rs = write_result_set();
7846 rs.push_row(vec![
7847 Some(Value::Int(created as i64)),
7848 Some(Value::Int(0)),
7849 Some(Value::Int(0)),
7850 ]);
7851 Ok(rs)
7852 }
7853
7854 fn exec_match_set(
7855 &mut self,
7856 stmt: core_query::cypher::MatchSetStmt,
7857 params: &BTreeMap<String, Value>,
7858 ) -> Result<ResultSet> {
7859 let project_returns = stmt.returns.clone();
7860 // Collect unique node vars targeted by SET clauses, plus RETURN bindings
7861 // so the post-write projection can look them up by key.
7862 let mut set_vars: Vec<String> = Vec::new();
7863 for s in &stmt.sets {
7864 if !set_vars.contains(&s.var) {
7865 set_vars.push(s.var.clone());
7866 }
7867 }
7868 let rel_vars = pattern_rel_vars(&stmt.matches);
7869 let mut lookup_vars = set_vars.clone();
7870 for v in pattern_node_vars(&stmt.matches) {
7871 add_var(&mut lookup_vars, &v);
7872 }
7873 if let Some(ref returns) = project_returns {
7874 for v in ret_node_vars(returns) {
7875 if !rel_vars.iter().any(|r| r == &v) {
7876 add_var(&mut lookup_vars, &v);
7877 }
7878 }
7879 }
7880
7881 // Synthesize a read query: MATCH … WHERE … RETURN <lookup_vars>, <set_values…>
7882 // SET values are projected as ScalarExpr items so that arithmetic expressions
7883 // (e.g. `SET n.score = n.score * 1.5`) are evaluated in the matched-row context.
7884 let mut set_returns: Vec<RetItem> = lookup_vars
7885 .iter()
7886 .map(|v| RetItem {
7887 value: RetVal::Var(v.clone()),
7888 alias: None,
7889 })
7890 .collect();
7891 // One computed column per SET clause; alias is `__sv_<i>`.
7892 let set_val_cols: Vec<String> = stmt
7893 .sets
7894 .iter()
7895 .enumerate()
7896 .map(|(i, _)| format!("__sv_{i}"))
7897 .collect();
7898 for (sc, col) in stmt.sets.iter().zip(&set_val_cols) {
7899 set_returns.push(RetItem {
7900 value: RetVal::ScalarExpr(sc.value.clone()),
7901 alias: Some(col.clone()),
7902 });
7903 }
7904 // Capture relationship types while r is bound; SET does not change them.
7905 for r in &rel_vars {
7906 set_returns.push(RetItem {
7907 value: RetVal::FuncCall {
7908 name: "type".into(),
7909 args: vec![Operand::Var(r.clone())],
7910 },
7911 alias: Some(rel_type_alias(r)),
7912 });
7913 }
7914
7915 let read_q = Query {
7916 matches: stmt.matches.clone(),
7917 optional_clauses: vec![],
7918 where_expr: stmt.where_expr.clone(),
7919 unwinds: vec![],
7920 post_unwind_where: None,
7921 stages: vec![],
7922 returns: set_returns,
7923 distinct: false,
7924 order_by: vec![],
7925 skip: None,
7926 limit: None,
7927 };
7928 let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
7929 detail: format!("plan: {e}"),
7930 })?;
7931 // MATCH phase is read-only; borrow ends before batch opens.
7932 //
7933 // When a role-scoped write is in flight, run the MATCH read through
7934 // view_masked so hidden nodes are invisible → hidden ≡ absent ≡
7935 // zero-rows (no SetProp ops generated, no existence-oracle 403).
7936 // Full-authority writes (pending_write_authz=None) keep view().
7937 let match_rs = {
7938 let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
7939 if let Some(ref mask) = mask_opt {
7940 execute(&self.view_masked(mask), &ops, &Params(params))
7941 } else {
7942 execute(&self.view(), &ops, &Params(params))
7943 }
7944 }
7945 .map_err(|e| GraphError::QueryError {
7946 detail: format!("execute: {e}"),
7947 })?;
7948
7949 // Collect (key, field, value) for each matched row × each SET clause.
7950 let mut set_ops: Vec<(String, String, Value)> = Vec::new();
7951 for row_i in 0..match_rs.len() {
7952 for (sc, col) in stmt.sets.iter().zip(&set_val_cols) {
7953 let key = match match_rs.get(row_i, &sc.var) {
7954 Some(Value::Str(k)) => k.clone(),
7955 _ => {
7956 return Err(GraphError::QueryError {
7957 detail: format!(
7958 "SET variable '{}' did not resolve to a node key",
7959 sc.var
7960 ),
7961 })
7962 }
7963 };
7964 // The SET value was already evaluated by the executor.
7965 let value = match match_rs.get(row_i, col) {
7966 Some(v) => v.clone(),
7967 None => {
7968 return Err(GraphError::QueryError {
7969 detail: format!(
7970 "SET value for {}.{} evaluated to null",
7971 sc.var, sc.field
7972 ),
7973 })
7974 }
7975 };
7976 set_ops.push((key, sc.field.clone(), value));
7977 }
7978 }
7979
7980 // Apply as one atomic batch.
7981 let props_set = set_ops.len();
7982 let mut batch = self.batch();
7983 for (key, field, value) in set_ops {
7984 batch.set_prop(&key, &field, value);
7985 }
7986 batch.commit()?;
7987
7988 if let Some(returns) = project_returns {
7989 return project_set_return_rows(self, &rel_vars, &match_rs, &returns, params);
7990 }
7991
7992 let mut rs = write_result_set();
7993 rs.push_row(vec![
7994 Some(Value::Int(0)),
7995 Some(Value::Int(props_set as i64)),
7996 Some(Value::Int(0)),
7997 ]);
7998 Ok(rs)
7999 }
8000
8001 fn exec_match_delete(
8002 &mut self,
8003 stmt: core_query::cypher::MatchDeleteStmt,
8004 params: &BTreeMap<String, Value>,
8005 ) -> Result<ResultSet> {
8006 // Collect unique node vars needed to identify edge endpoints.
8007 let mut node_vars: Vec<String> = Vec::new();
8008 for ed in &stmt.deletes {
8009 if !node_vars.contains(&ed.src_var) {
8010 node_vars.push(ed.src_var.clone());
8011 }
8012 if !node_vars.contains(&ed.dst_var) {
8013 node_vars.push(ed.dst_var.clone());
8014 }
8015 }
8016
8017 // Synthesize read query.
8018 let returns: Vec<RetItem> = node_vars
8019 .iter()
8020 .map(|v| RetItem {
8021 value: RetVal::Var(v.clone()),
8022 alias: None,
8023 })
8024 .collect();
8025 let read_q = Query {
8026 matches: stmt.matches,
8027 optional_clauses: vec![],
8028 where_expr: stmt.where_expr,
8029 unwinds: vec![],
8030 post_unwind_where: None,
8031 stages: vec![],
8032 returns,
8033 distinct: false,
8034 order_by: vec![],
8035 skip: None,
8036 limit: None,
8037 };
8038 let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
8039 detail: format!("plan: {e}"),
8040 })?;
8041 // Role-scoped writes: mask the MATCH read phase so hidden nodes are
8042 // invisible → hidden ≡ absent ≡ zero-rows (spec §3.1, hidden ≡ absent).
8043 let match_rs = {
8044 let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
8045 if let Some(ref mask) = mask_opt {
8046 execute(&self.view_masked(mask), &ops, &Params(params))
8047 } else {
8048 execute(&self.view(), &ops, &Params(params))
8049 }
8050 }
8051 .map_err(|e| GraphError::QueryError {
8052 detail: format!("execute: {e}"),
8053 })?;
8054
8055 // Collect (etype, src_key, dst_key) for each row × each delete target.
8056 let mut del_ops: Vec<(String, String, String)> = Vec::new();
8057 for row_i in 0..match_rs.len() {
8058 for ed in &stmt.deletes {
8059 let src_key = match match_rs.get(row_i, &ed.src_var) {
8060 Some(Value::Str(k)) => k.clone(),
8061 _ => {
8062 return Err(GraphError::QueryError {
8063 detail: format!(
8064 "DELETE src variable '{}' did not resolve to a node key",
8065 ed.src_var
8066 ),
8067 })
8068 }
8069 };
8070 let dst_key = match match_rs.get(row_i, &ed.dst_var) {
8071 Some(Value::Str(k)) => k.clone(),
8072 _ => {
8073 return Err(GraphError::QueryError {
8074 detail: format!(
8075 "DELETE dst variable '{}' did not resolve to a node key",
8076 ed.dst_var
8077 ),
8078 })
8079 }
8080 };
8081 del_ops.push((ed.etype.clone(), src_key, dst_key));
8082 }
8083 }
8084
8085 // Apply as one atomic batch.
8086 let deleted = del_ops.len();
8087 let mut batch = self.batch();
8088 for (etype, src_key, dst_key) in del_ops {
8089 batch.delete_edge(&etype, &src_key, &dst_key);
8090 }
8091 batch.commit().map_err(|e| match e {
8092 GraphError::RuleOwned { .. } => GraphError::QueryError {
8093 detail: "cannot delete derived edge; retract via the rule or change the property"
8094 .to_string(),
8095 },
8096 other => other,
8097 })?;
8098
8099 let mut rs = write_result_set();
8100 rs.push_row(vec![
8101 Some(Value::Int(0)),
8102 Some(Value::Int(0)),
8103 Some(Value::Int(deleted as i64)),
8104 ]);
8105 Ok(rs)
8106 }
8107
8108 /// Execute `MATCH … [DETACH] DELETE <node_var> [, …]`.
8109 ///
8110 /// Collects the matching node keys via an ephemeral read query, then calls
8111 /// `delete_node` on each one. When `stmt.detach` is `false` (bare DELETE)
8112 /// the executor first checks that the node has no incident edges; if any
8113 /// remain it returns a named error matching openCypher semantics.
8114 fn exec_match_delete_node(
8115 &mut self,
8116 stmt: MatchDeleteNodeStmt,
8117 params: &BTreeMap<String, Value>,
8118 ) -> Result<ResultSet> {
8119 // Build a read query returning only the node keys we need.
8120 let returns: Vec<RetItem> = stmt
8121 .node_vars
8122 .iter()
8123 .map(|v| RetItem {
8124 value: RetVal::Var(v.clone()),
8125 alias: None,
8126 })
8127 .collect();
8128 let read_q = Query {
8129 matches: stmt.matches,
8130 optional_clauses: vec![],
8131 where_expr: stmt.where_expr,
8132 unwinds: vec![],
8133 post_unwind_where: None,
8134 stages: vec![],
8135 returns,
8136 distinct: false,
8137 order_by: vec![],
8138 skip: None,
8139 limit: None,
8140 };
8141 let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
8142 detail: format!("plan: {e}"),
8143 })?;
8144 // Role-scoped writes: mask the MATCH read phase so hidden nodes are
8145 // invisible → hidden ≡ absent ≡ zero-rows (spec §3.1, hidden ≡ absent).
8146 let match_rs = {
8147 let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
8148 if let Some(ref mask) = mask_opt {
8149 execute(&self.view_masked(mask), &ops, &Params(params))
8150 } else {
8151 execute(&self.view(), &ops, &Params(params))
8152 }
8153 }
8154 .map_err(|e| GraphError::QueryError {
8155 detail: format!("execute: {e}"),
8156 })?;
8157
8158 // Collect unique node keys to delete (deduplicate across rows × vars).
8159 let mut keys: Vec<String> = Vec::new();
8160 for row_i in 0..match_rs.len() {
8161 for var in &stmt.node_vars {
8162 if let Some(Value::Str(k)) = match_rs.get(row_i, var) {
8163 if !keys.contains(k) {
8164 keys.push(k.clone());
8165 }
8166 }
8167 }
8168 }
8169
8170 if !stmt.detach {
8171 // openCypher bare DELETE: error if any matched node has incident edges.
8172 for key in &keys {
8173 if let Some(id) = self.ids.get(key) {
8174 let tv = self.topo_view();
8175 let has_edges = tv.etypes().any(|et| {
8176 !tv.neighbors(et, Direction::Out, id).is_empty()
8177 || !tv.neighbors(et, Direction::In, id).is_empty()
8178 });
8179 if has_edges {
8180 return Err(GraphError::QueryError {
8181 detail: format!(
8182 "Cannot delete node `{key}` because it still has incident edges. \
8183 Use DETACH DELETE to remove the node and all its edges."
8184 ),
8185 });
8186 }
8187 }
8188 }
8189 }
8190
8191 let mut nodes_deleted = 0i64;
8192 let mut edges_deleted = 0i64;
8193 for key in keys {
8194 match self.delete_node(&key) {
8195 Ok(report) => {
8196 nodes_deleted += 1;
8197 edges_deleted += (report.manual_edges + report.derived_edges) as i64;
8198 }
8199 Err(GraphError::KeyNotFound { .. }) => {
8200 // Node may have been deleted by an earlier iteration (e.g., via
8201 // multiple MATCH rows for the same node). Safe to skip.
8202 }
8203 Err(e) => return Err(e),
8204 }
8205 }
8206
8207 let mut rs = write_result_set();
8208 rs.push_row(vec![
8209 Some(Value::Int(0)),
8210 Some(Value::Int(0)),
8211 Some(Value::Int(nodes_deleted + edges_deleted)),
8212 ]);
8213 Ok(rs)
8214 }
8215
8216 fn exec_merge(
8217 &mut self,
8218 stmt: core_query::cypher::MergeStmt,
8219 params: &BTreeMap<String, Value>,
8220 ) -> Result<ResultSet> {
8221 // MERGE: check if a node with the given key already exists.
8222 let key = match &stmt.key_value {
8223 Value::Str(s) => s.clone(),
8224 _ => {
8225 return Err(GraphError::QueryError {
8226 detail: format!(
8227 "MERGE key value must be a string (got {:?})",
8228 stmt.key_value
8229 ),
8230 })
8231 }
8232 };
8233
8234 if let Some(var) = stmt.var.as_deref() {
8235 for sc in stmt.on_create.iter().chain(&stmt.on_match) {
8236 if sc.var != var {
8237 return Err(GraphError::QueryError {
8238 detail: format!(
8239 "SET variable '{}' does not match MERGE variable '{var}'",
8240 sc.var
8241 ),
8242 });
8243 }
8244 }
8245 }
8246
8247 // ── MERGE authz pre-check (when role-scoped) ─────────────────────────
8248 //
8249 // MERGE scope precondition: check create OR update scope for the
8250 // declared label BEFORE calling `has_node` (timing-oracle closure,
8251 // spec §6.2 "MERGE visibility oracle" item: hidden ≡ absent for
8252 // unscoped roles — the scope denial fires without touching the key store).
8253 //
8254 // Clone to avoid holding a borrow on `self.pending_write_authz` while
8255 // also calling `self.ids.get(key)`.
8256 let merge_existed: bool = if let Some(authz) = self.pending_write_authz.clone() {
8257 let has_create = authz.scope.create_labels.contains(&stmt.label);
8258 let has_update = authz.scope.update_labels.contains(&stmt.label);
8259 if !has_create && !has_update {
8260 // Scope-before-lookup: 403 without has_node call (timing oracle
8261 // closure — see test_merge_unscoped_no_key_lookup).
8262 return Err(GraphError::RoleWriteDenied {
8263 reason: format!(
8264 "role-bound token: label '{}' not in write scope (create_labels)",
8265 stmt.label
8266 ),
8267 });
8268 }
8269 // Key lookup under mask.
8270 match self.ids.get(key.as_str()) {
8271 Some(id) if authz.mask.contains_id(id) => {
8272 // Visible: must have update scope to proceed to match arm.
8273 if !has_update {
8274 return Err(GraphError::RoleWriteDenied {
8275 reason: format!(
8276 "role-bound token: label '{}' not in write scope (update_labels)",
8277 stmt.label
8278 ),
8279 });
8280 }
8281 true // existed = true → match arm
8282 }
8283 Some(_) => {
8284 // Hidden: same error as absent to the role (spec §3.1/§3.3).
8285 return Err(GraphError::RoleWriteDenied {
8286 reason: "role-bound token: target node not visible".into(),
8287 });
8288 }
8289 None => {
8290 // Absent: must have create scope to proceed to the create arm.
8291 //
8292 // Update-only roles (create_labels empty, update_labels set):
8293 // return the SAME "not visible" error as the hidden-key branch
8294 // so hidden ≡ absent — no distinguishing oracle (spec §6.1
8295 // "confirm existence of hidden nodes: No").
8296 //
8297 // Create-scoped roles (has_create=true): absent → create arm
8298 // as before. The accepted structural key-existence disclosure
8299 // (§THREAT-MODEL) applies only when the role holds create scope.
8300 if !has_create {
8301 return Err(GraphError::RoleWriteDenied {
8302 reason: "role-bound token: target node not visible".into(),
8303 });
8304 }
8305 false // existed = false → create arm
8306 }
8307 }
8308 } else {
8309 // Full authority: use the existing non-masked has_node check.
8310 self.has_node(&key)
8311 };
8312
8313 let existed = merge_existed;
8314 let mut created = 0i64;
8315 if !existed || !stmt.on_match.is_empty() {
8316 let mut batch = self.batch();
8317 if !existed {
8318 let props = vec![(stmt.key_field.clone(), stmt.key_value.clone())];
8319 batch.insert_node(&stmt.label, &key, props);
8320 for sc in &stmt.on_create {
8321 let value = resolve_merge_set_value(&sc.value, params)?;
8322 batch.set_prop(&key, &sc.field, value);
8323 }
8324 created = 1;
8325 } else {
8326 for sc in &stmt.on_match {
8327 let value = resolve_merge_set_value(&sc.value, params)?;
8328 batch.set_prop(&key, &sc.field, value);
8329 }
8330 }
8331 batch.commit()?;
8332 }
8333
8334 // Refresh the role mask so the just-created node is visible to this
8335 // statement's RETURN (read-after-write). Safe: create_labels ⊆ read labels
8336 // (apply_schema subset rule), so the new node's label is already in the
8337 // role's read scope — this never widens beyond the role's declared labels.
8338 if !existed {
8339 if let Some(role) = self.pending_write_authz.as_ref().map(|a| a.role.clone()) {
8340 let new_mask = self.mask_for_role(&role)?;
8341 if let Some(a) = self.pending_write_authz.as_mut() {
8342 a.mask = new_mask;
8343 }
8344 }
8345 }
8346
8347 // Optional RETURN clause: project the node (created or matched) as a read result.
8348 if let Some(returns) = stmt.returns {
8349 let var = stmt.var.as_deref().unwrap_or("_mn0");
8350 let q = Query {
8351 matches: vec![Pattern {
8352 start: NodePat {
8353 var: Some(var.to_string()),
8354 label: Some(stmt.label.clone()),
8355 props: vec![("id".to_string(), Operand::Lit(stmt.key_value.clone()))],
8356 },
8357 chain: vec![],
8358 shortest: false,
8359 }],
8360 optional_clauses: vec![],
8361 where_expr: None,
8362 unwinds: vec![],
8363 post_unwind_where: None,
8364 stages: vec![],
8365 returns,
8366 distinct: false,
8367 order_by: vec![],
8368 skip: None,
8369 limit: None,
8370 };
8371 let ops = plan(&q).map_err(|e| GraphError::QueryError {
8372 detail: format!("plan: {e}"),
8373 })?;
8374 // Use view_masked when a role-scoped write is in flight so the
8375 // post-merge projection is consistent with the masked read phase.
8376 let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
8377 return (if let Some(ref mask) = mask_opt {
8378 execute(&self.view_masked(mask), &ops, &Params(params))
8379 } else {
8380 execute(&self.view(), &ops, &Params(params))
8381 })
8382 .map_err(|e| GraphError::QueryError {
8383 detail: format!("execute: {e}"),
8384 });
8385 }
8386
8387 let mut rs = write_result_set();
8388 rs.push_row(vec![
8389 Some(Value::Int(created)),
8390 Some(Value::Int(0)),
8391 Some(Value::Int(0)),
8392 ]);
8393 Ok(rs)
8394 }
8395
8396 /// Return all rule-owned edges between `key_a` and `key_b` (either direction),
8397 /// annotated with rule name, edge type, direction, and weight.
8398 /// Results are sorted by (rule, edge_type).
8399 /// Returns `Err(KeyNotFound)` if either key is unknown.
8400 pub fn explain(&self, key_a: &str, key_b: &str) -> Result<Vec<Explanation>> {
8401 self.ensure_v8_base_sections_loaded();
8402 let id_a = self
8403 .ids
8404 .get(key_a)
8405 .ok_or_else(|| GraphError::KeyNotFound { key: key_a.into() })?;
8406 let id_b = self
8407 .ids
8408 .get(key_b)
8409 .ok_or_else(|| GraphError::KeyNotFound { key: key_b.into() })?;
8410
8411 let mut results = Vec::new();
8412
8413 // Walk the smaller incident set so explain is O(min(deg(a), deg(b)))
8414 // rather than O(total provenance).
8415 let scan = if self.engine.provenance_touching_len(id_a)
8416 <= self.engine.provenance_touching_len(id_b)
8417 {
8418 id_a
8419 } else {
8420 id_b
8421 };
8422 for (rule_name, etype, src, dst) in self.engine.provenance_touching(scan) {
8423 if !((src == id_a && dst == id_b) || (src == id_b && dst == id_a)) {
8424 continue;
8425 }
8426 let Some(rule_def) = self.engine.rules().find(|r| r.name == rule_name) else {
8427 continue;
8428 };
8429 let edge_type = match self.syms.resolve(etype) {
8430 Some(s) => s.to_string(),
8431 None => continue,
8432 };
8433 // Provenance (src, dst) ids come from the archived PROVENANCE section
8434 // (large, no eager CRC). A corrupt section can produce ids that are
8435 // out of range; return Corrupt rather than panic.
8436 let src_key = self
8437 .ids
8438 .key_of(src)
8439 .ok_or_else(|| GraphError::Corrupt {
8440 detail: format!("v8: provenance src id {src} not in id table"),
8441 })?
8442 .to_string();
8443 let dst_key = self
8444 .ids
8445 .key_of(dst)
8446 .ok_or_else(|| GraphError::Corrupt {
8447 detail: format!("v8: provenance dst id {dst} not in id table"),
8448 })?
8449 .to_string();
8450 let stored = rule_def.weight_prop.as_deref().and_then(|prop| {
8451 self.edge_props_view()
8452 .get(etype, src, dst, prop)
8453 .and_then(|v| {
8454 if let Value::Float(f) = v {
8455 Some(f)
8456 } else {
8457 None
8458 }
8459 })
8460 });
8461 // Rules that store no weight (KeyMatch/FieldEqual defaults, auto-FK)
8462 // still have a score: recompute it from the predicate so explain
8463 // never reports "no score" for an edge the engine scored. Via-hop
8464 // rules score over their via set, not over (src, dst), so leave
8465 // those None rather than report a number the rule did not produce.
8466 let weight = stored.or_else(|| {
8467 if rule_def.via_edge.is_some() {
8468 return None;
8469 }
8470 let props_view = build_props_view(&self.props, &self.base);
8471 let src_get = |field: &str| props_view.get(src, field).map(|vr| vr.into_value());
8472 let dst_get = |field: &str| props_view.get(dst, field).map(|vr| vr.into_value());
8473 let src_view = NodeView {
8474 key: &src_key,
8475 props: &src_get,
8476 };
8477 let dst_view = NodeView {
8478 key: &dst_key,
8479 props: &dst_get,
8480 };
8481 evaluate(&rule_def.predicate, &src_view, &dst_view)
8482 });
8483 results.push(Explanation {
8484 rule: rule_name.to_string(),
8485 edge_type,
8486 src_key,
8487 dst_key,
8488 weight,
8489 predicate: PredicateSummary {
8490 approximate: rule_def.approximate,
8491 ..PredicateSummary::from(&rule_def.predicate)
8492 },
8493 via_edge: rule_def.via_edge.clone(),
8494 });
8495 }
8496
8497 results.sort_by(|a, b| a.rule.cmp(&b.rule).then(a.edge_type.cmp(&b.edge_type)));
8498 Ok(results)
8499 }
8500
8501 pub fn neighbors(&self, key: &str, edge_type: &str, dir: Direction) -> Result<Vec<String>> {
8502 let id = self
8503 .ids
8504 .get(key)
8505 .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
8506 let Some(sym) = self.syms.get(edge_type) else {
8507 return Ok(Vec::new());
8508 };
8509 self.topo_view()
8510 .neighbors(sym, dir, id)
8511 .iter()
8512 .map(|&n| {
8513 self.ids
8514 .key_of(n)
8515 .map(|k| k.to_string())
8516 .ok_or_else(|| GraphError::Corrupt {
8517 detail: format!("topology id {n} has no key"),
8518 })
8519 })
8520 .collect::<Result<Vec<_>>>()
8521 }
8522
8523 /// Return the last-change commit sequence for `key`, or `None` if the node
8524 /// does not exist or has never been mutated since the last V5-V7 snapshot
8525 /// (horizon-bounded for legacy stores).
8526 ///
8527 /// The returned sequence is a monotonically increasing counter that starts
8528 /// at 1 for the first commit after `open` and increments with every
8529 /// successful write. WAL replay at open also assigns sequences (1..N for N
8530 /// replayed frames), so sequences are consistent across snapshot+WAL cycles.
8531 ///
8532 /// For V5-V7 stores opened without a V8 snapshot, nodes that were present
8533 /// in the snapshot but not touched by any WAL frame will return `None`
8534 /// (horizon-bounded: CAS against such nodes is only safe after the first
8535 /// V8 snapshot or after the node is next mutated).
8536 pub fn last_changed(&self, key: &str) -> Option<u64> {
8537 let id = self.ids.get(key)?;
8538 self.last_change.get(&id).copied()
8539 }
8540
8541 /// The current commit sequence (number of successful commits since open,
8542 /// including WAL replay frames). Useful for recording a baseline before
8543 /// a read-modify-write cycle.
8544 pub fn commit_seq(&self) -> u64 {
8545 self.commit_seq
8546 }
8547
8548 /// Check that all `preconds` are satisfied against the current db state.
8549 /// Returns `Err(GraphError::CasConflict)` on the first failing precondition.
8550 pub(crate) fn check_preconditions(&self, preconds: &[Precondition]) -> Result<()> {
8551 for precond in preconds {
8552 match precond {
8553 Precondition::NodeUnchangedSince { key, expected } => {
8554 // Missing entry means the node predates the WAL window or
8555 // does not exist; treat as 0 (before any commit).
8556 let actual = self.last_changed(key).unwrap_or_default();
8557 if actual != *expected {
8558 return Err(GraphError::CasConflict {
8559 key: key.clone(),
8560 expected: *expected,
8561 actual,
8562 });
8563 }
8564 }
8565 Precondition::NodeAbsent { key } => {
8566 // Node must not exist (not live).
8567 if self.ids.get(key).is_some() {
8568 let actual = self.last_changed(key).unwrap_or(0);
8569 return Err(GraphError::CasConflict {
8570 key: key.clone(),
8571 expected: u64::MAX,
8572 actual,
8573 });
8574 }
8575 }
8576 }
8577 }
8578 Ok(())
8579 }
8580
8581 /// Apply a batch of mutations with compare-and-set preconditions.
8582 ///
8583 /// All preconditions are checked atomically before any operation is applied.
8584 /// If any precondition fails, the entire batch is rejected with
8585 /// [`GraphError::CasConflict`] and no WAL frame is written.
8586 ///
8587 /// # Returns
8588 /// `(nodes_inserted, edges_inserted)` on success, same as [`write_batch`].
8589 ///
8590 /// # Errors
8591 /// - [`GraphError::CasConflict`] if any precondition is not satisfied.
8592 /// - Any error that [`write_batch`] would return for the ops themselves.
8593 pub fn write_batch_cas(
8594 &mut self,
8595 preconds: Vec<Precondition>,
8596 ops: Vec<BatchOp>,
8597 ) -> Result<(usize, usize)> {
8598 self.check_preconditions(&preconds)?;
8599 self.commit_logged_batch(ops, None, None)
8600 }
8601
8602 /// Update the per-node last-change map for a WAL record at commit `seq`.
8603 ///
8604 /// Called after a successful apply to record which nodes were touched.
8605 /// For replay, called with the WAL-frame's replayed seq.
8606 ///
8607 /// Touch definition (see [`Precondition`] doc):
8608 /// - InsertNode / InsertNodeId / SetProp / SetPropId / RemoveProp → the node.
8609 /// - InsertEdge / InsertEdgeId / DeleteEdge → both src and dst.
8610 /// - DeleteNode → node tombstoned; last_changed() returns None so no update needed.
8611 /// - DerivedEdge markers, Intern, rule/view records → no-ops.
8612 /// - Batch → recurse into inner records.
8613 fn update_last_change_from_rec(&mut self, rec: &WalRecord, seq: u64) {
8614 match rec {
8615 WalRecord::InsertNode { key, .. }
8616 | WalRecord::SetProp { key, .. }
8617 | WalRecord::RemoveProp { key, .. } => {
8618 if let Some(id) = self.ids.get(key) {
8619 self.last_change.insert(id, seq);
8620 }
8621 }
8622 WalRecord::InsertNodeId { key, .. } => {
8623 if let Some(id) = self.ids.get(key) {
8624 self.last_change.insert(id, seq);
8625 }
8626 }
8627 WalRecord::SetPropId { id, .. } => {
8628 self.last_change.insert(*id, seq);
8629 }
8630 WalRecord::InsertEdge {
8631 src_key, dst_key, ..
8632 }
8633 | WalRecord::DeleteEdge {
8634 src_key, dst_key, ..
8635 } => {
8636 if let Some(src_id) = self.ids.get(src_key) {
8637 self.last_change.insert(src_id, seq);
8638 }
8639 if let Some(dst_id) = self.ids.get(dst_key) {
8640 self.last_change.insert(dst_id, seq);
8641 }
8642 }
8643 WalRecord::InsertEdgeId { src, dst, .. } => {
8644 self.last_change.insert(*src, seq);
8645 self.last_change.insert(*dst, seq);
8646 }
8647 // DeleteNode: node is tombstoned; last_changed(key) returns None for
8648 // deleted keys (ids.get() returns None post-tombstone), so no update needed.
8649 // History markers: state no-ops; the underlying mutation already
8650 // touched the relevant nodes' last_change entries.
8651 WalRecord::DeleteNode { .. }
8652 | WalRecord::DerivedEdgeAdded { .. }
8653 | WalRecord::DerivedEdgeRetracted { .. }
8654 | WalRecord::Intern { .. }
8655 | WalRecord::CreateRule { .. }
8656 | WalRecord::DeleteRule { .. }
8657 | WalRecord::RebuildRule { .. }
8658 | WalRecord::CreateView { .. }
8659 | WalRecord::DeleteView { .. }
8660 | WalRecord::EnableFulltext { .. }
8661 | WalRecord::DisableFulltext { .. }
8662 | WalRecord::EnableIndex { .. }
8663 | WalRecord::DisableIndex { .. } => {}
8664 // RenameNode: node id is stable; update last_change via the new key.
8665 // Called after apply(), so ids already reflects new_key.
8666 WalRecord::RenameNode { new_key, .. } => {
8667 if let Some(id) = self.ids.get(new_key) {
8668 self.last_change.insert(id, seq);
8669 }
8670 }
8671 WalRecord::Batch(inner) => {
8672 for inner_rec in inner {
8673 self.update_last_change_from_rec(inner_rec, seq);
8674 }
8675 }
8676 }
8677 }
8678
8679 pub fn node_count(&self) -> usize {
8680 self.ids.len()
8681 }
8682
8683 /// Configure archive retention: keep the `N` newest WAL archives at each
8684 /// [`snapshot_with`] call when `archive_wal: true`.
8685 ///
8686 /// `Some(N)` where N > 0 → prune oldest archives keeping the newest N.
8687 /// `Some(0)` or `None` → unlimited (no pruning).
8688 ///
8689 /// Pruning only ever happens inside [`snapshot_with`]; this method only
8690 /// stores the policy. Archives below the retention limit are deleted
8691 /// oldest-first. The horizon floor is updated so that
8692 /// [`was_linked`] / history APIs return `CommitOutOfRange` for commits
8693 /// in pruned archives rather than silently returning wrong data.
8694 pub fn set_wal_archive_retention(&mut self, keep: Option<u32>) {
8695 self.wal_archive_retention = keep;
8696 }
8697
8698 /// Delete any WAL archives that are fully below the current horizon floor.
8699 ///
8700 /// Orphaned archives arise when the floor is written first during retention
8701 /// pruning and then a crash interrupts the archive-delete sequence. The
8702 /// opening cleanup ensures no subsequent read path sees stale data.
8703 ///
8704 /// Under the monotonic naming scheme, the archive name N equals the
8705 /// cumulative end-frame index of the archive in global commit space (i.e.
8706 /// the archive covers global frames `[prev_n, N)`). An archive is
8707 /// fully orphaned when `N <= wal_horizon_floor`: all of its frames fall
8708 /// below the floor and have already been counted in it.
8709 fn cleanup_orphaned_archives(&mut self) -> Result<()> {
8710 if self.wal_horizon_floor == 0 {
8711 // Floor at 0 means no pruning has ever occurred; nothing to clean.
8712 return Ok(());
8713 }
8714 let archive_ns = self.fs.list_archives()?;
8715 for n in archive_ns {
8716 if n <= self.wal_horizon_floor {
8717 // Archive N ends at global frame N; all its frames are below
8718 // the floor (floor already accounts for them) → orphaned.
8719 self.fs.delete_archive(n).map_err(GraphError::Io)?;
8720 } else {
8721 // Archives are sorted ascending; first one above floor stops scan.
8722 break;
8723 }
8724 }
8725 Ok(())
8726 }
8727
8728 /// Collect all WAL frames from surviving archives (oldest-first) then the
8729 /// live WAL into one flat list, and return the total along with the number
8730 /// of archive frames at the front of the list.
8731 ///
8732 /// Commit indices into the returned list are LOCAL (0 = first frame of
8733 /// oldest surviving archive). To obtain the GLOBAL index add
8734 /// `self.wal_horizon_floor`.
8735 fn all_frames(&self) -> Result<(Vec<WalRecord>, u64)> {
8736 let archive_ns = self.fs.list_archives()?;
8737 let mut all: Vec<WalRecord> = Vec::new();
8738 for n in archive_ns {
8739 let bytes = self.fs.read_archive(n)?;
8740 let (frames, _) = decode_all(&bytes);
8741 all.extend(frames);
8742 }
8743 let archive_count = all.len() as u64;
8744 let live_bytes = self.fs.read(FileId::Wal)?;
8745 let (live_frames, _) = decode_all(&live_bytes);
8746 all.extend(live_frames);
8747 Ok((all, archive_count))
8748 }
8749
8750 /// Return the total number of committed WAL frames visible in the current
8751 /// horizon window, including frames in surviving WAL archives.
8752 ///
8753 /// This is the exclusive upper bound for valid `at_commit` indices in
8754 /// `was_linked`. Valid indices are `wal_horizon_floor()..wal_total_commits()`.
8755 ///
8756 /// Returns the horizon floor when all surviving history is empty.
8757 pub fn wal_total_commits(&self) -> Result<u64> {
8758 let (frames, _) = self.all_frames()?;
8759 Ok(self.wal_horizon_floor + frames.len() as u64)
8760 }
8761
8762 /// The global frame index of the first commit reachable through surviving
8763 /// archives (0 when no archives have been pruned).
8764 pub fn wal_horizon_floor(&self) -> u64 {
8765 self.wal_horizon_floor
8766 }
8767
8768 /// Return the per-node change history for `key` by scanning the on-disk WAL.
8769 ///
8770 /// ## Horizon
8771 ///
8772 /// History reaches back only to the last WAL-truncating snapshot, exactly like `open_at`.
8773 /// Snapshots written with `keep_wal: true` preserve deeper history. This is the honest,
8774 /// zero-cost contract; a durable history log is out of scope.
8775 ///
8776 /// ## Derived edges
8777 ///
8778 /// Rule-created (derived) edges are **not** in the WAL and therefore do not appear in
8779 /// history. Only edges written directly by the application are recorded.
8780 ///
8781 /// ## Deleted nodes
8782 ///
8783 /// For nodes that have been deleted, dense-id records (SetPropId, InsertEdgeId) that
8784 /// predate the deletion may not resolve (the id is tombstoned in the live map). The
8785 /// string-keyed `DeleteNode` record still matches and produces a `NodeDeleted` entry.
8786 /// Prop/edge history of a deleted node may therefore be partially unresolvable.
8787 ///
8788 /// ## Dense-id edge entries and tombstoned partners
8789 ///
8790 /// Edge entries from dense-id WAL records (`InsertEdgeId`) are omitted when the partner
8791 /// endpoint's dense id is tombstoned. As a result, a live node's history can contain an
8792 /// `EdgeRemoved` (string-keyed, always resolves) without a corresponding `EdgeAdded`.
8793 /// Build commit-bounded alias intervals for `queried_key`.
8794 ///
8795 /// Returns a list of `(key, valid_from_inclusive, valid_until_exclusive)` tuples.
8796 /// A record written under `key` at commit `c` matches the queried identity iff
8797 /// `c >= valid_from && (valid_until.is_none() || c < valid_until)`.
8798 ///
8799 /// Each alias entry carries both a lower and an upper bound so that key-reuse
8800 /// after a rename is handled correctly: if "a" is renamed to "b" at commit 5,
8801 /// then a NEW node is created as "a" at commit 7 and renamed to "c" at commit 10,
8802 /// querying "c" must NOT surface identity-1's events (commits 0–4 under "a");
8803 /// only identity-2's events (commits 7–9 under "a") are in scope.
8804 ///
8805 /// Only **forward aliasing**: querying the *new* key surfaces events written
8806 /// under the *old* key. The reverse direction is not supported.
8807 fn build_key_alias_intervals(
8808 &self,
8809 frames: &[core_storage::wal::WalRecord],
8810 queried_key: &str,
8811 ) -> Vec<(String, u64, Option<u64>)> {
8812 use core_storage::wal::WalRecord;
8813
8814 // Pre-pass: build reverse_rename and key_starts maps.
8815 let mut reverse_rename: HashMap<String, (String, u64)> = HashMap::new();
8816 let mut key_starts: HashMap<String, Vec<u64>> = HashMap::new();
8817
8818 for (local_i, frame) in frames.iter().enumerate() {
8819 let commit = self.wal_horizon_floor + local_i as u64;
8820 let records: &[WalRecord] = match frame {
8821 WalRecord::Batch(inner) => inner.as_slice(),
8822 single => std::slice::from_ref(single),
8823 };
8824 for rec in records {
8825 match rec {
8826 WalRecord::InsertNode { key, .. } | WalRecord::InsertNodeId { key, .. } => {
8827 key_starts.entry(key.clone()).or_default().push(commit);
8828 }
8829 WalRecord::RenameNode { old_key, new_key } => {
8830 // new_key came into existence at this commit.
8831 key_starts.entry(new_key.clone()).or_default().push(commit);
8832 // Record the reverse rename: new_key was introduced by renaming old_key.
8833 reverse_rename.insert(new_key.clone(), (old_key.clone(), commit));
8834 }
8835 _ => {}
8836 }
8837 }
8838 }
8839
8840 // Build alias intervals by following the reverse rename chain.
8841 let mut result: Vec<(String, u64, Option<u64>)> = Vec::new();
8842 let mut current_key = queried_key.to_string();
8843 let mut current_valid_until: Option<u64> = None;
8844
8845 loop {
8846 // valid_from: the most recent commit where current_key was assigned to this
8847 // identity. For aliases (valid_until = Some(vu)), find the last start event
8848 // for the key strictly before vu — this is where the alias's occupancy by
8849 // this identity began, correctly excluding prior identities that reused the key.
8850 let valid_from = if let Some(vu) = current_valid_until {
8851 key_starts
8852 .get(¤t_key)
8853 .and_then(|starts| starts.iter().rev().find(|&&s| s < vu).copied())
8854 .unwrap_or(self.wal_horizon_floor)
8855 } else {
8856 // Queried key — no upper bound; may have been introduced at any commit.
8857 self.wal_horizon_floor
8858 };
8859
8860 result.push((current_key.clone(), valid_from, current_valid_until));
8861
8862 match reverse_rename.get(¤t_key) {
8863 Some((old_key, rename_commit)) => {
8864 current_valid_until = Some(*rename_commit);
8865 current_key = old_key.clone();
8866 }
8867 None => break,
8868 }
8869 }
8870
8871 result
8872 }
8873
8874 /// Returns true if `record_key` matches any alias interval that covers `commit`.
8875 fn aliases_match(
8876 intervals: &[(String, u64, Option<u64>)],
8877 record_key: &str,
8878 commit: u64,
8879 ) -> bool {
8880 intervals
8881 .iter()
8882 .any(|(k, vf, vu)| k == record_key && commit >= *vf && vu.is_none_or(|u| commit < u))
8883 }
8884
8885 pub fn node_history(&self, key: &str) -> Result<Vec<crate::history::HistoryEntry>> {
8886 use crate::history::{HistoryChange, HistoryEntry};
8887 use core_storage::wal::WalRecord;
8888
8889 let (frames, _) = self.all_frames()?;
8890
8891 // Resolve commit-bounded alias intervals for `key` (handles renames in the WAL).
8892 let alias_intervals = self.build_key_alias_intervals(&frames, key);
8893
8894 let mut out: Vec<HistoryEntry> = Vec::new();
8895
8896 for (local_i, frame) in frames.iter().enumerate() {
8897 let commit = self.wal_horizon_floor + local_i as u64;
8898 // Collect the inner records to process — Batch is one commit, single records are one commit.
8899 let records: &[WalRecord] = match frame {
8900 WalRecord::Batch(inner) => inner.as_slice(),
8901 single => std::slice::from_ref(single),
8902 };
8903
8904 for rec in records {
8905 let change = match rec {
8906 WalRecord::InsertNode { label, key: k, .. }
8907 if Self::aliases_match(&alias_intervals, k, commit) =>
8908 {
8909 Some(HistoryChange::NodeInserted {
8910 label: label.clone(),
8911 })
8912 }
8913 WalRecord::InsertNodeId { label, key: k, .. }
8914 if Self::aliases_match(&alias_intervals, k, commit) =>
8915 {
8916 let label_str = match self.syms.resolve(*label) {
8917 Some(s) => s.to_string(),
8918 None => continue,
8919 };
8920 Some(HistoryChange::NodeInserted { label: label_str })
8921 }
8922 WalRecord::SetProp {
8923 key: k,
8924 field,
8925 value,
8926 } if Self::aliases_match(&alias_intervals, k, commit) => {
8927 Some(HistoryChange::PropSet {
8928 field: field.clone(),
8929 value: value.clone(),
8930 })
8931 }
8932 WalRecord::SetPropId { id, field, value } => match self.ids.key_of(*id) {
8933 // key_of returns the current (post-rename) key; compare to queried key.
8934 Some(resolved) if resolved == key => {
8935 let field_str = match self.syms.resolve(*field) {
8936 Some(s) => s.to_string(),
8937 None => continue,
8938 };
8939 Some(HistoryChange::PropSet {
8940 field: field_str,
8941 value: value.clone(),
8942 })
8943 }
8944 _ => None,
8945 },
8946 WalRecord::RemoveProp { key: k, field }
8947 if Self::aliases_match(&alias_intervals, k, commit) =>
8948 {
8949 Some(HistoryChange::PropRemoved {
8950 field: field.clone(),
8951 })
8952 }
8953 WalRecord::InsertEdge {
8954 edge_type,
8955 src_key,
8956 dst_key,
8957 } => {
8958 if Self::aliases_match(&alias_intervals, src_key, commit) {
8959 Some(HistoryChange::EdgeAdded {
8960 edge_type: edge_type.clone(),
8961 other: dst_key.clone(),
8962 outgoing: true,
8963 })
8964 } else if Self::aliases_match(&alias_intervals, dst_key, commit) {
8965 Some(HistoryChange::EdgeAdded {
8966 edge_type: edge_type.clone(),
8967 other: src_key.clone(),
8968 outgoing: false,
8969 })
8970 } else {
8971 None
8972 }
8973 }
8974 WalRecord::InsertEdgeId { etype, src, dst } => {
8975 let etype_str = match self.syms.resolve(*etype) {
8976 Some(s) => s.to_string(),
8977 None => continue,
8978 };
8979 let src_key = self.ids.key_of(*src);
8980 let dst_key = self.ids.key_of(*dst);
8981 if src_key == Some(key) {
8982 let other = match dst_key {
8983 Some(s) => s.to_string(),
8984 None => continue,
8985 };
8986 Some(HistoryChange::EdgeAdded {
8987 edge_type: etype_str,
8988 other,
8989 outgoing: true,
8990 })
8991 } else if dst_key == Some(key) {
8992 let other = match src_key {
8993 Some(s) => s.to_string(),
8994 None => continue,
8995 };
8996 Some(HistoryChange::EdgeAdded {
8997 edge_type: etype_str,
8998 other,
8999 outgoing: false,
9000 })
9001 } else {
9002 None
9003 }
9004 }
9005 WalRecord::DeleteEdge {
9006 edge_type,
9007 src_key,
9008 dst_key,
9009 } => {
9010 if Self::aliases_match(&alias_intervals, src_key, commit) {
9011 Some(HistoryChange::EdgeRemoved {
9012 edge_type: edge_type.clone(),
9013 other: dst_key.clone(),
9014 outgoing: true,
9015 })
9016 } else if Self::aliases_match(&alias_intervals, dst_key, commit) {
9017 Some(HistoryChange::EdgeRemoved {
9018 edge_type: edge_type.clone(),
9019 other: src_key.clone(),
9020 outgoing: false,
9021 })
9022 } else {
9023 None
9024 }
9025 }
9026 WalRecord::DeleteNode { key: k }
9027 if Self::aliases_match(&alias_intervals, k, commit) =>
9028 {
9029 Some(HistoryChange::NodeDeleted)
9030 }
9031 // Skip: rule/view/fulltext/intern metadata; Batch wrapper handled above.
9032 _ => None,
9033 };
9034
9035 if let Some(change) = change {
9036 out.push(HistoryEntry { commit, change });
9037 }
9038 }
9039 }
9040
9041 Ok(out)
9042 }
9043
9044 /// Return the per-edge change history between nodes `a` and `b` by scanning
9045 /// the on-disk WAL.
9046 ///
9047 /// ## Horizon
9048 ///
9049 /// History reaches back only to the last WAL-truncating snapshot, exactly
9050 /// like `node_history` and `open_at`. The returned [`HistoryResult`] carries
9051 /// `total_commits` (= number of WAL frames), which is the exclusive upper
9052 /// bound for valid commit indices.
9053 ///
9054 /// ## Derived edges
9055 ///
9056 /// Rule-derived edges appear via `DerivedEdgeAdded` / `DerivedEdgeRetracted`
9057 /// WAL markers written by `log_then_apply_with` after each rule-firing
9058 /// mutation. The `rule` field of those events carries the rule name.
9059 ///
9060 /// ## DeleteNode
9061 ///
9062 /// When a node is deleted, its manual incident edges are swept inline without
9063 /// individual `DeleteEdge` WAL records. `edge_history` detects `DeleteNode`
9064 /// events for either endpoint and synthesises `Retracted(rule:None)` events
9065 /// for each manual edge that was active at that point. Derived edges active at
9066 /// the time of deletion are handled by the `DerivedEdgeRetracted` marker that
9067 /// the engine appends immediately after the `DeleteNode` record; those events
9068 /// carry correct rule attribution and are emitted by the marker arm, not the
9069 /// synthetic sweep.
9070 ///
9071 /// ## Masks
9072 ///
9073 /// Like `node_history`, this method has no mask parameter and returns WAL
9074 /// history regardless of any role mask. For masked history semantics, apply
9075 /// the mask at the caller level.
9076 pub fn edge_history(
9077 &self,
9078 a: &str,
9079 b: &str,
9080 ) -> Result<crate::history::HistoryResult<crate::history::EdgeHistoryEvent>> {
9081 use crate::history::{EdgeEvent, EdgeHistoryEvent, HistoryResult};
9082 use core_storage::wal::WalRecord;
9083
9084 let (frames, _) = self.all_frames()?;
9085 let total_commits = self.wal_horizon_floor + frames.len() as u64;
9086
9087 // Resolve all historical names for a and b (handles RenameNode in the WAL).
9088 // Intervals are commit-bounded so recycled keys don't contaminate histories.
9089 let alias_a = self.build_key_alias_intervals(&frames, a);
9090 let alias_b = self.build_key_alias_intervals(&frames, b);
9091
9092 // Active edges between a and b tracked as (edge_type, src_key, dst_key, is_derived).
9093 // The is_derived flag is used by the DeleteNode sweep: manual edges are
9094 // swept with a synthetic Retracted(rule:None); derived edges are skipped
9095 // because the engine writes a DerivedEdgeRetracted marker immediately after
9096 // the DeleteNode record, which carries the correct rule attribution.
9097 let mut active: Vec<(String, String, String, bool)> = Vec::new();
9098 let mut out: Vec<EdgeHistoryEvent> = Vec::new();
9099
9100 for (local_i, frame) in frames.iter().enumerate() {
9101 let commit = self.wal_horizon_floor + local_i as u64;
9102 let records: &[WalRecord] = match frame {
9103 WalRecord::Batch(inner) => inner.as_slice(),
9104 single => std::slice::from_ref(single),
9105 };
9106
9107 for rec in records {
9108 match rec {
9109 WalRecord::InsertEdge {
9110 edge_type,
9111 src_key,
9112 dst_key,
9113 } => {
9114 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
9115 && Self::aliases_match(&alias_b, dst_key, commit);
9116 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
9117 && Self::aliases_match(&alias_a, dst_key, commit);
9118 if is_ab || is_ba {
9119 active.push((
9120 edge_type.clone(),
9121 src_key.clone(),
9122 dst_key.clone(),
9123 false,
9124 ));
9125 out.push(EdgeHistoryEvent {
9126 edge_type: edge_type.clone(),
9127 commit,
9128 event: EdgeEvent::Added,
9129 rule: None,
9130 });
9131 }
9132 }
9133 WalRecord::InsertEdgeId { etype, src, dst } => {
9134 let etype_str = match self.syms.resolve(*etype) {
9135 Some(s) => s.to_string(),
9136 None => continue,
9137 };
9138 // Use key_of_historical so tombstoned nodes (deleted
9139 // later in the WAL) still resolve during the scan.
9140 let src_key = self.ids.key_of_historical(*src);
9141 let dst_key = self.ids.key_of_historical(*dst);
9142 let is_ab = src_key == Some(a) && dst_key == Some(b);
9143 let is_ba = src_key == Some(b) && dst_key == Some(a);
9144 if is_ab || is_ba {
9145 let src_str = src_key.unwrap().to_string();
9146 let dst_str = dst_key.unwrap().to_string();
9147 active.push((etype_str.clone(), src_str, dst_str, false));
9148 out.push(EdgeHistoryEvent {
9149 edge_type: etype_str,
9150 commit,
9151 event: EdgeEvent::Added,
9152 rule: None,
9153 });
9154 }
9155 }
9156 WalRecord::DeleteEdge {
9157 edge_type,
9158 src_key,
9159 dst_key,
9160 } => {
9161 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
9162 && Self::aliases_match(&alias_b, dst_key, commit);
9163 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
9164 && Self::aliases_match(&alias_a, dst_key, commit);
9165 if is_ab || is_ba {
9166 // Remove the first matching active entry (flag ignored).
9167 if let Some(pos) = active.iter().position(|(et, s, d, _)| {
9168 et == edge_type && s == src_key && d == dst_key
9169 }) {
9170 active.remove(pos);
9171 }
9172 out.push(EdgeHistoryEvent {
9173 edge_type: edge_type.clone(),
9174 commit,
9175 event: EdgeEvent::Retracted,
9176 rule: None,
9177 });
9178 }
9179 }
9180 WalRecord::DeleteNode { key: k }
9181 if Self::aliases_match(&alias_a, k, commit)
9182 || Self::aliases_match(&alias_b, k, commit) =>
9183 {
9184 // Sweep: implicitly retract only MANUAL active edges.
9185 // Derived active edges are skipped here because the rule
9186 // engine appends a DerivedEdgeRetracted marker immediately
9187 // after this DeleteNode record; that marker produces the
9188 // single correctly-attributed Retracted event. Derived
9189 // entries are dropped from `active` (the marker arm's
9190 // idempotent retain finds nothing to remove).
9191 for (et, _, _, is_derived) in active.drain(..) {
9192 if !is_derived {
9193 out.push(EdgeHistoryEvent {
9194 edge_type: et,
9195 commit,
9196 event: EdgeEvent::Retracted,
9197 rule: None,
9198 });
9199 }
9200 // Derived: drop silently; marker carries the Retracted event.
9201 }
9202 }
9203 WalRecord::DerivedEdgeAdded {
9204 rule,
9205 edge_type: et,
9206 src_key,
9207 dst_key,
9208 } => {
9209 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
9210 && Self::aliases_match(&alias_b, dst_key, commit);
9211 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
9212 && Self::aliases_match(&alias_a, dst_key, commit);
9213 if is_ab || is_ba {
9214 active.push((et.clone(), src_key.clone(), dst_key.clone(), true));
9215 out.push(EdgeHistoryEvent {
9216 edge_type: et.clone(),
9217 commit,
9218 event: EdgeEvent::Added,
9219 rule: Some(rule.clone()),
9220 });
9221 }
9222 }
9223 WalRecord::DerivedEdgeRetracted {
9224 rule,
9225 edge_type: et,
9226 src_key,
9227 dst_key,
9228 } => {
9229 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
9230 && Self::aliases_match(&alias_b, dst_key, commit);
9231 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
9232 && Self::aliases_match(&alias_a, dst_key, commit);
9233 if is_ab || is_ba {
9234 // Push unconditionally: a derived edge whose Added marker
9235 // predates the history horizon has no `active` entry, but
9236 // the retraction is still a real in-window event.
9237 // Remove from active idempotently if present.
9238 active.retain(|(aet, s, d, _)| {
9239 !(aet == et && s == src_key && d == dst_key)
9240 });
9241 out.push(EdgeHistoryEvent {
9242 edge_type: et.clone(),
9243 commit,
9244 event: EdgeEvent::Retracted,
9245 rule: Some(rule.clone()),
9246 });
9247 }
9248 }
9249 // All other records (InsertNode, SetProp, CreateRule, etc.)
9250 // do not affect edges between a and b.
9251 _ => {}
9252 }
9253 }
9254 }
9255
9256 Ok(HistoryResult {
9257 items: out,
9258 total_commits,
9259 })
9260 }
9261
9262 /// Return `true` iff an edge of `edge_type` existed between `a` and `b`
9263 /// (in either direction) at the WAL commit `at_commit`.
9264 ///
9265 /// ## Horizon
9266 ///
9267 /// Valid commit indices are `0..total_commits` where `total_commits` is the
9268 /// number of WAL frames. An `at_commit >= total_commits` is outside the
9269 /// visible horizon and returns [`GraphError::CommitOutOfRange`].
9270 ///
9271 /// ## Derived edges
9272 ///
9273 /// Rule-derived edges are tracked via `DerivedEdgeAdded` / `DerivedEdgeRetracted`
9274 /// WAL markers appended at firing time (Task 1). `was_linked` reads these markers
9275 /// and therefore includes derived edges in its point-in-time evaluation,
9276 /// matching `edge_history`'s fidelity.
9277 pub fn was_linked(&self, a: &str, b: &str, edge_type: &str, at_commit: u64) -> Result<bool> {
9278 use core_storage::wal::WalRecord;
9279
9280 let (frames, _) = self.all_frames()?;
9281 let total_commits = self.wal_horizon_floor + frames.len() as u64;
9282
9283 // Horizon floor: commits in pruned archives are unreachable.
9284 if at_commit < self.wal_horizon_floor {
9285 return Err(GraphError::CommitOutOfRange {
9286 commit: at_commit,
9287 total: total_commits,
9288 });
9289 }
9290 if at_commit >= total_commits {
9291 return Err(GraphError::CommitOutOfRange {
9292 commit: at_commit,
9293 total: total_commits,
9294 });
9295 }
9296
9297 // Resolve all historical names for a and b (handles RenameNode in the WAL).
9298 // Intervals are commit-bounded so recycled keys don't contaminate point-in-time reads.
9299 let alias_a = self.build_key_alias_intervals(&frames, a);
9300 let alias_b = self.build_key_alias_intervals(&frames, b);
9301
9302 // Local index into surviving frames (0 = first frame of oldest archive).
9303 let local_commit = at_commit - self.wal_horizon_floor;
9304
9305 // Replay local frames 0..=local_commit, tracking active edges.
9306 let mut active: BTreeSet<(String, String, String)> = BTreeSet::new();
9307
9308 for (local_i, frame) in frames.iter().enumerate().take((local_commit + 1) as usize) {
9309 let commit = self.wal_horizon_floor + local_i as u64;
9310 let records: &[WalRecord] = match frame {
9311 WalRecord::Batch(inner) => inner.as_slice(),
9312 single => std::slice::from_ref(single),
9313 };
9314
9315 for rec in records {
9316 match rec {
9317 WalRecord::InsertEdge {
9318 edge_type: et,
9319 src_key,
9320 dst_key,
9321 } => {
9322 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
9323 && Self::aliases_match(&alias_b, dst_key, commit);
9324 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
9325 && Self::aliases_match(&alias_a, dst_key, commit);
9326 if is_ab || is_ba {
9327 active.insert((et.clone(), src_key.clone(), dst_key.clone()));
9328 }
9329 }
9330 WalRecord::InsertEdgeId { etype, src, dst } => {
9331 let etype_str = match self.syms.resolve(*etype) {
9332 Some(s) => s.to_string(),
9333 None => continue,
9334 };
9335 // Use key_of_historical so tombstoned nodes resolve.
9336 let src_key = self.ids.key_of_historical(*src);
9337 let dst_key = self.ids.key_of_historical(*dst);
9338 let is_ab = src_key == Some(a) && dst_key == Some(b);
9339 let is_ba = src_key == Some(b) && dst_key == Some(a);
9340 if is_ab || is_ba {
9341 active.insert((
9342 etype_str,
9343 src_key.unwrap().to_string(),
9344 dst_key.unwrap().to_string(),
9345 ));
9346 }
9347 }
9348 WalRecord::DeleteEdge {
9349 edge_type: et,
9350 src_key,
9351 dst_key,
9352 } => {
9353 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
9354 && Self::aliases_match(&alias_b, dst_key, commit);
9355 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
9356 && Self::aliases_match(&alias_a, dst_key, commit);
9357 if is_ab || is_ba {
9358 active.remove(&(et.clone(), src_key.clone(), dst_key.clone()));
9359 }
9360 }
9361 WalRecord::DeleteNode { key: k }
9362 if Self::aliases_match(&alias_a, k, commit)
9363 || Self::aliases_match(&alias_b, k, commit) =>
9364 {
9365 // All edges touching the deleted node are gone.
9366 active.retain(|(_, s, d)| s != k && d != k);
9367 }
9368 WalRecord::DerivedEdgeAdded {
9369 edge_type: et,
9370 src_key,
9371 dst_key,
9372 ..
9373 } => {
9374 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
9375 && Self::aliases_match(&alias_b, dst_key, commit);
9376 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
9377 && Self::aliases_match(&alias_a, dst_key, commit);
9378 if is_ab || is_ba {
9379 active.insert((et.clone(), src_key.clone(), dst_key.clone()));
9380 }
9381 }
9382 WalRecord::DerivedEdgeRetracted {
9383 edge_type: et,
9384 src_key,
9385 dst_key,
9386 ..
9387 } => {
9388 let is_ab = Self::aliases_match(&alias_a, src_key, commit)
9389 && Self::aliases_match(&alias_b, dst_key, commit);
9390 let is_ba = Self::aliases_match(&alias_b, src_key, commit)
9391 && Self::aliases_match(&alias_a, dst_key, commit);
9392 if is_ab || is_ba {
9393 active.remove(&(et.clone(), src_key.clone(), dst_key.clone()));
9394 }
9395 }
9396 _ => {}
9397 }
9398 }
9399 }
9400
9401 Ok(active.iter().any(|(et, _, _)| et == edge_type))
9402 }
9403
9404 pub fn edge_count(&self) -> u64 {
9405 self.topo_view().edge_count()
9406 }
9407
9408 /// Live/tombstone/edge counts plus per-rule provenance size, trip latch,
9409 /// and fire counter (includes rebuild evaluations). Rules are sorted by name.
9410 pub fn stats(&self) -> Stats {
9411 self.ensure_v8_base_sections_loaded();
9412 let rules: Vec<RuleStats> = self
9413 .engine
9414 .rules()
9415 .map(|r| RuleStats {
9416 name: r.name.clone(),
9417 edges: self
9418 .engine
9419 .provenance()
9420 .get(&r.name)
9421 .map(|s| s.len() as u64)
9422 .unwrap_or(0),
9423 tripped: self.engine.is_tripped(&r.name),
9424 fires: self.engine.fire_count(&r.name),
9425 approximate: r.approximate,
9426 })
9427 .collect();
9428 Stats {
9429 nodes_live: self.ids.live_len(),
9430 nodes_tombstoned: self.ids.len() - self.ids.live_len(),
9431 edges: self.topo_view().edge_count(),
9432 rules,
9433 chain_truncations: self.engine.chain_truncations(),
9434 }
9435 }
9436
9437 /// On-disk size of the WAL file in bytes.
9438 ///
9439 /// Reads file metadata without loading WAL contents. Returns `Err` for
9440 /// in-memory (`SimFs`) databases where no WAL file exists on disk.
9441 pub fn wal_size_bytes(&self) -> std::io::Result<u64> {
9442 let path = self.fs.wal_path().ok_or_else(|| {
9443 std::io::Error::new(
9444 std::io::ErrorKind::Unsupported,
9445 "wal_path not available for this Fs implementation",
9446 )
9447 })?;
9448 Ok(std::fs::metadata(path)?.len())
9449 }
9450
9451 /// Set the slow-query threshold. Queries whose execution time equals or
9452 /// exceeds `ms` milliseconds are logged. Pass `0` to disable.
9453 ///
9454 /// Use this setter in tests — the environment variable
9455 /// `MUSHROOMDB_SLOW_QUERY_MS` is process-global and races parallel test
9456 /// threads.
9457 pub fn set_slow_query_threshold_ms(&mut self, ms: u64) {
9458 self.slow_query_threshold_ms = ms;
9459 }
9460
9461 /// Snapshot of the slow-query ring buffer and lifetime counter.
9462 pub fn slow_query_snapshot(&self) -> SlowQuerySnapshot {
9463 let log = self.slow_queries.lock().unwrap_or_else(|e| e.into_inner());
9464 SlowQuerySnapshot {
9465 threshold_ms: self.slow_query_threshold_ms,
9466 count: log.total,
9467 last: log.entries.iter().cloned().collect(),
9468 }
9469 }
9470
9471 /// Instant the database was opened. Used by consumers (e.g. `/metrics`)
9472 /// to compute uptime.
9473 pub fn started_at(&self) -> std::time::Instant {
9474 self.started_at
9475 }
9476
9477 /// On-disk snapshot format version this binary writes and reads.
9478 pub fn format_version() -> u16 {
9479 core_storage::snapshot::VERSION
9480 }
9481
9482 /// Test-support: total bytes appended (SimFs only usage).
9483 pub fn fs_total_appended(&self) -> usize
9484 where
9485 F: FsIntrospect,
9486 {
9487 self.fs.total_appended()
9488 }
9489
9490 /// Test-support: successful `Fs::sync` calls (SimFs / counting fs).
9491 pub fn fs_sync_count(&self) -> usize
9492 where
9493 F: FsIntrospect,
9494 {
9495 self.fs.sync_count()
9496 }
9497
9498 /// Consume the db, returning its fs (for crash simulation).
9499 pub fn into_fs(self) -> F {
9500 self.fs
9501 }
9502
9503 pub fn snapshot(&mut self) -> Result<()> {
9504 self.snapshot_with(SnapshotOptions::default())
9505 }
9506
9507 /// Snapshot with explicit options.
9508 ///
9509 /// # `keep_wal`
9510 ///
9511 /// When `keep_wal` is `false` (the default, same as [`snapshot`]):
9512 /// - The WAL is replaced with a minimal baseline containing one
9513 /// `EnableFulltext` record per active declaration. All pre-snapshot
9514 /// history is discarded; `open_at` can only reach post-snapshot commits.
9515 ///
9516 /// When `keep_wal` is `true`:
9517 /// - The WAL is left intact. All pre-snapshot commits remain reachable
9518 /// via `open_at`. The existing WAL already contains the original
9519 /// `EnableFulltext` records, so no baseline re-write is needed; the
9520 /// recovery guards in `apply()` silently skip any duplicate records on
9521 /// replay.
9522 /// - Crash window: a crash after the snapshot write but before the next
9523 /// WAL write leaves the full pre-snapshot WAL intact. On reopen the
9524 /// snapshot is loaded and the WAL replayed idempotently over it — safe
9525 /// because every `apply()` arm is idempotent when replayed over an
9526 /// already-current snapshot.
9527 pub fn snapshot_with(&mut self, opts: SnapshotOptions) -> Result<()> {
9528 if self.read_only {
9529 return Err(GraphError::ReadOnly);
9530 }
9531 // A snapshot rewrites `wal.bin` through a tmp+rename, so a peer that is
9532 // appending ends up holding a descriptor on an unlinked inode and loses
9533 // commits it believes durable. Snapshotting therefore requires the
9534 // cross-process write lock, exactly as appending does. Unlike the WAL
9535 // append path this does not go through `log_then_apply_with`, so both
9536 // guards are repeated here.
9537 if self.degraded {
9538 return Err(GraphError::Io(std::io::Error::other(
9539 "database degraded after group-commit fsync failure; reopen required",
9540 )));
9541 }
9542 if self.lock_denied {
9543 return Err(GraphError::Busy { holder: None });
9544 }
9545 // Capture whether snapshot.bin already existed BEFORE this snapshot write.
9546 // Used by the archive path's conservative genesis-chain check: if a prior
9547 // snapshot exists but wal.truncated does not, we cannot distinguish a
9548 // legacy store (may have been truncated in an older code version) from a
9549 // new store that only used keep_wal=true. Conservative: refuse genesis in
9550 // both cases. Must be sampled here, before the snapshot write below.
9551 let had_prior_snapshot = self.fs.snapshot_path().map(|p| p.exists()).unwrap_or(false);
9552 self.ensure_v8_base_sections_loaded();
9553 // Ensure provenance is decoded before to_persist() clones it.
9554 self.engine.ensure_provenance_loaded_mut();
9555 let (rule_defs_typed, provenance, rule_tripped, rule_fires) = self.engine.to_persist();
9556 let rule_defs = rule_defs_typed
9557 .iter()
9558 .map(|r| bincode::serialize(r).expect("RuleDef serialize cannot fail"))
9559 .collect();
9560 // Collect HNSW state and IVF state. When indexes are not yet
9561 // populated (clean open, no mutation since open), pass the retained
9562 // raw bytes through directly so that migrate/snapshot does not
9563 // silently discard fitted approximate-rule indexes.
9564 let hnsw_state = self.engine.export_hnsw_state_passthrough();
9565 let ivf_bytes = if !self.engine.indexes_populated() {
9566 // Pass retained IVF bytes through unchanged (no re-encode).
9567 self.engine.retained_ivf_bytes_clone().unwrap_or_default()
9568 } else {
9569 // Indexes live: encode from current state.
9570 let raw_ivf = self.engine.export_ivf_state();
9571 let ivf_state_map: BTreeMap<String, core_storage::snapshot::PerRuleIvfState> = raw_ivf
9572 .into_iter()
9573 .map(|(name, ((sc, sa, sd), (dc, da, dd)))| {
9574 (
9575 name,
9576 core_storage::snapshot::PerRuleIvfState {
9577 src: core_storage::snapshot::SideIvfState {
9578 centroids: sc,
9579 clusters: sa,
9580 drift: sd,
9581 },
9582 dst: core_storage::snapshot::SideIvfState {
9583 centroids: dc,
9584 clusters: da,
9585 drift: dd,
9586 },
9587 },
9588 )
9589 })
9590 .collect();
9591 if ivf_state_map.is_empty() {
9592 Vec::new()
9593 } else {
9594 bincode::serialize(&ivf_state_map).expect("IVF state serialize cannot fail")
9595 }
9596 };
9597 let view_defs: Vec<Vec<u8>> = self
9598 .view_store
9599 .views()
9600 .map(|v| bincode::serialize(v).expect("ViewDef serialize cannot fail"))
9601 .collect();
9602 if self.base.is_some() {
9603 // V8 merge-snapshot path: encode base+overlay into a new V8 snapshot,
9604 // write it atomically, remap it as the new base, then clear the overlay.
9605 let meta = V8Meta {
9606 labels: self.labels.clone(),
9607 edge_props: self.edge_props.clone(),
9608 rule_defs,
9609 provenance,
9610 rule_tripped,
9611 rule_fires,
9612 ivf_bytes,
9613 view_defs,
9614 wal_truncated: !opts.keep_wal,
9615 hnsw: hnsw_state,
9616 last_change: self.last_change.clone(),
9617 };
9618 let mut buf: Vec<u8> = Vec::new();
9619 {
9620 // Clone the Arc so the old base stays alive while we encode.
9621 // The borrow of archived_csr (into old_base's mmap) is released
9622 // at the end of this block, before we replace self.base.
9623 let old_base = self.base.clone().expect("is_some checked above");
9624 let archived_csr = old_base.topology().map_err(|e| GraphError::Corrupt {
9625 detail: format!("v8 snapshot: topology section: {e:?}"),
9626 })?;
9627 let archived_cols = old_base.columns().map_err(|e| GraphError::Corrupt {
9628 detail: format!("v8 snapshot: columns section: {e:?}"),
9629 })?;
9630 let archived_edge_props =
9631 old_base
9632 .edge_props_section()
9633 .map_err(|e| GraphError::Corrupt {
9634 detail: format!("v8 snapshot: edge_props section: {e:?}"),
9635 })?;
9636 let edge_props_raw =
9637 old_base
9638 .edge_props_raw_bytes()
9639 .map_err(|e| GraphError::Corrupt {
9640 detail: format!("v8 snapshot: edge_props raw bytes: {e:?}"),
9641 })?;
9642 let prov_raw =
9643 old_base
9644 .provenance_raw_bytes()
9645 .map_err(|e| GraphError::Corrupt {
9646 detail: format!("v8 snapshot: provenance raw bytes: {e:?}"),
9647 })?;
9648 encode_v8(
9649 Some(archived_csr),
9650 Some(archived_cols),
9651 Some((archived_edge_props, edge_props_raw)),
9652 Some(prov_raw),
9653 &self.topo,
9654 &self.props,
9655 &self.ids,
9656 &self.syms,
9657 &meta,
9658 &mut buf,
9659 )?;
9660 }
9661 self.fs.write_atomic(FileId::Snapshot, &buf)?;
9662 // Remap the freshly-written snapshot as the new base.
9663 // C2: use file mmap on RealFs; fall back to from_bytes on SimFs.
9664 let new_base = if let Some(snap_path) = self.fs.snapshot_path() {
9665 core_storage::v8::MappedBase::map(&snap_path)
9666 } else {
9667 core_storage::v8::MappedBase::from_bytes(buf)
9668 }
9669 .map_err(|e| GraphError::Corrupt {
9670 detail: format!("v8 snapshot: remap new base: {e:?}"),
9671 })?;
9672 self.base = Some(Arc::new(new_base));
9673 // Clear the overlay and prop tombstones — all data is now in the new base.
9674 self.topo = Topology::new();
9675 self.props = core_storage::columns::ColumnStore::new();
9676 } else {
9677 // Legacy path (V5–V7 stores without a V8 base).
9678 //
9679 // Memory-diet path: build V8Meta directly from &self — no SnapshotState
9680 // clone and no encode_v8_from_state intermediate clones. The big
9681 // structures (self.topo, self.props) are borrowed, not cloned.
9682 // self.edge_props is moved (not cloned) because we immediately clear it
9683 // when we remap the new V8 snapshot as self.base (see below).
9684 //
9685 // Eliminates from peak RSS vs. the old SnapshotState path:
9686 // • self.topo.clone() (~topology HashMap footprint)
9687 // • self.props.clone() (~column-store footprint)
9688 // • encode_v8_from_state V8Meta secondary clones (labels, edge_props, …)
9689 let meta = V8Meta {
9690 labels: self.labels.clone(),
9691 wal_truncated: !opts.keep_wal,
9692 // Move edge_props out so the large overlay is freed when meta
9693 // drops at end of this block (self.edge_props is now empty; reads
9694 // after base assignment go through the mmap'd base section).
9695 edge_props: std::mem::take(&mut self.edge_props),
9696 rule_defs,
9697 provenance,
9698 rule_tripped,
9699 rule_fires,
9700 ivf_bytes,
9701 view_defs,
9702 hnsw: hnsw_state,
9703 last_change: self.last_change.clone(),
9704 };
9705 let mut buf = Vec::new();
9706 encode_v8(
9707 None,
9708 None,
9709 None,
9710 None,
9711 &self.topo,
9712 &self.props,
9713 &self.ids,
9714 &self.syms,
9715 &meta,
9716 &mut buf,
9717 )?;
9718 // meta (and the moved edge_props inside it) is no longer needed;
9719 // drop it before the write to keep the peak window narrow.
9720 drop(meta);
9721 self.fs.write_atomic(FileId::Snapshot, &buf)?;
9722 // Remap the freshly-written V8 snapshot as self.base.
9723 // On RealFs: drop the encode buffer before mmap to recover ~1.9 GiB.
9724 // On SimFs (tests): pass buf to from_bytes.
9725 let new_base = if let Some(snap_path) = self.fs.snapshot_path() {
9726 drop(buf);
9727 core_storage::v8::MappedBase::map(&snap_path)
9728 } else {
9729 core_storage::v8::MappedBase::from_bytes(buf)
9730 }
9731 .map_err(|e| GraphError::Corrupt {
9732 detail: format!("v8 snapshot: remap new base (legacy path): {e:?}"),
9733 })?;
9734 self.base = Some(Arc::new(new_base));
9735 // Free the large heap-allocated decoded state — all data is now in the
9736 // mmap'd base. Mirrors the V8 merge-snapshot path (see above).
9737 // self.edge_props was already moved into meta and is effectively empty.
9738 self.topo = Topology::new();
9739 self.props = core_storage::columns::ColumnStore::new();
9740 }
9741
9742 if opts.archive_wal {
9743 // History-preserving snapshot (Task 4):
9744 // 1. Snapshot already written above (write_atomic → fsynced).
9745 // 2. Rename WAL → wal.<commit_seq>.archive (atomic, same fs).
9746 // Crash window B: crash here leaves archive present, WAL
9747 // absent. Reopen: snapshot loaded (full state), no WAL
9748 // replay. Archive is NOT replayed into live state — it is
9749 // pre-snapshot by construction. Safe.
9750 // 3. Optionally write genesis marker (first archive only, no
9751 // prior WAL truncation).
9752 // 4. Prune old archives (retention), update horizon floor.
9753 // Pruning invalidates the genesis chain; delete marker.
9754 // 5. Write new minimal baseline WAL (write_atomic).
9755 // Crash window C: crash here leaves new archive plus no live
9756 // WAL. Same as window B — handled above.
9757 //
9758 // Sample existing archives BEFORE the rename so we can detect
9759 // whether this is the first archive.
9760 let existing_archives = self.fs.list_archives()?;
9761 let is_first_archive = existing_archives.is_empty();
9762
9763 // Compute a globally-monotonic archive name: the name equals the
9764 // cumulative end-frame index of the archive in global commit space.
9765 //
9766 // Using `commit_seq` directly is UNSOUND across sessions: on reopen
9767 // commit_seq is seeded from max(last_change), which underestimates
9768 // the WAL depth when trailing commits (e.g. insert_edge) do not
9769 // update last_change. A session-2 archive could then receive a name
9770 // ≤ the session-1 archive, causing incorrect sort order or collision.
9771 //
9772 // Instead: read and decode the live WAL here (before the rename) to
9773 // get its exact frame count, then add it to the last known global
9774 // end-frame index (the name of the most recent existing archive, or
9775 // wal_horizon_floor if no archives exist). This is O(WAL size) but
9776 // snapshot is already serialising the full graph state, so the cost
9777 // is dominated.
9778 let live_wal_bytes_for_name = self.fs.read(FileId::Wal)?;
9779 let (live_frames_for_name, _) = decode_all(&live_wal_bytes_for_name);
9780 let archive_n = existing_archives
9781 .last()
9782 .copied()
9783 .unwrap_or(self.wal_horizon_floor)
9784 + live_frames_for_name.len() as u64;
9785 self.fs.archive_wal(archive_n)?;
9786
9787 // Genesis marker: written once when the first archive is taken
9788 // from a store that has never undergone a WAL-truncating snapshot.
9789 // When present, `open_at` may replay archive-resident commits from
9790 // empty state (the archive chain covers from global index 0).
9791 //
9792 // Two conditions must ALL hold:
9793 // 1. This is the first archive (existing_archives was empty).
9794 // 2. No snapshot.bin existed before this operation (had_prior_snapshot=false).
9795 // A WAL-truncating snapshot (keep_wal=false) always writes snapshot.bin
9796 // before truncating the WAL, so if any prior truncating snapshot was taken
9797 // — even in a previous session — snapshot.bin is present and this condition
9798 // is false. This subsumes the cross-session truncation case without
9799 // requiring a separate wal.truncated sidecar file.
9800 // For legacy stores (snapshot.bin written by an older code version that
9801 // may have truncated the WAL), the same conservative refusal applies:
9802 // we cannot prove the chain is complete, so we refuse genesis (cost =
9803 // no as-of-through-archives; never silent wrong data).
9804 // On SimFs (snapshot_path() == None) had_prior_snapshot is always false,
9805 // so SimFs always passes this check.
9806 if is_first_archive && !had_prior_snapshot {
9807 self.fs.write_genesis_marker()?;
9808 self.archive_genesis_chain = true;
9809 }
9810
9811 // Retention pruning: keep newest `keep` archives; delete oldest.
9812 // Pruning is the ONLY deletion site for archives.
9813 //
9814 // Crash-safety ordering (C1 fix):
9815 // 1. Count frames in surplus archives (reads only — no mutation).
9816 // 2. Advance and PERSIST the horizon floor FIRST via write-then-
9817 // rename (atomic). A crash after this point leaves orphaned
9818 // archives on disk, but the floor is correct. The opening
9819 // cleanup sweep (`cleanup_orphaned_archives`) removes them on
9820 // the next open, so the store is always safe to reopen.
9821 // 3. Delete the genesis marker (floor > 0 already blocks open_at
9822 // via the conjunctive gate; marker cleanup is belt-and-suspenders).
9823 // 4. Delete surplus archives. A crash between any two deletes
9824 // leaves the floor committed and orphaned archives cleaned at
9825 // next open — never a stale floor with a missing archive prefix.
9826 if let Some(keep) = self.wal_archive_retention {
9827 if keep > 0 {
9828 let archives = self.fs.list_archives()?;
9829 // archives is sorted ascending (oldest first)
9830 if archives.len() as u32 > keep {
9831 let surplus = archives.len() - keep as usize;
9832 // Step 1: count pruned frames (reads, no mutation).
9833 let mut pruned_frames = 0u64;
9834 for &n in &archives[..surplus] {
9835 let bytes = self.fs.read_archive(n)?;
9836 let (frames, _) = decode_all(&bytes);
9837 pruned_frames += frames.len() as u64;
9838 }
9839 // Step 2: advance and persist floor FIRST.
9840 self.wal_horizon_floor += pruned_frames;
9841 self.fs.write_horizon_floor(self.wal_horizon_floor)?;
9842 // Step 3: delete genesis marker (floor > 0 already
9843 // blocks open_at; this is belt-and-suspenders cleanup).
9844 if pruned_frames > 0 && self.archive_genesis_chain {
9845 self.fs.delete_genesis_marker()?;
9846 self.archive_genesis_chain = false;
9847 }
9848 // Step 4: delete surplus archives. Crash here →
9849 // orphaned archives; cleaned at next open.
9850 for &n in &archives[..surplus] {
9851 self.fs.delete_archive(n)?;
9852 }
9853 }
9854 }
9855 }
9856
9857 // Write new minimal baseline WAL (mirrors the keep_wal=false path).
9858 let mut baseline_wal: Vec<u8> = Vec::new();
9859 for (label, field) in self.fulltext.enabled_pairs() {
9860 let rec = WalRecord::EnableFulltext {
9861 label: label.clone(),
9862 field: field.clone(),
9863 };
9864 baseline_wal.extend_from_slice(&encode_record(&rec));
9865 }
9866 for (label, field) in self.prop_index.enabled_pairs() {
9867 let rec = WalRecord::EnableIndex {
9868 label: label.clone(),
9869 field: field.clone(),
9870 };
9871 baseline_wal.extend_from_slice(&encode_record(&rec));
9872 }
9873 self.fs.write_atomic(FileId::Wal, &baseline_wal)?;
9874 } else if opts.keep_wal {
9875 // keep_wal=true: WAL is left untouched. The existing WAL already
9876 // contains the EnableFulltext records from the original enable calls;
9877 // replay is idempotent (guards in apply() skip already-live entries).
9878 // No baseline re-write is needed or safe here — the full WAL history
9879 // must remain intact for open_at to reach pre-snapshot commits.
9880 } else {
9881 // keep_wal=false (default): truncate by replacing the WAL with a
9882 // minimal baseline of one EnableFulltext record per active pair.
9883 //
9884 // Crash-ordering: write_atomic is atomic.
9885 // • Crash before snapshot write → WAL unchanged. Safe.
9886 // • Crash after snapshot write but before this WAL write → full
9887 // pre-snapshot WAL still present; open_with replays idempotently.
9888 // • Crash after both writes → normal post-snapshot state.
9889 //
9890 // Genesis chain: a WAL-truncating snapshot breaks the archive chain
9891 // for any archives taken AFTER this point (their WAL slices would
9892 // not start at genesis). Delete any existing genesis marker so that
9893 // open_at refuses archive-resident commits. Future sessions are
9894 // covered by had_prior_snapshot: snapshot.bin written here persists
9895 // across sessions and prevents a later archiving session from
9896 // incorrectly claiming a complete genesis chain.
9897 if self.archive_genesis_chain {
9898 self.fs.delete_genesis_marker()?;
9899 self.archive_genesis_chain = false;
9900 }
9901 let mut baseline_wal: Vec<u8> = Vec::new();
9902 for (label, field) in self.fulltext.enabled_pairs() {
9903 let rec = WalRecord::EnableFulltext {
9904 label: label.clone(),
9905 field: field.clone(),
9906 };
9907 baseline_wal.extend_from_slice(&encode_record(&rec));
9908 }
9909 for (label, field) in self.prop_index.enabled_pairs() {
9910 let rec = WalRecord::EnableIndex {
9911 label: label.clone(),
9912 field: field.clone(),
9913 };
9914 baseline_wal.extend_from_slice(&encode_record(&rec));
9915 }
9916 self.fs.write_atomic(FileId::Wal, &baseline_wal)?;
9917 }
9918 // After snapshot the overlay may have changed (V8 merge path clears
9919 // self.topo and self.props). Refresh the MVCC fold so future readers
9920 // see the post-snapshot state rather than stale overlay data.
9921 self.fold_now();
9922 // We wrote the snapshot and (unless keep_wal) replaced the WAL, so both
9923 // markers this handle uses to detect other processes' work must be
9924 // re-taken from disk. Skipping this would make our own snapshot look
9925 // like a peer's on the next staleness check and force a needless
9926 // reload.
9927 self.wal_consumed = self.fs.wal_len().map_err(GraphError::Io)?;
9928 self.snapshot_ident = self.fs.snapshot_ident().map_err(GraphError::Io)?;
9929 Ok(())
9930 }
9931}
9932
9933/// Queued mutation for a [`BatchBuilder`] or [`GraphDb::commit_group`].
9934///
9935/// The `submit_batch` / `commit_group` APIs accept `Vec<BatchOp>` so that
9936/// callers can build a set of mutations without holding `&mut GraphDb` and
9937/// hand them off to the group-committing writer for durable, batched I/O.
9938pub enum BatchOp {
9939 InsertNode {
9940 label: String,
9941 key: String,
9942 props: Vec<(String, Value)>,
9943 },
9944 InsertEdge {
9945 edge_type: String,
9946 src_key: String,
9947 dst_key: String,
9948 },
9949 SetProp {
9950 key: String,
9951 field: String,
9952 value: Value,
9953 },
9954 RemoveProp {
9955 key: String,
9956 field: String,
9957 },
9958 DeleteEdge {
9959 edge_type: String,
9960 src_key: String,
9961 dst_key: String,
9962 },
9963 DeleteNode {
9964 key: String,
9965 },
9966 CreateRule(RuleDef),
9967 DeleteRule {
9968 name: String,
9969 },
9970 /// Rename a node's key. Validated: old must exist, new must not.
9971 RenameNode {
9972 old_key: String,
9973 new_key: String,
9974 },
9975 /// Insert an edge, auto-creating any missing endpoint as a plain node with
9976 /// `placeholder_label` and no props. Rules fire and last-change is updated
9977 /// for each created endpoint (normal InsertNode semantics in the batch frame).
9978 InsertEdgeUpsert {
9979 edge_type: String,
9980 src_key: String,
9981 dst_key: String,
9982 placeholder_label: String,
9983 },
9984}
9985
9986/// Three-way node visibility status used by `check_single_op_authz`.
9987enum NodeAuthzStatus {
9988 /// Node exists in the store and is in the role's read mask.
9989 Visible(String), // carries the node's label
9990 /// Node exists in the store but is NOT in the role's read mask.
9991 Hidden,
9992 /// Node does not exist in the store.
9993 Absent,
9994}
9995
9996/// Overlay of ops already accepted earlier in the same batch. Never written
9997/// back to the database — validation only.
9998#[derive(Default)]
9999struct Overlay {
10000 extra_keys: BTreeSet<String>,
10001 deleted_keys: BTreeSet<String>,
10002 extra_props: BTreeMap<(String, String), Value>,
10003 removed_props: BTreeSet<(String, String)>,
10004 extra_edges: BTreeSet<(String, String, String)>,
10005 deleted_edges: BTreeSet<(String, String, String)>,
10006 extra_rules: BTreeSet<String>,
10007 deleted_rules: BTreeSet<String>,
10008 /// `rule name → (via_edge, edge_type)` for every via-hop rule accepted
10009 /// earlier in this batch. Feeds the rule-chain cycle check, which otherwise
10010 /// sees only the rules already committed to the engine. Keyed by name so a
10011 /// later `DeleteRule` in the same batch drops the arc with the rule.
10012 extra_rule_arcs: BTreeMap<String, (String, String)>,
10013}
10014
10015/// Read-only view of live db state plus a batch overlay. Shared by single-op
10016/// public methods (empty overlay) and `commit_batch`.
10017struct MutPreview<'a, F: Fs> {
10018 db: &'a GraphDb<F>,
10019 overlay: Overlay,
10020}
10021
10022/// Shortest path from `start` to `target` following `arcs` (`from → to`), or
10023/// `None` if `target` is unreachable.
10024///
10025/// Used for rule-chain cycle detection, where an arc is "a rule hops over
10026/// `from` and writes `to`". Breadth-first over BTree-ordered adjacency, so the
10027/// reported path is stable for a given rule set, and iterative so a pathological
10028/// rule graph cannot overflow the stack.
10029fn find_cycle_through(arcs: &[(String, String)], start: &str, target: &str) -> Option<Vec<String>> {
10030 let mut adj: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
10031 for (from, to) in arcs {
10032 adj.entry(from.as_str()).or_default().insert(to.as_str());
10033 }
10034 let mut parent: BTreeMap<&str, &str> = BTreeMap::new();
10035 let mut visited: BTreeSet<&str> = BTreeSet::new();
10036 let mut queue: std::collections::VecDeque<&str> = std::collections::VecDeque::new();
10037 visited.insert(start);
10038 queue.push_back(start);
10039 while let Some(node) = queue.pop_front() {
10040 if node == target {
10041 let mut path = vec![node.to_string()];
10042 let mut cur = node;
10043 while let Some(&p) = parent.get(cur) {
10044 path.push(p.to_string());
10045 cur = p;
10046 }
10047 path.reverse();
10048 return Some(path);
10049 }
10050 for &next in adj.get(node).into_iter().flatten() {
10051 if visited.insert(next) {
10052 parent.insert(next, node);
10053 queue.push_back(next);
10054 }
10055 }
10056 }
10057 None
10058}
10059
10060impl<'a, F: Fs> MutPreview<'a, F> {
10061 fn new(db: &'a GraphDb<F>) -> Self {
10062 Self {
10063 db,
10064 overlay: Overlay::default(),
10065 }
10066 }
10067
10068 fn has_key(&self, key: &str) -> bool {
10069 if self.overlay.extra_keys.contains(key) {
10070 return true;
10071 }
10072 if self.overlay.deleted_keys.contains(key) {
10073 return false;
10074 }
10075 self.db.ids.get(key).is_some()
10076 }
10077
10078 fn has_prop(&self, key: &str, field: &str) -> bool {
10079 if !self.has_key(key) {
10080 return false;
10081 }
10082 let k = (key.to_string(), field.to_string());
10083 if self.overlay.removed_props.contains(&k) {
10084 return false;
10085 }
10086 if self.overlay.extra_props.contains_key(&k) {
10087 return true;
10088 }
10089 // Fresh identity (first insert in this batch, or delete+reinsert):
10090 // ignore props still sitting on the soon-to-be-tombstoned slot.
10091 if self.overlay.extra_keys.contains(key) {
10092 return false;
10093 }
10094 self.db.get_prop(key, field).is_some()
10095 }
10096
10097 fn has_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
10098 let k = (
10099 edge_type.to_string(),
10100 src_key.to_string(),
10101 dst_key.to_string(),
10102 );
10103 if self.overlay.deleted_edges.contains(&k) {
10104 return false;
10105 }
10106 if self.overlay.extra_edges.contains(&k) {
10107 return true;
10108 }
10109 // A key created in this batch (including reinsert) has no db edges.
10110 if self.overlay.extra_keys.contains(src_key) || self.overlay.extra_keys.contains(dst_key) {
10111 return false;
10112 }
10113 if self.overlay.deleted_keys.contains(src_key)
10114 || self.overlay.deleted_keys.contains(dst_key)
10115 {
10116 return false;
10117 }
10118 let Some(src) = self.db.ids.get(src_key) else {
10119 return false;
10120 };
10121 let Some(dst) = self.db.ids.get(dst_key) else {
10122 return false;
10123 };
10124 let Some(sym) = self.db.syms.get(edge_type) else {
10125 return false;
10126 };
10127 self.db
10128 .topo_view()
10129 .neighbors(sym, Direction::Out, src)
10130 .binary_search(&dst)
10131 .is_ok()
10132 }
10133
10134 fn has_rule(&self, name: &str) -> bool {
10135 if self.overlay.extra_rules.contains(name) {
10136 return true;
10137 }
10138 if self.overlay.deleted_rules.contains(name) {
10139 return false;
10140 }
10141 self.db.engine.rules().any(|r| r.name == name)
10142 }
10143
10144 fn is_rule_owned(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
10145 if self.overlay.extra_keys.contains(src_key) || self.overlay.extra_keys.contains(dst_key) {
10146 return false;
10147 }
10148 if self.overlay.deleted_keys.contains(src_key)
10149 || self.overlay.deleted_keys.contains(dst_key)
10150 {
10151 return false;
10152 }
10153 let Some(src) = self.db.ids.get(src_key) else {
10154 return false;
10155 };
10156 let Some(dst) = self.db.ids.get(dst_key) else {
10157 return false;
10158 };
10159 let Some(et) = self.db.syms.get(edge_type) else {
10160 return false;
10161 };
10162 // extra_rules is deliberately not consulted: a CreateRule earlier in
10163 // this batch has not fired, so it contributes no provenance. That is
10164 // the documented rule-window gap (see GraphDb::batch).
10165 if self.overlay.deleted_rules.is_empty() {
10166 return self.db.engine.is_owned(et, src, dst);
10167 }
10168 for (rule, triples) in self.db.engine.provenance() {
10169 if self.overlay.deleted_rules.contains(rule) {
10170 continue;
10171 }
10172 if triples.contains(&(et, src, dst)) {
10173 return true;
10174 }
10175 }
10176 false
10177 }
10178
10179 fn check_insert_node(&self, key: &str) -> Result<()> {
10180 if self.has_key(key) {
10181 Err(GraphError::DuplicateKey { key: key.into() })
10182 } else {
10183 Ok(())
10184 }
10185 }
10186
10187 fn check_live_key(&self, key: &str) -> Result<()> {
10188 if self.has_key(key) {
10189 Ok(())
10190 } else {
10191 Err(GraphError::KeyNotFound { key: key.into() })
10192 }
10193 }
10194
10195 fn prepare_insert_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
10196 for k in [src_key, dst_key] {
10197 if !self.has_key(k) {
10198 return Err(GraphError::KeyNotFound { key: k.into() });
10199 }
10200 }
10201 if self.is_rule_owned(edge_type, src_key, dst_key) {
10202 return Err(GraphError::RuleOwned {
10203 detail: format!("edge {edge_type} {src_key}→{dst_key} is rule-owned"),
10204 });
10205 }
10206 Ok(!self.has_edge(edge_type, src_key, dst_key))
10207 }
10208
10209 fn prepare_remove_prop(&self, key: &str, field: &str) -> Result<bool> {
10210 self.check_live_key(key)?;
10211 Ok(self.has_prop(key, field))
10212 }
10213
10214 fn prepare_delete_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
10215 for k in [src_key, dst_key] {
10216 if !self.has_key(k) {
10217 return Err(GraphError::KeyNotFound { key: k.into() });
10218 }
10219 }
10220 // Provenance-owned OR a live rule would derive this pair. User-first
10221 // edges that a later rule matches are not in `owned`, but deleting
10222 // them would leave a hole `rebuild_rule` immediately fills.
10223 if self.is_rule_owned(edge_type, src_key, dst_key) {
10224 return Err(GraphError::RuleOwned {
10225 detail: format!(
10226 "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
10227 delete or change the owning rule"
10228 ),
10229 });
10230 }
10231 if self.would_derive(edge_type, src_key, dst_key) {
10232 return Err(GraphError::RuleOwned {
10233 detail: format!(
10234 "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
10235 delete or change the owning rule, or a live rule would re-derive it"
10236 ),
10237 });
10238 }
10239 Ok(self.has_edge(edge_type, src_key, dst_key))
10240 }
10241
10242 /// True if any live rule (minus overlay-deleted names) would derive
10243 /// `(edge_type, src, dst)` from current overlay-visible props/labels.
10244 /// CreateRule names in `extra_rules` are ignored — same documented
10245 /// same-batch rule-window as [`Self::is_rule_owned`].
10246 fn would_derive(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
10247 if src_key == dst_key {
10248 return false;
10249 }
10250 let Some(src_label) = self.label_of(src_key) else {
10251 return false;
10252 };
10253 let Some(dst_label) = self.label_of(dst_key) else {
10254 return false;
10255 };
10256 for rule in self.db.engine.rules() {
10257 if self.overlay.deleted_rules.contains(&rule.name) {
10258 continue;
10259 }
10260 if rule.edge_type != edge_type {
10261 continue;
10262 }
10263 if rule.src_label != src_label || rule.dst_label != dst_label {
10264 continue;
10265 }
10266 let src_props = |f: &str| self.prop_value(src_key, f);
10267 let dst_props = |f: &str| self.prop_value(dst_key, f);
10268 let src_view = NodeView {
10269 key: src_key,
10270 props: &src_props,
10271 };
10272 let dst_view = NodeView {
10273 key: dst_key,
10274 props: &dst_props,
10275 };
10276 if evaluate(&rule.predicate, &src_view, &dst_view).is_some() {
10277 return true;
10278 }
10279 }
10280 false
10281 }
10282
10283 fn label_of(&self, key: &str) -> Option<String> {
10284 if self.overlay.deleted_keys.contains(key) {
10285 return None;
10286 }
10287 // Fresh identities created in this batch have no stored label in the
10288 // overlay; they cannot be provenance-owned yet either.
10289 let id = self.db.ids.get(key)?;
10290 let sym = self.db.labels.get(id as usize).copied()?;
10291 if sym == u32::MAX {
10292 return None;
10293 }
10294 self.db.syms.resolve(sym).map(str::to_string)
10295 }
10296
10297 fn prop_value(&self, key: &str, field: &str) -> Option<Value> {
10298 if !self.has_key(key) {
10299 return None;
10300 }
10301 let k = (key.to_string(), field.to_string());
10302 if self.overlay.removed_props.contains(&k) {
10303 return None;
10304 }
10305 if let Some(v) = self.overlay.extra_props.get(&k) {
10306 return Some(v.clone());
10307 }
10308 if self.overlay.extra_keys.contains(key) {
10309 return None;
10310 }
10311 self.db.get_prop(key, field)
10312 }
10313
10314 fn check_create_rule(&self, def: &RuleDef) -> Result<()> {
10315 def.validate()
10316 .map_err(|e| GraphError::RuleInvalid { detail: e })?;
10317 if self.has_rule(&def.name) {
10318 return Err(GraphError::RuleInvalid {
10319 detail: format!("rule {:?} already exists", def.name),
10320 });
10321 }
10322 // Rule-chain cycle rejection. Derived edges feed via-hop rules, so a
10323 // rule set forms a graph whose arcs are "hops over `via_edge`, writes
10324 // `edge_type`". A cycle in that graph is a rule set that would re-fire
10325 // itself forever; the engine's depth cap would silently truncate it
10326 // instead, leaving an arbitrary partial result. Reject it here, the one
10327 // place that sees the whole rule set.
10328 //
10329 // Rules accepted earlier in the same batch count too: the overlay
10330 // carries their arcs, so a cycle cannot be assembled one op at a time.
10331 if let Some(via) = def.via_edge.as_deref() {
10332 if via == def.edge_type {
10333 return Err(GraphError::RuleInvalid {
10334 detail: format!("rule chain cycle: {} -> {}", via, def.edge_type),
10335 });
10336 }
10337 let mut arcs: Vec<(String, String)> = self
10338 .db
10339 .engine
10340 .rules()
10341 .filter(|r| !self.overlay.deleted_rules.contains(&r.name))
10342 .filter_map(|r| r.via_edge.clone().map(|v| (v, r.edge_type.clone())))
10343 .collect();
10344 arcs.extend(self.overlay.extra_rule_arcs.values().cloned());
10345 arcs.push((via.to_string(), def.edge_type.clone()));
10346 if let Some(path) = find_cycle_through(&arcs, &def.edge_type, via) {
10347 return Err(GraphError::RuleInvalid {
10348 detail: format!("rule chain cycle: {} -> {}", via, path.join(" -> ")),
10349 });
10350 }
10351 }
10352 Ok(())
10353 }
10354
10355 fn check_delete_rule(&self, name: &str) -> Result<()> {
10356 if self.has_rule(name) {
10357 Ok(())
10358 } else {
10359 Err(GraphError::RuleNotFound { name: name.into() })
10360 }
10361 }
10362
10363 fn note_insert_node(&mut self, key: &str, props: &[(String, Value)]) {
10364 self.overlay.deleted_keys.remove(key);
10365 self.overlay.extra_keys.insert(key.to_string());
10366 self.overlay.extra_props.retain(|(k, _), _| k != key);
10367 self.overlay.removed_props.retain(|(k, _)| k != key);
10368 for (field, value) in props {
10369 self.overlay
10370 .extra_props
10371 .insert((key.to_string(), field.clone()), value.clone());
10372 }
10373 }
10374
10375 fn note_insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) {
10376 let k = (
10377 edge_type.to_string(),
10378 src_key.to_string(),
10379 dst_key.to_string(),
10380 );
10381 self.overlay.deleted_edges.remove(&k);
10382 self.overlay.extra_edges.insert(k);
10383 }
10384
10385 fn note_set_prop(&mut self, key: &str, field: &str, value: &Value) {
10386 let k = (key.to_string(), field.to_string());
10387 self.overlay.removed_props.remove(&k);
10388 self.overlay.extra_props.insert(k, value.clone());
10389 }
10390
10391 fn note_remove_prop(&mut self, key: &str, field: &str) {
10392 let k = (key.to_string(), field.to_string());
10393 self.overlay.extra_props.remove(&k);
10394 self.overlay.removed_props.insert(k);
10395 }
10396
10397 fn note_delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) {
10398 let k = (
10399 edge_type.to_string(),
10400 src_key.to_string(),
10401 dst_key.to_string(),
10402 );
10403 self.overlay.extra_edges.remove(&k);
10404 self.overlay.deleted_edges.insert(k);
10405 }
10406
10407 fn note_delete_node(&mut self, key: &str) {
10408 self.overlay.extra_keys.remove(key);
10409 self.overlay.deleted_keys.insert(key.to_string());
10410 self.overlay.extra_props.retain(|(k, _), _| k != key);
10411 self.overlay.removed_props.retain(|(k, _)| k != key);
10412 self.overlay
10413 .extra_edges
10414 .retain(|(_, s, d)| s != key && d != key);
10415 self.overlay
10416 .deleted_edges
10417 .retain(|(_, s, d)| s != key && d != key);
10418 }
10419
10420 fn note_create_rule(&mut self, def: &RuleDef) {
10421 self.overlay.deleted_rules.remove(&def.name);
10422 self.overlay.extra_rules.insert(def.name.clone());
10423 // Rules accepted earlier in this batch are not in the engine yet, so
10424 // the cycle check would not see their arcs. Keep the arc, not just the
10425 // name, so a batch cannot smuggle in a cycle one op at a time.
10426 if let Some(via) = def.via_edge.clone() {
10427 self.overlay
10428 .extra_rule_arcs
10429 .insert(def.name.clone(), (via, def.edge_type.clone()));
10430 }
10431 }
10432
10433 fn check_rename_node(&self, old: &str, new: &str) -> Result<()> {
10434 if !self.has_key(old) {
10435 return Err(GraphError::KeyNotFound { key: old.into() });
10436 }
10437 if self.has_key(new) {
10438 return Err(GraphError::DuplicateKey { key: new.into() });
10439 }
10440 Ok(())
10441 }
10442
10443 fn note_rename_node(&mut self, old: &str, new: &str) {
10444 // Mark old as deleted so subsequent batch ops cannot reference it.
10445 self.overlay.extra_keys.remove(old);
10446 self.overlay.deleted_keys.insert(old.to_string());
10447 // Mark new as extra so subsequent batch ops can reference it.
10448 self.overlay.deleted_keys.remove(new);
10449 self.overlay.extra_keys.insert(new.to_string());
10450 // Migrate any overlay props from old key to new key.
10451 let new_str = new.to_string();
10452 let transferred: Vec<((String, String), Value)> = self
10453 .overlay
10454 .extra_props
10455 .iter()
10456 .filter(|((k, _), _)| k.as_str() == old)
10457 .map(|((_, f), v)| ((new_str.clone(), f.clone()), v.clone()))
10458 .collect();
10459 self.overlay
10460 .extra_props
10461 .retain(|(k, _), _| k.as_str() != old);
10462 for (k, v) in transferred {
10463 self.overlay.extra_props.insert(k, v);
10464 }
10465 // Migrate removed_props.
10466 let transferred_removed: Vec<(String, String)> = self
10467 .overlay
10468 .removed_props
10469 .iter()
10470 .filter(|(k, _)| k.as_str() == old)
10471 .map(|(_, f)| (new_str.clone(), f.clone()))
10472 .collect();
10473 self.overlay
10474 .removed_props
10475 .retain(|(k, _)| k.as_str() != old);
10476 for k in transferred_removed {
10477 self.overlay.removed_props.insert(k);
10478 }
10479 }
10480
10481 fn note_delete_rule(&mut self, name: &str) {
10482 self.overlay.extra_rules.remove(name);
10483 // Drop its chain arc too: a rule created and then deleted in the same
10484 // batch must not make a later, legal rule look like a cycle.
10485 self.overlay.extra_rule_arcs.remove(name);
10486 self.overlay.deleted_rules.insert(name.to_string());
10487 // Treat the deleted rule's current provenance as gone so a later
10488 // delete_edge of those triples is a no-op (matches sequential).
10489 if let Some(triples) = self.db.engine.provenance().get(name) {
10490 for &(et, s, d) in triples {
10491 let Some(etype) = self.db.syms.resolve(et) else {
10492 continue;
10493 };
10494 let Some(src) = self.db.ids.key_of(s) else {
10495 continue;
10496 };
10497 let Some(dst) = self.db.ids.key_of(d) else {
10498 continue;
10499 };
10500 let k = (etype.to_string(), src.to_string(), dst.to_string());
10501 self.overlay.extra_edges.remove(&k);
10502 self.overlay.deleted_edges.insert(k);
10503 }
10504 }
10505 }
10506}
10507
10508/// Collects mutations and commits them as one WAL `Batch` frame.
10509///
10510/// Holds `&mut GraphDb` for its lifetime. Queue with the same method names
10511/// as [`GraphDb`]; call [`commit`](Self::commit) to validate, log, and apply.
10512/// See [`GraphDb::batch`] for validation and atomicity rules.
10513pub struct BatchBuilder<'a, F: Fs> {
10514 db: &'a mut GraphDb<F>,
10515 ops: Vec<BatchOp>,
10516}
10517
10518impl<'a, F: Fs> BatchBuilder<'a, F> {
10519 pub fn insert_node(
10520 &mut self,
10521 label: &str,
10522 key: &str,
10523 props: Vec<(String, Value)>,
10524 ) -> &mut Self {
10525 self.ops.push(BatchOp::InsertNode {
10526 label: label.into(),
10527 key: key.into(),
10528 props,
10529 });
10530 self
10531 }
10532
10533 pub fn insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> &mut Self {
10534 self.ops.push(BatchOp::InsertEdge {
10535 edge_type: edge_type.into(),
10536 src_key: src_key.into(),
10537 dst_key: dst_key.into(),
10538 });
10539 self
10540 }
10541
10542 pub fn set_prop(&mut self, key: &str, field: &str, value: Value) -> &mut Self {
10543 self.ops.push(BatchOp::SetProp {
10544 key: key.into(),
10545 field: field.into(),
10546 value,
10547 });
10548 self
10549 }
10550
10551 pub fn remove_prop(&mut self, key: &str, field: &str) -> &mut Self {
10552 self.ops.push(BatchOp::RemoveProp {
10553 key: key.into(),
10554 field: field.into(),
10555 });
10556 self
10557 }
10558
10559 pub fn delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> &mut Self {
10560 self.ops.push(BatchOp::DeleteEdge {
10561 edge_type: edge_type.into(),
10562 src_key: src_key.into(),
10563 dst_key: dst_key.into(),
10564 });
10565 self
10566 }
10567
10568 pub fn delete_node(&mut self, key: &str) -> &mut Self {
10569 self.ops.push(BatchOp::DeleteNode { key: key.into() });
10570 self
10571 }
10572
10573 pub fn create_rule(&mut self, def: RuleDef) -> &mut Self {
10574 self.ops.push(BatchOp::CreateRule(def));
10575 self
10576 }
10577
10578 pub fn delete_rule(&mut self, name: &str) -> &mut Self {
10579 self.ops.push(BatchOp::DeleteRule { name: name.into() });
10580 self
10581 }
10582
10583 /// Queue a node-rename in this batch.
10584 ///
10585 /// Validation (old exists, new not taken) runs at commit time.
10586 pub fn rename_node(&mut self, old_key: &str, new_key: &str) -> &mut Self {
10587 self.ops.push(BatchOp::RenameNode {
10588 old_key: old_key.into(),
10589 new_key: new_key.into(),
10590 });
10591 self
10592 }
10593
10594 /// Queue an edge insert with endpoint auto-creation.
10595 ///
10596 /// Any missing endpoint is created as a plain node `{key, label:
10597 /// placeholder_label, no props}` inside this batch frame. Rules fire and
10598 /// last-change is updated for each auto-created node.
10599 pub fn insert_edge_upsert(
10600 &mut self,
10601 edge_type: &str,
10602 src_key: &str,
10603 dst_key: &str,
10604 placeholder_label: &str,
10605 ) -> &mut Self {
10606 self.ops.push(BatchOp::InsertEdgeUpsert {
10607 edge_type: edge_type.into(),
10608 src_key: src_key.into(),
10609 dst_key: dst_key.into(),
10610 placeholder_label: placeholder_label.into(),
10611 });
10612 self
10613 }
10614
10615 /// Validate every queued op, then log one `Batch` frame and apply.
10616 /// Empty / all-noop batches return `Ok(())` without writing the WAL.
10617 /// A second `commit()` after a successful one is an empty-batch no-op
10618 /// (queued ops were taken).
10619 /// Takes `&mut self` so it chains after the queue methods (`b.insert_node(..).commit()`)
10620 /// and also works as `let mut b = db.batch(); b.insert_node(..); b.commit()`.
10621 ///
10622 /// **Rule-window limitation:** batch validation cannot see edges that a
10623 /// rule created earlier in the *same* batch will derive at apply time, so
10624 /// a `delete_edge` / `insert_edge` in that window is silently no-oped
10625 /// where sequential calls would return `Err(RuleOwned)`. State integrity
10626 /// is unaffected (idempotent apply, provenance intact). Create rules in
10627 /// their own batch, or sequentially, when later ops may touch derived
10628 /// edges.
10629 /// Validate every queued op and commit atomically.
10630 ///
10631 /// Returns `(nodes_inserted, edges_inserted)` — the counts of node and edge
10632 /// WAL records actually written (duplicate edges are silent no-ops and are
10633 /// NOT counted). Both are 0 when the batch is empty or all-noop.
10634 pub fn commit(&mut self) -> Result<(usize, usize)> {
10635 let ops = std::mem::take(&mut self.ops);
10636 self.db.commit_batch(ops)
10637 }
10638
10639 /// Same as [`commit`](Self::commit) but tail the inner events with
10640 /// [`MutationEvent::Ingested`] instead of [`MutationEvent::BatchApplied`].
10641 pub(crate) fn commit_ingest(&mut self, label: &str, inserted: usize) -> Result<(usize, usize)> {
10642 let ops = std::mem::take(&mut self.ops);
10643 self.db
10644 .commit_logged_batch(ops, Some((label.to_string(), inserted)), None)
10645 }
10646}
10647
10648pub struct NodeRef<'a, F: Fs> {
10649 db: &'a GraphDb<F>,
10650 id: u32,
10651}
10652
10653impl<'a, F: Fs> NodeRef<'a, F> {
10654 pub fn key(&self) -> &str {
10655 self.db.ids.key_of(self.id).expect("dense ids")
10656 }
10657
10658 pub fn label(&self) -> &str {
10659 let sym = self
10660 .db
10661 .labels
10662 .get(self.id as usize)
10663 .copied()
10664 .filter(|&s| s != u32::MAX)
10665 .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
10666 self.db.syms.resolve(sym).expect("interned label symbol")
10667 }
10668
10669 pub fn prop(&self, field: &str) -> Option<Value> {
10670 self.db
10671 .props_view()
10672 .get(self.id, field)
10673 .map(|vr| vr.into_value())
10674 }
10675
10676 /// All stored fields for this node, sorted by field name.
10677 ///
10678 /// Reads from the full base+overlay view so that props stored only in the
10679 /// V8 snapshot base (i.e. before any post-snapshot WAL writes) are visible.
10680 pub fn props(&self) -> BTreeMap<String, Value> {
10681 let mut out = BTreeMap::new();
10682 let pv = self.db.props_view();
10683 for field in pv.field_names() {
10684 if let Some(vr) = pv.get(self.id, &field) {
10685 out.insert(field, vr.into_value());
10686 }
10687 }
10688 out
10689 }
10690
10691 /// depth-N BFS as a ResultSet: columns ["key","label","depth"], BFS order.
10692 pub fn neighborhood(&self, depth: u32, edge_types: Option<&[&str]>, dir: Dir) -> ResultSet {
10693 let view = self.db.view();
10694 let resolved: Option<Vec<u32>> = edge_types.map(|names| {
10695 names
10696 .iter()
10697 .filter_map(|name| view.syms.get(name))
10698 .collect()
10699 });
10700 let nb = neighborhood(&view, self.id, depth, resolved.as_deref(), dir);
10701 let mut rs = ResultSet::new(vec!["key".into(), "label".into(), "depth".into()]);
10702 for (nid, d) in nb.nodes {
10703 let key = view.key_of(nid);
10704 let label = view
10705 .label_of(nid)
10706 .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
10707 rs.push_row(vec![
10708 Some(Value::Str(key.to_string())),
10709 Some(Value::Str(label.to_string())),
10710 Some(Value::Int(d as i64)),
10711 ]);
10712 }
10713 rs
10714 }
10715
10716 /// 1-hop, Both directions: edge-type name → sorted unique neighbor keys.
10717 pub fn grouped_by_edge_type(&self) -> BTreeMap<String, Vec<String>> {
10718 let view = self.db.view();
10719 let mut groups: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
10720 for e in expand(&view, self.id, None, Dir::Both) {
10721 // Skip edges with unknown etypes (only possible from corrupt large
10722 // TOPOLOGY section; function returns BTreeMap not Result).
10723 let Some(etype) = view.syms.resolve(e.etype) else {
10724 continue;
10725 };
10726 let etype = etype.to_string();
10727 let nbr = if e.src == self.id { e.dst } else { e.src };
10728 groups
10729 .entry(etype)
10730 .or_default()
10731 .insert(view.key_of(nbr).to_string());
10732 }
10733 groups
10734 .into_iter()
10735 .map(|(k, v)| (k, v.into_iter().collect()))
10736 .collect()
10737 }
10738}
10739
10740#[cfg(test)]
10741mod tests {
10742 use super::*;
10743 use core_rules::Predicate;
10744
10745 fn tmp_dir(name: &str) -> std::path::PathBuf {
10746 let d =
10747 std::env::temp_dir().join(format!("graphdb-db-unit-{}-{}", name, std::process::id()));
10748 let _ = std::fs::remove_dir_all(&d);
10749 d
10750 }
10751
10752 fn fk_rule() -> RuleDef {
10753 RuleDef {
10754 name: "works_at".into(),
10755 src_label: "Person".into(),
10756 dst_label: "Org".into(),
10757 predicate: Predicate::KeyMatch {
10758 field: "org_id".into(),
10759 },
10760 edge_type: "WORKS_AT".into(),
10761 weight_prop: None,
10762 max_edges: None,
10763 approximate: false,
10764 via_label: None,
10765 via_edge: None,
10766 via_dir: None,
10767 }
10768 }
10769
10770 /// Regression guard for the no-views delta-copy fast path.
10771 ///
10772 /// When no views are defined, `pending_deltas_since().to_vec()` must never
10773 /// be called — even during a large CreateRule backfill. The DELTA_COPY_COUNT
10774 /// thread-local is incremented inside every `if !view_store.is_empty()` block;
10775 /// a count of 0 after the entire sequence proves the guard fires correctly.
10776 #[test]
10777 fn no_delta_copy_when_no_views() {
10778 DELTA_COPY_COUNT.with(|c| c.set(0));
10779 let dir = tmp_dir("no-delta-copy");
10780 {
10781 let mut db = GraphDb::open(&dir).unwrap();
10782 // Insert 50 Org + 50 Person nodes with FK links.
10783 for i in 0..50u32 {
10784 db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
10785 }
10786 for i in 0..50u32 {
10787 db.insert_node(
10788 "Person",
10789 &format!("p{i}"),
10790 vec![("org_id".into(), Value::Str(format!("o{i}")))],
10791 )
10792 .unwrap();
10793 }
10794 // CreateRule backfill should NOT invoke to_vec() when no views are defined.
10795 db.create_rule(fk_rule()).unwrap();
10796
10797 // Counter must stay 0 — no views, no copies.
10798 let copies = DELTA_COPY_COUNT.with(|c| c.get());
10799 assert_eq!(
10800 copies, 0,
10801 "pending_deltas_since().to_vec() called despite no views"
10802 );
10803
10804 // Derived edges must still be correct (the guard skips only the
10805 // empty delta propagation loop, not the rule application itself).
10806 let nbrs = db.neighbors("p0", "WORKS_AT", Direction::Out).unwrap();
10807 assert_eq!(
10808 nbrs,
10809 vec!["o0"],
10810 "rule must derive edges even with no views"
10811 );
10812 }
10813 let _ = std::fs::remove_dir_all(&dir);
10814 }
10815
10816 /// Gating regression: subscribe AFTER a backfill must see no stale events.
10817 /// subscribe BEFORE a backfill must see every edge-fire event.
10818 #[test]
10819 fn subscribe_after_backfill_no_stale_events() {
10820 let dir = tmp_dir("sub-after-backfill");
10821 {
10822 let mut db = GraphDb::open(&dir).unwrap();
10823 for i in 0..10u32 {
10824 db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
10825 db.insert_node(
10826 "Person",
10827 &format!("p{i}"),
10828 vec![("org_id".into(), Value::Str(format!("o{i}")))],
10829 )
10830 .unwrap();
10831 }
10832 // Create rule BEFORE subscribing — emit_deltas is false during backfill.
10833 db.create_rule(fk_rule()).unwrap();
10834
10835 // Subscribe AFTER the backfill — queue must be empty (no stale events).
10836 let sub = db.subscribe_all_rules().unwrap();
10837 // No events should have queued for the prior backfill.
10838 assert!(
10839 sub.try_recv().is_none(),
10840 "subscribe after backfill must see no stale events"
10841 );
10842
10843 // Inserting a new node now should fire an event (emit_deltas is now true).
10844 db.insert_node("Org", "o_new", vec![]).unwrap();
10845 db.insert_node(
10846 "Person",
10847 "p_new",
10848 vec![("org_id".into(), Value::Str("o_new".into()))],
10849 )
10850 .unwrap();
10851 let ev = sub.recv_timeout(std::time::Duration::from_millis(200));
10852 assert!(
10853 ev.is_some(),
10854 "edge-fire event must arrive after subscribe (emit_deltas=true)"
10855 );
10856 }
10857 let _ = std::fs::remove_dir_all(&dir);
10858 }
10859
10860 /// Gating regression: subscribe BEFORE a backfill → events flow.
10861 #[test]
10862 fn subscribe_before_backfill_events_flow() {
10863 let dir = tmp_dir("sub-before-backfill");
10864 {
10865 let mut db = GraphDb::open(&dir).unwrap();
10866 // Subscribe FIRST — emit_deltas becomes true.
10867 let sub = db.subscribe_all_rules().unwrap();
10868
10869 for i in 0..5u32 {
10870 db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
10871 db.insert_node(
10872 "Person",
10873 &format!("p{i}"),
10874 vec![("org_id".into(), Value::Str(format!("o{i}")))],
10875 )
10876 .unwrap();
10877 }
10878 // Backfill fires with emit_deltas=true → events queued.
10879 db.create_rule(fk_rule()).unwrap();
10880
10881 // Should receive at least one edge-fired event from the backfill.
10882 let mut received = 0usize;
10883 while sub.try_recv().is_some() {
10884 received += 1;
10885 }
10886 assert!(
10887 received > 0,
10888 "subscribe before backfill must receive edge-fire events (got 0)"
10889 );
10890 }
10891 let _ = std::fs::remove_dir_all(&dir);
10892 }
10893
10894 /// Companion: when a view IS defined, the delta path fires and view values update.
10895 #[test]
10896 fn delta_copy_fires_when_view_exists() {
10897 use core_rules::ViewSource;
10898 DELTA_COPY_COUNT.with(|c| c.set(0));
10899 let dir = tmp_dir("delta-copy-with-view");
10900 {
10901 let mut db = GraphDb::open(&dir).unwrap();
10902 db.insert_node("Org", "o1", vec![]).unwrap();
10903 db.insert_node(
10904 "Person",
10905 "p1",
10906 vec![("org_id".into(), Value::Str("o1".into()))],
10907 )
10908 .unwrap();
10909 // Declare a Degree view so is_empty() returns false.
10910 db.create_view(ViewDef {
10911 name: "degree_out".into(),
10912 label: "Person".into(),
10913 view_prop: "degree_out".into(),
10914 source: ViewSource::Degree {
10915 edge_type: "WORKS_AT".into(),
10916 direction: Direction::Out,
10917 },
10918 })
10919 .unwrap();
10920 db.create_rule(fk_rule()).unwrap();
10921
10922 // At least one delta copy should have happened (CreateRule backfill).
10923 let copies = DELTA_COPY_COUNT.with(|c| c.get());
10924 assert!(
10925 copies > 0,
10926 "expected delta copy to fire when a view is defined"
10927 );
10928
10929 // View value should be computed: p1 has one WORKS_AT out-edge.
10930 let info = db.node_info("p1").unwrap();
10931 let degree = info.props.get("degree_out");
10932 assert!(
10933 degree.is_some(),
10934 "view prop should be written to node props"
10935 );
10936 }
10937 let _ = std::fs::remove_dir_all(&dir);
10938 }
10939
10940 /// Regression: `open_at_with` must call `rebuild_all` after WAL replay so
10941 /// derived-edge-driven view values reflect the as-of state rather than just
10942 /// the initial backfill written at `CreateView` time.
10943 ///
10944 /// Base WAL frames (indices 0..=5 before history markers):
10945 /// 0: insert Org "o1"
10946 /// 1: create_view "employee_count" (Degree / WORKS_AT / In) on Org
10947 /// 2: create_rule fk_rule (WORKS_AT, Person→Org via org_id)
10948 /// 3: insert Person "p1" → rule fires WORKS_AT p1→o1 (degree = 1) ← mid
10949 /// 4: insert Person "p2" → rule fires WORKS_AT p2→o1 (degree = 2)
10950 /// 5: insert Person "p3" → rule fires WORKS_AT p3→o1 (degree = 3) ← latest
10951 ///
10952 /// Each rule-fire also appends a DerivedEdgeAdded history-marker frame (state
10953 /// no-op), so the total commit count is higher than the base frame count.
10954 /// The "latest" open_at commit is computed dynamically via `wal_commit_count_at`.
10955 ///
10956 /// Without `rebuild_all`, the as-of instance's "emp" view stays at the
10957 /// initial backfill value (0) instead of reflecting the replayed derived edges.
10958 #[test]
10959 fn open_at_derived_edge_view_values_correct() {
10960 use core_rules::ViewSource;
10961 let dir = tmp_dir("open-at-view-rebuild");
10962 {
10963 let mut db = GraphDb::open(&dir).unwrap();
10964 // frame 0
10965 db.insert_node("Org", "o1", vec![]).unwrap();
10966 // frame 1: create view — initial backfill sees 0 derived edges (none fired yet)
10967 db.create_view(ViewDef {
10968 name: "employee_count".into(),
10969 label: "Org".into(),
10970 view_prop: "emp".into(),
10971 source: ViewSource::Degree {
10972 edge_type: "WORKS_AT".into(),
10973 direction: Direction::In,
10974 },
10975 })
10976 .unwrap();
10977 // frame 2: create rule — no Persons yet; backfill is a no-op
10978 db.create_rule(fk_rule()).unwrap();
10979 // frame 3: p1 — rule fires WORKS_AT p1→o1; degree = 1
10980 db.insert_node(
10981 "Person",
10982 "p1",
10983 vec![("org_id".into(), Value::Str("o1".into()))],
10984 )
10985 .unwrap();
10986 // frame 4: p2 — degree = 2
10987 db.insert_node(
10988 "Person",
10989 "p2",
10990 vec![("org_id".into(), Value::Str("o1".into()))],
10991 )
10992 .unwrap();
10993 // frame 5: p3 — degree = 3
10994 db.insert_node(
10995 "Person",
10996 "p3",
10997 vec![("org_id".into(), Value::Str("o1".into()))],
10998 )
10999 .unwrap();
11000 // Sanity: normal open sees degree = 3.
11001 assert_eq!(
11002 db.get_view_prop("o1", "emp"),
11003 Some(Value::Int(3)),
11004 "normal db must show degree 3 after 3 derived edges"
11005 );
11006 } // WAL flushed
11007
11008 // Re-open normally to get the authoritative reference value.
11009 let normal_db = GraphDb::open(&dir).unwrap();
11010 let normal_emp = normal_db.get_view_prop("o1", "emp");
11011 assert_eq!(
11012 normal_emp,
11013 Some(Value::Int(3)),
11014 "re-opened normal db must show degree 3"
11015 );
11016
11017 // Latest as-of (last WAL commit): must match the normal open.
11018 // History-marker frames are appended after each rule-fire, so the total
11019 // commit count is computed dynamically rather than hardcoded.
11020 let total = crate::wal_commit_count_at(&dir).unwrap();
11021 let aof_latest = GraphDb::open_at(&dir, total - 1).unwrap();
11022 assert_eq!(
11023 aof_latest.get_view_prop("o1", "emp"),
11024 normal_emp,
11025 "open_at latest: derived-edge view must equal normal open (rebuild_all required)"
11026 );
11027
11028 // Mid-history as-of (commit 3 = p1 insert Batch frame): only p1; degree = 1.
11029 // The DerivedEdgeAdded marker for p1 is at frame 4 (state no-op on replay),
11030 // so replaying 0..=3 correctly re-derives only the p1→o1 edge.
11031 let aof_mid = GraphDb::open_at(&dir, 3).unwrap();
11032 assert_eq!(
11033 aof_mid.get_view_prop("o1", "emp"),
11034 Some(Value::Int(1)),
11035 "open_at mid-history: only p1 exists at frame 3, degree must be 1"
11036 );
11037
11038 let _ = std::fs::remove_dir_all(&dir);
11039 }
11040
11041 /// Pin: subscribe_* on an as-of instance must return Err(ReadOnly) —
11042 /// as-of instances never commit, so distribute_events never runs and any
11043 /// subscription would wait forever.
11044 #[test]
11045 fn subscribe_on_as_of_returns_read_only_error() {
11046 let dir = tmp_dir("sub-as-of-read-only");
11047 {
11048 let mut db = GraphDb::open(&dir).unwrap();
11049 db.insert_node("Org", "o1", vec![]).unwrap();
11050 db.create_rule(fk_rule()).unwrap();
11051 }
11052 let mut aof = GraphDb::open_at(&dir, 0).unwrap();
11053
11054 assert!(
11055 matches!(
11056 aof.subscribe_all_rules(),
11057 Err(core_storage::GraphError::ReadOnly)
11058 ),
11059 "subscribe_all_rules on as-of must return ReadOnly"
11060 );
11061 assert!(
11062 matches!(
11063 aof.subscribe_writes(),
11064 Err(core_storage::GraphError::ReadOnly)
11065 ),
11066 "subscribe_writes on as-of must return ReadOnly"
11067 );
11068 assert!(
11069 matches!(
11070 aof.subscribe_rule("works_at"),
11071 Err(core_storage::GraphError::ReadOnly)
11072 ),
11073 "subscribe_rule on as-of must return ReadOnly"
11074 );
11075 let _ = std::fs::remove_dir_all(&dir);
11076 }
11077
11078 /// Regression: a failed dense WAL rewrite must not leave speculative
11079 /// interns in `syms`. If it does, the next successful mutation logs an
11080 /// `Intern` record with an inflated id; replay (which never saw the
11081 /// orphans) assigns a smaller id and the WAL becomes unreplayable.
11082 #[test]
11083 fn dense_rewrite_error_rolls_back_speculative_interns() {
11084 let dir = tmp_dir("dense-rewrite-rollback");
11085 {
11086 let mut db = GraphDb::open(&dir).unwrap();
11087 db.insert_node("Person", "a", vec![]).unwrap();
11088
11089 // Bypass MutPreview validation to hit the rewrite's own error path
11090 // (same shape as an id-exhaustion failure mid-rewrite). The
11091 // InsertEdge arm interns the edge type before it resolves keys.
11092 let err = db.rewrite_wal_dense(vec![WalRecord::InsertEdge {
11093 edge_type: "ORPHAN_TYPE".into(),
11094 src_key: "missing".into(),
11095 dst_key: "a".into(),
11096 }]);
11097 assert!(err.is_err(), "rewrite of a missing src key must fail");
11098 assert_eq!(
11099 db.syms.get("ORPHAN_TYPE"),
11100 None,
11101 "failed rewrite must roll back speculative interns"
11102 );
11103
11104 // A later successful mutation must produce a replayable WAL.
11105 db.set_prop("a", "later_field", Value::Int(2)).unwrap();
11106 }
11107 let db = GraphDb::open(&dir).expect("WAL must replay after failed rewrite");
11108 assert_eq!(db.get_prop("a", "later_field"), Some(Value::Int(2)));
11109 let _ = std::fs::remove_dir_all(&dir);
11110 }
11111}